diff --git a/README.md b/README.md index 8ea4ecb3..b21a89c5 100644 --- a/README.md +++ b/README.md @@ -268,9 +268,10 @@ import { withBindings } from '@stoprocent/noble'; const noble = withBindings('default'); // Specific bindings -const nobleHci = withBindings('hci'); // HCI socket binding -const nobleMac = withBindings('mac'); // macOS binding -const nobleWin = withBindings('win'); // Windows binding +const nobleHci = withBindings('hci'); // HCI socket binding +const nobleDbus = withBindings('dbus'); // BlueZ D-Bus binding (Linux desktop) +const nobleMac = withBindings('mac'); // macOS binding +const nobleWin = withBindings('win'); // Windows binding // Custom options for HCI binding (Using UART HCI Dongle) const nobleCustom = withBindings('hci', { @@ -288,6 +289,23 @@ const nobleCustom = withBindings('hci', { hciDriver: 'native', deviceId: 0 // This could be also set by env.NOBLE_HCI_DEVICE_ID=0 }); + +// D-Bus / BlueZ binding (Linux desktop). Talks to bluetoothd over org.bluez, +// so it coexists with the system Bluetooth stack and does not need root / +// CAP_NET_ADMIN. Requires the `dbus-next` package to be installed in the +// host project — it is not bundled, since it is only useful on Linux: +// +// npm install dbus-next +// +// Supports basic GATT: scan, connect, service/characteristic/descriptor +// discovery, read, write, notify/indicate. Does not support raw HCI handle +// I/O, custom scan parameters, or vendor-specific commands. +const nobleDbus = withBindings('dbus', { + adapterId: 'hci0' // optional; defaults to the first BlueZ adapter +}); + +// Equivalent to withBindings('dbus') without code changes: +// NOBLE_BINDINGS=dbus node app.js ``` ### Core Methods diff --git a/lib/dbus/bindings.js b/lib/dbus/bindings.js new file mode 100644 index 00000000..79cb3125 --- /dev/null +++ b/lib/dbus/bindings.js @@ -0,0 +1,783 @@ +const { EventEmitter } = require('events'); +const debug = require('debug')('noble-dbus'); + +const { + normalizeUuid, + expandUuid, + addressToId, + devicePathToAddress, + deviceIdFromPath, + devicePathFromAddress +} = require('./uuid'); + +const BLUEZ_SERVICE = 'org.bluez'; +const ROOT_PATH = '/'; +const ADAPTER_IFACE = 'org.bluez.Adapter1'; +const DEVICE_IFACE = 'org.bluez.Device1'; +const GATT_SERVICE_IFACE = 'org.bluez.GattService1'; +const GATT_CHAR_IFACE = 'org.bluez.GattCharacteristic1'; +const GATT_DESC_IFACE = 'org.bluez.GattDescriptor1'; +const PROPS_IFACE = 'org.freedesktop.DBus.Properties'; +const OBJECT_MANAGER_IFACE = 'org.freedesktop.DBus.ObjectManager'; + +const FLAG_TO_PROPERTY = { + broadcast: 'broadcast', + read: 'read', + 'write-without-response': 'writeWithoutResponse', + write: 'write', + notify: 'notify', + indicate: 'indicate', + 'authenticated-signed-writes': 'authenticatedSignedWrites', + 'reliable-write': 'extendedProperties', + 'writable-auxiliaries': 'extendedProperties' +}; + +function loadDbus () { + try { + // dbus-next is an optional peer of this Linux-only backend; the host + // project installs it explicitly. eslint-plugin-node would otherwise + // flag the missing module on platforms where it is not present. + // eslint-disable-next-line node/no-missing-require + return require('dbus-next'); + } catch (err) { + const wrapped = new Error( + 'noble dbus backend requires the "dbus-next" package. ' + + 'Install it with: npm install dbus-next' + ); + wrapped.cause = err; + throw wrapped; + } +} + +function unwrapVariant (variant) { + if (variant && typeof variant === 'object' && 'value' in variant && 'signature' in variant) { + return variant.value; + } + return variant; +} + +function unwrapDict (dict) { + const out = {}; + if (!dict) return out; + for (const key of Object.keys(dict)) { + out[key] = unwrapVariant(dict[key]); + } + return out; +} + +function flagsToProperties (flags) { + const set = new Set(); + for (const flag of flags || []) { + const mapped = FLAG_TO_PROPERTY[flag]; + if (mapped) set.add(mapped); + } + return Array.from(set); +} + +function buildAdvertisement (deviceProps) { + const advertisement = { + localName: undefined, + txPowerLevel: undefined, + manufacturerData: undefined, + serviceData: [], + serviceUuids: [], + serviceSolicitationUuids: [] + }; + + if (typeof deviceProps.Name === 'string') { + advertisement.localName = deviceProps.Name; + } else if (typeof deviceProps.Alias === 'string') { + advertisement.localName = deviceProps.Alias; + } + + if (typeof deviceProps.TxPower === 'number') { + advertisement.txPowerLevel = deviceProps.TxPower; + } + + if (Array.isArray(deviceProps.UUIDs)) { + advertisement.serviceUuids = deviceProps.UUIDs.map(normalizeUuid); + } + + if (deviceProps.ManufacturerData && typeof deviceProps.ManufacturerData === 'object') { + const entries = Object.entries(deviceProps.ManufacturerData); + if (entries.length > 0) { + const buffers = []; + for (const [companyId, payload] of entries) { + const id = Number(companyId) & 0xffff; + const header = Buffer.from([id & 0xff, (id >> 8) & 0xff]); + const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); + buffers.push(Buffer.concat([header, data])); + } + advertisement.manufacturerData = buffers.length === 1 ? buffers[0] : Buffer.concat(buffers); + } + } + + if (deviceProps.ServiceData && typeof deviceProps.ServiceData === 'object') { + for (const [uuid, payload] of Object.entries(deviceProps.ServiceData)) { + advertisement.serviceData.push({ + uuid: normalizeUuid(uuid), + data: Buffer.isBuffer(payload) ? payload : Buffer.from(payload) + }); + } + } + + return advertisement; +} + +class DbusBindings extends EventEmitter { + constructor (options = {}) { + super(); + this._options = options || {}; + this._dbus = null; + this._bus = null; + this._rootProxy = null; + this._objectManager = null; + + this._adapterPath = null; + this._adapterProxy = null; + this._adapterIface = null; + this._adapterProps = null; + this._adapterAddress = 'unknown'; + this._state = 'unknown'; + this._isScanning = false; + + // Object tree mirror: path -> { iface: props } + this._objects = new Map(); + + // Discovery filter for service uuid filtering + this._scanServiceUuids = []; + + // Per-device live state + // id -> { path, address, addressType, connectable, scannable, rssi, advertisement, proxy, propsListener, connectPromise, servicesResolved } + this._devices = new Map(); + + // Per-characteristic notify listener: path -> { iface, listener } + this._charPropsListeners = new Map(); + + this._onInterfacesAddedBound = this._onInterfacesAdded.bind(this); + this._onInterfacesRemovedBound = this._onInterfacesRemoved.bind(this); + this._onAdapterPropsBound = this._onAdapterProperties.bind(this); + } + + start () { + this._dbus = loadDbus(); + this._bus = this._dbus.systemBus(); + this._init().catch(err => { + debug('init failed: %s', err && err.stack); + this.emit('warning', `dbus init failed: ${err.message}`); + this._state = 'unsupported'; + this.emit('stateChange', 'unsupported'); + }); + } + + async _init () { + this._rootProxy = await this._bus.getProxyObject(BLUEZ_SERVICE, ROOT_PATH); + this._objectManager = this._rootProxy.getInterface(OBJECT_MANAGER_IFACE); + this._objectManager.on('InterfacesAdded', this._onInterfacesAddedBound); + this._objectManager.on('InterfacesRemoved', this._onInterfacesRemovedBound); + + const managed = await this._objectManager.GetManagedObjects(); + for (const [path, ifaces] of Object.entries(managed)) { + const unwrapped = {}; + for (const [iface, props] of Object.entries(ifaces)) { + unwrapped[iface] = unwrapDict(props); + } + this._objects.set(path, unwrapped); + } + + const adapterPath = this._pickAdapterPath(); + if (!adapterPath) { + throw new Error('No BlueZ adapter found (is bluetoothd running?)'); + } + this._adapterPath = adapterPath; + + const adapterProxy = await this._bus.getProxyObject(BLUEZ_SERVICE, adapterPath); + this._adapterProxy = adapterProxy; + this._adapterIface = adapterProxy.getInterface(ADAPTER_IFACE); + this._adapterProps = adapterProxy.getInterface(PROPS_IFACE); + this._adapterProps.on('PropertiesChanged', this._onAdapterPropsBound); + + const adapterProps = this._objects.get(adapterPath)[ADAPTER_IFACE] || {}; + if (typeof adapterProps.Address === 'string') { + this._adapterAddress = adapterProps.Address; + this.emit('addressChange', adapterProps.Address); + } + + const powered = !!adapterProps.Powered; + this._setState(powered ? 'poweredOn' : 'poweredOff'); + + // Surface devices that already exist in BlueZ's cache as discovery events. + for (const [path, ifaces] of this._objects) { + if (ifaces[DEVICE_IFACE] && this._isUnderAdapter(path)) { + this._handleDeviceProps(path, ifaces[DEVICE_IFACE]); + } + } + } + + _pickAdapterPath () { + const requested = this._options.adapterId + || (this._options.hciDeviceId != null ? `hci${this._options.hciDeviceId}` : null); + let firstMatch = null; + for (const [path, ifaces] of this._objects) { + if (!ifaces[ADAPTER_IFACE]) continue; + if (!firstMatch) firstMatch = path; + if (requested && path.endsWith(`/${requested}`)) return path; + } + return firstMatch; + } + + _isUnderAdapter (path) { + return this._adapterPath && path.startsWith(`${this._adapterPath}/`); + } + + _setState (state) { + if (this._state === state) return; + this._state = state; + this.emit('stateChange', state); + } + + stop () { + if (this._objectManager) { + this._objectManager.off('InterfacesAdded', this._onInterfacesAddedBound); + this._objectManager.off('InterfacesRemoved', this._onInterfacesRemovedBound); + } + if (this._adapterProps) { + this._adapterProps.off('PropertiesChanged', this._onAdapterPropsBound); + } + for (const device of this._devices.values()) { + if (device.proxy && device.propsListener) { + const props = device.proxy.getInterface(PROPS_IFACE); + props.off('PropertiesChanged', device.propsListener); + } + } + for (const entry of this._charPropsListeners.values()) { + entry.props.off('PropertiesChanged', entry.listener); + } + this._charPropsListeners.clear(); + if (this._isScanning && this._adapterIface) { + this._adapterIface.StopDiscovery().catch(() => {}); + } + if (this._bus && typeof this._bus.disconnect === 'function') { + try { this._bus.disconnect(); } catch (_) { /* ignore */ } + } + } + + setScanParameters (_interval, _window) { + this.emit('warning', 'setScanParameters is not supported on the dbus backend (BlueZ controls scan parameters)'); + this.emit('scanParametersSet'); + } + + setAddress (_address) { + this.emit('warning', 'setAddress is not supported on the dbus backend'); + } + + startScanning (serviceUuids, allowDuplicates) { + this._scanServiceUuids = (serviceUuids || []).map(normalizeUuid); + this._startScanning(allowDuplicates).catch(err => { + this.emit('warning', `startScanning failed: ${err.message}`); + }); + } + + async _startScanning (allowDuplicates) { + if (!this._adapterIface) { + throw new Error('adapter not initialized'); + } + const { Variant } = this._dbus; + const filter = { + Transport: new Variant('s', 'le'), + DuplicateData: new Variant('b', !!allowDuplicates) + }; + if (this._scanServiceUuids.length > 0) { + filter.UUIDs = new Variant('as', this._scanServiceUuids.map(expandUuid)); + } + try { + await this._adapterIface.SetDiscoveryFilter(filter); + } catch (err) { + debug('SetDiscoveryFilter failed: %s', err.message); + } + if (!this._isScanning) { + await this._adapterIface.StartDiscovery(); + this._isScanning = true; + } + this.emit('scanStart', !!allowDuplicates); + } + + stopScanning () { + this._stopScanning().catch(err => { + this.emit('warning', `stopScanning failed: ${err.message}`); + this.emit('scanStop'); + }); + } + + async _stopScanning () { + if (this._isScanning && this._adapterIface) { + try { + await this._adapterIface.StopDiscovery(); + } catch (err) { + debug('StopDiscovery failed: %s', err.message); + } + this._isScanning = false; + } + this.emit('scanStop'); + } + + // ---- ObjectManager + property change handlers ---- + + _onInterfacesAdded (path, ifaces) { + const unwrapped = {}; + for (const [iface, props] of Object.entries(ifaces)) { + unwrapped[iface] = unwrapDict(props); + } + const existing = this._objects.get(path) || {}; + this._objects.set(path, Object.assign(existing, unwrapped)); + + if (unwrapped[DEVICE_IFACE] && this._isUnderAdapter(path)) { + this._handleDeviceProps(path, unwrapped[DEVICE_IFACE]); + } + // Trigger services-resolved processing if a device just gained ServicesResolved + if (unwrapped[GATT_SERVICE_IFACE] || unwrapped[GATT_CHAR_IFACE] || unwrapped[GATT_DESC_IFACE]) { + // No direct emit; clients call discoverServices/Characteristics/Descriptors. + } + } + + _onInterfacesRemoved (path, ifaces) { + const stored = this._objects.get(path); + if (stored) { + for (const iface of ifaces) delete stored[iface]; + if (Object.keys(stored).length === 0) { + this._objects.delete(path); + } + } + if (ifaces.includes(DEVICE_IFACE)) { + const id = deviceIdFromPath(path); + if (id && this._devices.has(id)) { + this._onDeviceDisconnected(id, 'removed'); + } + } + } + + _onAdapterProperties (iface, changed) { + if (iface !== ADAPTER_IFACE) return; + const props = unwrapDict(changed); + if ('Powered' in props) { + this._setState(props.Powered ? 'poweredOn' : 'poweredOff'); + } + if (typeof props.Address === 'string') { + this._adapterAddress = props.Address; + this.emit('addressChange', props.Address); + } + const stored = this._objects.get(this._adapterPath) || {}; + stored[ADAPTER_IFACE] = Object.assign(stored[ADAPTER_IFACE] || {}, props); + this._objects.set(this._adapterPath, stored); + } + + _handleDeviceProps (path, props) { + const address = props.Address || devicePathToAddress(path); + if (!address) return; + const id = addressToId(address); + let device = this._devices.get(id); + if (!device) { + device = { + path, + address, + addressType: props.AddressType || 'unknown', + connectable: true, + scannable: false, + rssi: typeof props.RSSI === 'number' ? props.RSSI : 0, + advertisement: buildAdvertisement(props), + proxy: null, + propsListener: null, + servicesResolved: !!props.ServicesResolved, + connectPromise: null + }; + this._devices.set(id, device); + } else { + device.path = path; + device.address = address; + if (props.AddressType) device.addressType = props.AddressType; + if (typeof props.RSSI === 'number') device.rssi = props.RSSI; + Object.assign(device.advertisement, buildAdvertisement(props)); + } + + this.emit( + 'discover', + id, + device.address, + device.addressType, + device.connectable, + device.advertisement, + device.rssi, + device.scannable + ); + } + + // ---- Per-device proxy + property listening ---- + + async _ensureDeviceProxy (id) { + const device = this._devices.get(id); + if (!device) throw new Error(`unknown peripheral ${id}`); + if (device.proxy) return device; + const path = device.path || devicePathFromAddress(this._adapterPath, device.address); + device.path = path; + device.proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path); + const props = device.proxy.getInterface(PROPS_IFACE); + device.propsListener = (iface, changed) => { + if (iface !== DEVICE_IFACE) return; + const c = unwrapDict(changed); + if ('RSSI' in c) { + device.rssi = c.RSSI; + this.emit('rssiUpdate', id, c.RSSI); + } + if ('Connected' in c) { + if (c.Connected) { + // Wait for ServicesResolved to fire 'connect' with services available. + // If ServicesResolved already true (cached device), fire now. + if (device.servicesResolved && device.connectPromise) { + device.connectPromise.resolve(); + device.connectPromise = null; + } + } else { + this._onDeviceDisconnected(id, 'remote'); + } + } + if ('ServicesResolved' in c) { + device.servicesResolved = !!c.ServicesResolved; + if (c.ServicesResolved && device.connectPromise) { + device.connectPromise.resolve(); + device.connectPromise = null; + } + } + }; + props.on('PropertiesChanged', device.propsListener); + return device; + } + + _onDeviceDisconnected (id, reason) { + const device = this._devices.get(id); + if (!device) return; + if (device.proxy && device.propsListener) { + const props = device.proxy.getInterface(PROPS_IFACE); + props.off('PropertiesChanged', device.propsListener); + } + device.proxy = null; + device.propsListener = null; + device.servicesResolved = false; + if (device.connectPromise) { + device.connectPromise.reject(new Error(`disconnected: ${reason}`)); + device.connectPromise = null; + } + this._removeDeviceCharListeners(id); + this.emit('disconnect', id, reason); + } + + _removeDeviceCharListeners (id) { + const device = this._devices.get(id); + if (!device || !device.path) return; + const prefix = `${device.path}/`; + for (const [path, entry] of this._charPropsListeners) { + if (path.startsWith(prefix)) { + entry.props.off('PropertiesChanged', entry.listener); + this._charPropsListeners.delete(path); + } + } + } + + // ---- Connect / disconnect ---- + + connect (peripheralUuid, _parameters) { + this._connect(peripheralUuid).catch(err => { + this.emit('connect', peripheralUuid, err); + }); + } + + async _connect (id) { + const device = await this._ensureDeviceProxy(id); + const iface = device.proxy.getInterface(DEVICE_IFACE); + + const cached = this._objects.get(device.path) || {}; + const deviceProps = cached[DEVICE_IFACE] || {}; + if (deviceProps.Connected && deviceProps.ServicesResolved) { + device.servicesResolved = true; + this.emit('connect', id, null); + return; + } + + const waitConnected = new Promise((resolve, reject) => { + device.connectPromise = { resolve, reject }; + }); + + try { + await iface.Connect(); + } catch (err) { + device.connectPromise = null; + throw err; + } + + await waitConnected; + this.emit('connect', id, null); + } + + cancelConnect (peripheralUuid, _parameters) { + this.disconnect(peripheralUuid); + } + + disconnect (peripheralUuid) { + this._disconnect(peripheralUuid).catch(err => { + this.emit('warning', `disconnect failed: ${err.message}`); + }); + } + + async _disconnect (id) { + const device = this._devices.get(id); + if (!device) return; + if (!device.proxy) return; + const iface = device.proxy.getInterface(DEVICE_IFACE); + try { + await iface.Disconnect(); + } catch (err) { + debug('Disconnect call failed: %s', err.message); + } + } + + updateRssi (peripheralUuid) { + const device = this._devices.get(peripheralUuid); + if (!device || !device.path) { + this.emit('rssiUpdate', peripheralUuid, 0, new Error('unknown peripheral')); + return; + } + const cached = this._objects.get(device.path) || {}; + const props = cached[DEVICE_IFACE] || {}; + const rssi = typeof props.RSSI === 'number' ? props.RSSI : (device.rssi || 0); + this.emit('rssiUpdate', peripheralUuid, rssi); + } + + // ---- Service / characteristic / descriptor discovery ---- + + _findServicesForDevice (id) { + const device = this._devices.get(id); + if (!device || !device.path) return []; + const prefix = `${device.path}/`; + const services = []; + for (const [path, ifaces] of this._objects) { + if (!path.startsWith(prefix)) continue; + const svc = ifaces[GATT_SERVICE_IFACE]; + if (!svc) continue; + services.push({ path, uuid: normalizeUuid(svc.UUID), primary: !!svc.Primary }); + } + return services; + } + + _findCharacteristicsForService (servicePath) { + const prefix = `${servicePath}/`; + const result = []; + for (const [path, ifaces] of this._objects) { + if (!path.startsWith(prefix)) continue; + const ch = ifaces[GATT_CHAR_IFACE]; + if (!ch) continue; + result.push({ + path, + uuid: normalizeUuid(ch.UUID), + properties: flagsToProperties(ch.Flags) + }); + } + return result; + } + + _findDescriptorsForCharacteristic (charPath) { + const prefix = `${charPath}/`; + const result = []; + for (const [path, ifaces] of this._objects) { + if (!path.startsWith(prefix)) continue; + const d = ifaces[GATT_DESC_IFACE]; + if (!d) continue; + result.push({ path, uuid: normalizeUuid(d.UUID) }); + } + return result; + } + + discoverServices (peripheralUuid, uuids) { + const wanted = (uuids || []).map(normalizeUuid); + const found = this._findServicesForDevice(peripheralUuid); + const filtered = wanted.length === 0 ? found : found.filter(s => wanted.includes(s.uuid)); + const serviceUuids = filtered.map(s => s.uuid); + this.emit('servicesDiscover', peripheralUuid, serviceUuids); + this.emit('servicesDiscovered', peripheralUuid, serviceUuids); + } + + discoverIncludedServices (peripheralUuid, serviceUuid, _serviceUuids) { + // BlueZ does not expose included services directly via D-Bus. + this.emit('includedServicesDiscover', peripheralUuid, serviceUuid, []); + } + + discoverCharacteristics (peripheralUuid, serviceUuid, characteristicUuids) { + const services = this._findServicesForDevice(peripheralUuid); + const service = services.find(s => s.uuid === normalizeUuid(serviceUuid)); + if (!service) { + this.emit('characteristicsDiscover', peripheralUuid, serviceUuid, [], new Error('service not found')); + return; + } + const wanted = (characteristicUuids || []).map(normalizeUuid); + const all = this._findCharacteristicsForService(service.path); + const filtered = wanted.length === 0 ? all : all.filter(c => wanted.includes(c.uuid)); + const result = filtered.map(c => ({ uuid: c.uuid, properties: c.properties })); + this.emit('characteristicsDiscover', peripheralUuid, serviceUuid, result); + this.emit('characteristicsDiscovered', peripheralUuid, serviceUuid, result); + } + + _findCharacteristicPath (peripheralUuid, serviceUuid, characteristicUuid) { + const services = this._findServicesForDevice(peripheralUuid); + const service = services.find(s => s.uuid === normalizeUuid(serviceUuid)); + if (!service) return null; + const chars = this._findCharacteristicsForService(service.path); + const ch = chars.find(c => c.uuid === normalizeUuid(characteristicUuid)); + return ch ? ch.path : null; + } + + _findDescriptorPath (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid) { + const charPath = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid); + if (!charPath) return null; + const descs = this._findDescriptorsForCharacteristic(charPath); + const d = descs.find(x => x.uuid === normalizeUuid(descriptorUuid)); + return d ? d.path : null; + } + + read (peripheralUuid, serviceUuid, characteristicUuid) { + this._readChar(peripheralUuid, serviceUuid, characteristicUuid).catch(err => { + this.emit('read', peripheralUuid, serviceUuid, characteristicUuid, null, false, err); + }); + } + + async _readChar (peripheralUuid, serviceUuid, characteristicUuid) { + const path = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid); + if (!path) throw new Error('characteristic not found'); + const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path); + const iface = proxy.getInterface(GATT_CHAR_IFACE); + const value = await iface.ReadValue({}); + const buf = Buffer.isBuffer(value) ? value : Buffer.from(value); + this.emit('read', peripheralUuid, serviceUuid, characteristicUuid, buf, false); + } + + write (peripheralUuid, serviceUuid, characteristicUuid, data, withoutResponse) { + this._writeChar(peripheralUuid, serviceUuid, characteristicUuid, data, withoutResponse).catch(err => { + this.emit('write', peripheralUuid, serviceUuid, characteristicUuid, err); + }); + } + + async _writeChar (peripheralUuid, serviceUuid, characteristicUuid, data, withoutResponse) { + const path = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid); + if (!path) throw new Error('characteristic not found'); + const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path); + const iface = proxy.getInterface(GATT_CHAR_IFACE); + const { Variant } = this._dbus; + const options = { type: new Variant('s', withoutResponse ? 'command' : 'request') }; + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); + await iface.WriteValue(buf, options); + this.emit('write', peripheralUuid, serviceUuid, characteristicUuid); + } + + broadcast (peripheralUuid, serviceUuid, characteristicUuid, _broadcast) { + this.emit('warning', 'broadcast is not supported on the dbus backend'); + this.emit('broadcast', peripheralUuid, serviceUuid, characteristicUuid, false); + } + + notify (peripheralUuid, serviceUuid, characteristicUuid, notify) { + this._setNotify(peripheralUuid, serviceUuid, characteristicUuid, notify).catch(err => { + this.emit('notify', peripheralUuid, serviceUuid, characteristicUuid, false, err); + }); + } + + async _setNotify (peripheralUuid, serviceUuid, characteristicUuid, notify) { + const path = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid); + if (!path) throw new Error('characteristic not found'); + const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path); + const iface = proxy.getInterface(GATT_CHAR_IFACE); + const props = proxy.getInterface(PROPS_IFACE); + + if (notify) { + if (!this._charPropsListeners.has(path)) { + const listener = (ifaceName, changed) => { + if (ifaceName !== GATT_CHAR_IFACE) return; + const c = unwrapDict(changed); + if ('Value' in c) { + const buf = Buffer.isBuffer(c.Value) ? c.Value : Buffer.from(c.Value); + this.emit('read', peripheralUuid, serviceUuid, characteristicUuid, buf, true); + } + }; + props.on('PropertiesChanged', listener); + this._charPropsListeners.set(path, { props, listener }); + } + await iface.StartNotify(); + this.emit('notify', peripheralUuid, serviceUuid, characteristicUuid, true); + } else { + try { + await iface.StopNotify(); + } catch (err) { + debug('StopNotify failed: %s', err.message); + } + const entry = this._charPropsListeners.get(path); + if (entry) { + entry.props.off('PropertiesChanged', entry.listener); + this._charPropsListeners.delete(path); + } + this.emit('notify', peripheralUuid, serviceUuid, characteristicUuid, false); + } + } + + discoverDescriptors (peripheralUuid, serviceUuid, characteristicUuid) { + const charPath = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid); + if (!charPath) { + this.emit('descriptorsDiscover', peripheralUuid, serviceUuid, characteristicUuid, [], new Error('characteristic not found')); + return; + } + const descs = this._findDescriptorsForCharacteristic(charPath).map(d => d.uuid); + this.emit('descriptorsDiscover', peripheralUuid, serviceUuid, characteristicUuid, descs); + } + + readValue (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid) { + this._readDesc(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid).catch(err => { + this.emit('valueRead', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, null, err); + }); + } + + async _readDesc (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid) { + const path = this._findDescriptorPath(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid); + if (!path) throw new Error('descriptor not found'); + const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path); + const iface = proxy.getInterface(GATT_DESC_IFACE); + const value = await iface.ReadValue({}); + const buf = Buffer.isBuffer(value) ? value : Buffer.from(value); + this.emit('valueRead', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, buf); + } + + writeValue (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, data) { + this._writeDesc(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, data).catch(err => { + this.emit('valueWrite', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, err); + }); + } + + async _writeDesc (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, data) { + const path = this._findDescriptorPath(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid); + if (!path) throw new Error('descriptor not found'); + const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path); + const iface = proxy.getInterface(GATT_DESC_IFACE); + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); + await iface.WriteValue(buf, {}); + this.emit('valueWrite', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid); + } + + readHandle (peripheralUuid, handle) { + const err = new Error('readHandle is not supported on the dbus backend (BlueZ exposes UUIDs only)'); + this.emit('handleRead', peripheralUuid, handle, null, err); + } + + writeHandle (peripheralUuid, handle, _data, _withoutResponse) { + const err = new Error('writeHandle is not supported on the dbus backend (BlueZ exposes UUIDs only)'); + this.emit('handleWrite', peripheralUuid, handle, err); + } + + addressToId (address) { + return addressToId(address); + } +} + +module.exports = DbusBindings; diff --git a/lib/dbus/uuid.js b/lib/dbus/uuid.js new file mode 100644 index 00000000..31559311 --- /dev/null +++ b/lib/dbus/uuid.js @@ -0,0 +1,62 @@ +const BLUETOOTH_BASE_SUFFIX = '-0000-1000-8000-00805f9b34fb'; + +function normalizeUuid (uuid) { + if (!uuid) return uuid; + const lower = String(uuid).toLowerCase(); + if (lower.length === 36 && lower.endsWith(BLUETOOTH_BASE_SUFFIX)) { + const head = lower.slice(0, 8); + if (head.startsWith('0000')) { + return head.slice(4); + } + return head; + } + return lower.replace(/-/g, ''); +} + +function expandUuid (uuid) { + if (!uuid) return uuid; + const lower = String(uuid).toLowerCase().replace(/-/g, ''); + if (lower.length === 4) { + return `0000${lower}-0000-1000-8000-00805f9b34fb`; + } + if (lower.length === 8) { + return `${lower}-0000-1000-8000-00805f9b34fb`; + } + if (lower.length === 32) { + return `${lower.slice(0, 8)}-${lower.slice(8, 12)}-${lower.slice(12, 16)}-${lower.slice(16, 20)}-${lower.slice(20)}`; + } + return lower; +} + +function addressToId (address) { + return address.replace(/:/g, '').toLowerCase(); +} + +function idToAddress (id) { + return id.match(/.{1,2}/g).join(':').toUpperCase(); +} + +function devicePathToAddress (path) { + const m = path.match(/dev_([0-9A-Fa-f_]+)$/); + if (!m) return null; + return m[1].replace(/_/g, ':').toUpperCase(); +} + +function deviceIdFromPath (path) { + const address = devicePathToAddress(path); + return address ? addressToId(address) : null; +} + +function devicePathFromAddress (adapterPath, address) { + return `${adapterPath}/dev_${address.toUpperCase().replace(/:/g, '_')}`; +} + +module.exports = { + normalizeUuid, + expandUuid, + addressToId, + idToAddress, + devicePathToAddress, + deviceIdFromPath, + devicePathFromAddress +}; diff --git a/lib/resolve-bindings.js b/lib/resolve-bindings.js index a7f2664e..598eef45 100644 --- a/lib/resolve-bindings.js +++ b/lib/resolve-bindings.js @@ -6,6 +6,8 @@ function loadBindings (bindingType = null, options = {}) { switch (bindingType) { case 'hci': return new (require('./hci-socket/bindings'))(options); + case 'dbus': + return new (require('./dbus/bindings'))(options); case 'mac': return new (require('./mac/bindings'))(options); case 'win': @@ -17,7 +19,7 @@ function loadBindings (bindingType = null, options = {}) { function getWindowsBindings () { const ver = os.release().split('.').map((str) => parseInt(str, 10)); - const isWin10WithBLE = + const isWin10WithBLE = ver[0] > 10 || (ver[0] === 10 && ver[1] > 0) || (ver[0] === 10 && ver[1] === 0 && ver[2] >= 15063); @@ -26,6 +28,10 @@ function getWindowsBindings () { function getDefaultBindings (options = {}) { const platform = os.platform(); + const requested = (process.env.NOBLE_BINDINGS || '').toLowerCase(); + if (requested === 'dbus' || requested === 'hci' || requested === 'mac' || requested === 'win') { + return loadBindings(requested, options); + } if ( platform === 'linux' || platform === 'freebsd' || @@ -44,7 +50,7 @@ function getDefaultBindings (options = {}) { } module.exports = function (bindingType = 'default', options = {}) { - const bindings = bindingType === 'default' + const bindings = bindingType === 'default' ? getDefaultBindings(options) : loadBindings(bindingType, options); return new Noble(bindings); diff --git a/test/lib/dbus/bindings.test.js b/test/lib/dbus/bindings.test.js new file mode 100644 index 00000000..cbabe658 --- /dev/null +++ b/test/lib/dbus/bindings.test.js @@ -0,0 +1,412 @@ +const { EventEmitter } = require('events'); + +// ---- dbus-next mock ---- +// Provides an in-memory BlueZ object tree we can mutate per-test. + +class Variant { + constructor (signature, value) { + this.signature = signature; + this.value = value; + } +} + +const mockState = { + managedObjects: {}, + ifaceCalls: [], + proxies: new Map(), // path -> proxy + rootProxy: null, + systemBusListeners: [] +}; +const state = mockState; + +function v (signature, value) { + return new Variant(signature, value); +} + +// Wrap a plain props dict to look like dbus-next's variant-wrapped output +function wrapDict (obj) { + const out = {}; + for (const [k, val] of Object.entries(obj)) { + out[k] = val instanceof Variant ? val : v('v', val); + } + return out; +} + +function makeIfaceEmitter (extras = {}) { + const e = new EventEmitter(); + Object.assign(e, extras); + return e; +} + +function makeProxy (path) { + if (state.proxies.has(path)) return state.proxies.get(path); + + const propsIface = makeIfaceEmitter(); + const interfaces = { 'org.freedesktop.DBus.Properties': propsIface }; + + const ifacesAt = state.managedObjects[path] || {}; + for (const ifaceName of Object.keys(ifacesAt)) { + if (interfaces[ifaceName]) continue; + interfaces[ifaceName] = makeIfaceEmitter(); + } + + const recordedCall = (ifaceName, method) => (...args) => { + state.ifaceCalls.push({ path, iface: ifaceName, method, args }); + return Promise.resolve(); + }; + + if (interfaces['org.bluez.Adapter1']) { + Object.assign(interfaces['org.bluez.Adapter1'], { + SetDiscoveryFilter: recordedCall('org.bluez.Adapter1', 'SetDiscoveryFilter'), + StartDiscovery: recordedCall('org.bluez.Adapter1', 'StartDiscovery'), + StopDiscovery: recordedCall('org.bluez.Adapter1', 'StopDiscovery') + }); + } + if (interfaces['org.bluez.Device1']) { + Object.assign(interfaces['org.bluez.Device1'], { + Connect: recordedCall('org.bluez.Device1', 'Connect'), + Disconnect: recordedCall('org.bluez.Device1', 'Disconnect') + }); + } + if (interfaces['org.bluez.GattCharacteristic1']) { + Object.assign(interfaces['org.bluez.GattCharacteristic1'], { + ReadValue: jest.fn().mockResolvedValue(Buffer.from([0x01, 0x02])), + WriteValue: jest.fn().mockResolvedValue(undefined), + StartNotify: jest.fn().mockResolvedValue(undefined), + StopNotify: jest.fn().mockResolvedValue(undefined) + }); + } + if (interfaces['org.bluez.GattDescriptor1']) { + Object.assign(interfaces['org.bluez.GattDescriptor1'], { + ReadValue: jest.fn().mockResolvedValue(Buffer.from([0x09])), + WriteValue: jest.fn().mockResolvedValue(undefined) + }); + } + + if (path === '/') { + interfaces['org.freedesktop.DBus.ObjectManager'] = makeIfaceEmitter({ + GetManagedObjects: jest.fn().mockImplementation(async () => { + const out = {}; + for (const [p, ifs] of Object.entries(state.managedObjects)) { + out[p] = {}; + for (const [iname, props] of Object.entries(ifs)) { + out[p][iname] = wrapDict(props); + } + } + return out; + }) + }); + } + + const proxy = { + path, + interfaces, + getInterface: name => { + if (!interfaces[name]) interfaces[name] = makeIfaceEmitter(); + return interfaces[name]; + } + }; + state.proxies.set(path, proxy); + if (path === '/') state.rootProxy = proxy; + return proxy; +} + +const mockBus = { + getProxyObject: jest.fn().mockImplementation(async (_service, path) => makeProxy(path)), + disconnect: jest.fn() +}; + +const mockDbus = { + systemBus: jest.fn().mockReturnValue(mockBus), + Variant +}; + +jest.mock('dbus-next', () => mockDbus, { virtual: true }); + +// ---- Tests ---- + +const DbusBindings = require('../../../lib/dbus/bindings'); + +function resetState (objects = {}) { + state.managedObjects = objects; + state.ifaceCalls = []; + state.proxies.clear(); + state.rootProxy = null; +} + +function adapterTree (extra = {}) { + return { + '/org/bluez': { 'org.freedesktop.DBus.ObjectManager': {} }, + '/org/bluez/hci0': { + 'org.bluez.Adapter1': { + Address: '00:11:22:33:44:55', + Powered: true, + Discovering: false + } + }, + ...extra + }; +} + +async function flush () { + // Allow microtasks (init promise chain) to settle. + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); +} + +describe('dbus/bindings', () => { + beforeEach(() => { + resetState(adapterTree()); + }); + + test('start() emits stateChange("poweredOn") and addressChange', async () => { + const bindings = new DbusBindings(); + const states = []; + const addresses = []; + bindings.on('stateChange', s => states.push(s)); + bindings.on('addressChange', a => addresses.push(a)); + + bindings.start(); + await flush(); + + expect(states).toContain('poweredOn'); + expect(addresses).toContain('00:11:22:33:44:55'); + }); + + test('emits stateChange("poweredOff") when adapter is unpowered', async () => { + resetState(adapterTree({ + '/org/bluez/hci0': { + 'org.bluez.Adapter1': { Address: 'AA:BB:CC:DD:EE:FF', Powered: false } + } + })); + const bindings = new DbusBindings(); + const states = []; + bindings.on('stateChange', s => states.push(s)); + + bindings.start(); + await flush(); + + expect(states).toContain('poweredOff'); + }); + + test('emits unsupported when no adapter is found', async () => { + resetState({ '/org/bluez': { 'org.freedesktop.DBus.ObjectManager': {} } }); + const bindings = new DbusBindings(); + const states = []; + const warnings = []; + bindings.on('stateChange', s => states.push(s)); + bindings.on('warning', w => warnings.push(w)); + + bindings.start(); + await flush(); + + expect(states).toContain('unsupported'); + expect(warnings.some(w => /No BlueZ adapter/.test(w))).toBe(true); + }); + + test('startScanning calls SetDiscoveryFilter + StartDiscovery and emits scanStart', async () => { + const bindings = new DbusBindings(); + const scanStarts = []; + bindings.on('scanStart', () => scanStarts.push(true)); + + bindings.start(); + await flush(); + + bindings.startScanning(['180d'], false); + await flush(); + + const calls = state.ifaceCalls.filter(c => c.path === '/org/bluez/hci0'); + expect(calls.map(c => c.method)).toEqual( + expect.arrayContaining(['SetDiscoveryFilter', 'StartDiscovery']) + ); + expect(scanStarts.length).toBe(1); + }); + + test('stopScanning calls StopDiscovery and emits scanStop', async () => { + const bindings = new DbusBindings(); + const scanStops = []; + bindings.on('scanStop', () => scanStops.push(true)); + + bindings.start(); + await flush(); + bindings.startScanning([], false); + await flush(); + bindings.stopScanning(); + await flush(); + + const stopCall = state.ifaceCalls.find(c => c.method === 'StopDiscovery'); + expect(stopCall).toBeDefined(); + expect(scanStops.length).toBe(1); + }); + + test('InterfacesAdded for a Device1 emits discover with parsed advertisement', async () => { + const bindings = new DbusBindings(); + const discoveries = []; + bindings.on('discover', (...args) => discoveries.push(args)); + + bindings.start(); + await flush(); + + const om = state.rootProxy.getInterface('org.freedesktop.DBus.ObjectManager'); + om.emit('InterfacesAdded', '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF', { + 'org.bluez.Device1': wrapDict({ + Address: 'AA:BB:CC:DD:EE:FF', + AddressType: 'public', + Name: 'Test Device', + RSSI: -42, + UUIDs: ['0000180d-0000-1000-8000-00805f9b34fb'] + }) + }); + await flush(); + + expect(discoveries.length).toBe(1); + const [id, address, addressType, connectable, advertisement, rssi] = discoveries[0]; + expect(id).toBe('aabbccddeeff'); + expect(address).toBe('AA:BB:CC:DD:EE:FF'); + expect(addressType).toBe('public'); + expect(connectable).toBe(true); + expect(rssi).toBe(-42); + expect(advertisement.localName).toBe('Test Device'); + expect(advertisement.serviceUuids).toEqual(['180d']); + }); + + test('discoverServices/Characteristics/Descriptors walk the cached object tree', async () => { + const tree = adapterTree({ + '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF': { + 'org.bluez.Device1': { Address: 'AA:BB:CC:DD:EE:FF', AddressType: 'public', Connected: false } + }, + '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001': { + 'org.bluez.GattService1': { UUID: '0000180d-0000-1000-8000-00805f9b34fb', Primary: true } + }, + '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001/char0002': { + 'org.bluez.GattCharacteristic1': { + UUID: '00002a37-0000-1000-8000-00805f9b34fb', + Flags: ['read', 'notify'] + } + }, + '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001/char0002/desc0003': { + 'org.bluez.GattDescriptor1': { UUID: '00002902-0000-1000-8000-00805f9b34fb' } + } + }); + resetState(tree); + + const bindings = new DbusBindings(); + bindings.start(); + await flush(); + + const services = []; + const chars = []; + const descs = []; + bindings.on('servicesDiscover', (...a) => services.push(a)); + bindings.on('characteristicsDiscover', (...a) => chars.push(a)); + bindings.on('descriptorsDiscover', (...a) => descs.push(a)); + + bindings.discoverServices('aabbccddeeff', []); + bindings.discoverCharacteristics('aabbccddeeff', '180d', []); + bindings.discoverDescriptors('aabbccddeeff', '180d', '2a37'); + + expect(services[0]).toEqual(['aabbccddeeff', ['180d']]); + expect(chars[0][0]).toBe('aabbccddeeff'); + expect(chars[0][1]).toBe('180d'); + expect(chars[0][2]).toEqual([{ uuid: '2a37', properties: ['read', 'notify'] }]); + expect(descs[0][0]).toBe('aabbccddeeff'); + expect(descs[0][3]).toEqual(['2902']); + }); + + test('read emits "read" with the characteristic value', async () => { + const tree = adapterTree({ + '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF': { + 'org.bluez.Device1': { Address: 'AA:BB:CC:DD:EE:FF', AddressType: 'public' } + }, + '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001': { + 'org.bluez.GattService1': { UUID: '0000180d-0000-1000-8000-00805f9b34fb' } + }, + '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001/char0002': { + 'org.bluez.GattCharacteristic1': { + UUID: '00002a37-0000-1000-8000-00805f9b34fb', + Flags: ['read'] + } + } + }); + resetState(tree); + + const bindings = new DbusBindings(); + bindings.start(); + await flush(); + + const reads = []; + bindings.on('read', (...a) => reads.push(a)); + + bindings.read('aabbccddeeff', '180d', '2a37'); + await flush(); + + expect(reads.length).toBe(1); + expect(reads[0][0]).toBe('aabbccddeeff'); + expect(reads[0][1]).toBe('180d'); + expect(reads[0][2]).toBe('2a37'); + expect(Buffer.isBuffer(reads[0][3])).toBe(true); + expect(reads[0][4]).toBe(false); // not a notification + }); + + test('notify(true) calls StartNotify and forwards value updates as notifications', async () => { + const charPath = '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001/char0002'; + const tree = adapterTree({ + '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF': { + 'org.bluez.Device1': { Address: 'AA:BB:CC:DD:EE:FF', AddressType: 'public' } + }, + '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0001': { + 'org.bluez.GattService1': { UUID: '0000180d-0000-1000-8000-00805f9b34fb' } + }, + [charPath]: { + 'org.bluez.GattCharacteristic1': { + UUID: '00002a37-0000-1000-8000-00805f9b34fb', + Flags: ['notify'] + } + } + }); + resetState(tree); + + const bindings = new DbusBindings(); + bindings.start(); + await flush(); + + const notifies = []; + const reads = []; + bindings.on('notify', (...a) => notifies.push(a)); + bindings.on('read', (...a) => reads.push(a)); + + bindings.notify('aabbccddeeff', '180d', '2a37', true); + await flush(); + + const proxy = state.proxies.get(charPath); + expect(proxy.interfaces['org.bluez.GattCharacteristic1'].StartNotify).toHaveBeenCalled(); + expect(notifies[0]).toEqual(['aabbccddeeff', '180d', '2a37', true]); + + // Simulate BlueZ pushing a notification + proxy.interfaces['org.freedesktop.DBus.Properties'].emit( + 'PropertiesChanged', + 'org.bluez.GattCharacteristic1', + wrapDict({ Value: Buffer.from([0xaa]) }), + [] + ); + + expect(reads.length).toBe(1); + expect(reads[0][4]).toBe(true); // isNotification + expect(reads[0][3].equals(Buffer.from([0xaa]))).toBe(true); + }); + + test('readHandle is unsupported and emits an error', () => { + const bindings = new DbusBindings(); + const events = []; + bindings.on('handleRead', (...a) => events.push(a)); + bindings.readHandle('aabbccddeeff', 0x42); + expect(events.length).toBe(1); + expect(events[0][3]).toBeInstanceOf(Error); + expect(events[0][3].message).toMatch(/not supported/); + }); + + test('addressToId normalizes a MAC into a noble peripheral id', () => { + const bindings = new DbusBindings(); + expect(bindings.addressToId('AA:BB:CC:DD:EE:FF')).toBe('aabbccddeeff'); + }); +}); diff --git a/test/lib/dbus/uuid.test.js b/test/lib/dbus/uuid.test.js new file mode 100644 index 00000000..a378405f --- /dev/null +++ b/test/lib/dbus/uuid.test.js @@ -0,0 +1,78 @@ +const { + normalizeUuid, + expandUuid, + addressToId, + idToAddress, + devicePathToAddress, + deviceIdFromPath, + devicePathFromAddress +} = require('../../../lib/dbus/uuid'); + +describe('dbus/uuid', () => { + describe('normalizeUuid', () => { + test('shortens 16-bit Bluetooth base UUIDs', () => { + expect(normalizeUuid('00002a37-0000-1000-8000-00805f9b34fb')).toBe('2a37'); + }); + + test('shortens 32-bit Bluetooth base UUIDs', () => { + expect(normalizeUuid('1234abcd-0000-1000-8000-00805f9b34fb')).toBe('1234abcd'); + }); + + test('strips dashes for non-base 128-bit UUIDs', () => { + expect(normalizeUuid('6E400001-B5A3-F393-E0A9-E50E24DCCA9E')) + .toBe('6e400001b5a3f393e0a9e50e24dcca9e'); + }); + + test('returns falsy values unchanged', () => { + expect(normalizeUuid(undefined)).toBeUndefined(); + expect(normalizeUuid(null)).toBeNull(); + expect(normalizeUuid('')).toBe(''); + }); + }); + + describe('expandUuid', () => { + test('expands 16-bit short form', () => { + expect(expandUuid('2a37')).toBe('00002a37-0000-1000-8000-00805f9b34fb'); + }); + + test('expands 32-bit short form', () => { + expect(expandUuid('1234abcd')).toBe('1234abcd-0000-1000-8000-00805f9b34fb'); + }); + + test('expands no-dash 128-bit form', () => { + expect(expandUuid('6e400001b5a3f393e0a9e50e24dcca9e')) + .toBe('6e400001-b5a3-f393-e0a9-e50e24dcca9e'); + }); + }); + + describe('addressToId / idToAddress', () => { + test('addressToId strips colons and lowercases', () => { + expect(addressToId('AA:BB:CC:DD:EE:FF')).toBe('aabbccddeeff'); + }); + + test('idToAddress reverses the transformation', () => { + expect(idToAddress('aabbccddeeff')).toBe('AA:BB:CC:DD:EE:FF'); + }); + }); + + describe('devicePathToAddress / deviceIdFromPath', () => { + test('parses BlueZ device path', () => { + expect(devicePathToAddress('/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF')) + .toBe('AA:BB:CC:DD:EE:FF'); + expect(deviceIdFromPath('/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF')) + .toBe('aabbccddeeff'); + }); + + test('returns null for non-device paths', () => { + expect(devicePathToAddress('/org/bluez/hci0')).toBeNull(); + expect(deviceIdFromPath('/org/bluez/hci0')).toBeNull(); + }); + }); + + describe('devicePathFromAddress', () => { + test('builds the BlueZ device path', () => { + expect(devicePathFromAddress('/org/bluez/hci0', 'aa:bb:cc:dd:ee:ff')) + .toBe('/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF'); + }); + }); +});