From 0e24d2a0899327c68dd92ebdb172b4ceb781f083 Mon Sep 17 00:00:00 2001 From: Andreas Schneider Date: Sat, 30 May 2026 08:37:39 +0200 Subject: [PATCH] fix(dbus): re-surface cached BlueZ devices at scan start Matter device commissioning via matterjs-server consistently failed for the IKEA sensors (e.g. TIMMERFLOTTE) because noble never fired a `discover` event for it. bluetoothd had already cached the device from background scanning, so BlueZ never emitted an InterfacesAdded signal when noble started its own scan. The device was invisible to noble. After StartDiscovery, re-read GetManagedObjects and replay _handleDeviceProps for every cached device under the adapter so they appear as discover events. --- lib/dbus/bindings.js | 38 ++++++++++++++++++++++++++++++++++++++ lib/hci-socket/gap.js | 28 ++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/lib/dbus/bindings.js b/lib/dbus/bindings.js index 648d75f2..6ba2377b 100644 --- a/lib/dbus/bindings.js +++ b/lib/dbus/bindings.js @@ -299,6 +299,7 @@ class DbusBindings extends EventEmitter { if (this._scanServiceUuids.length > 0) { filter.UUIDs = new Variant('as', this._scanServiceUuids.map(expandUuid)); } + debug('startScanning: filter UUIDs=%j duplicates=%s', this._scanServiceUuids, !!allowDuplicates); try { await this._adapterIface.SetDiscoveryFilter(filter); } catch (err) { @@ -307,7 +308,37 @@ class DbusBindings extends EventEmitter { if (!this._isScanning) { await this._adapterIface.StartDiscovery(); this._isScanning = true; + debug('startScanning: StartDiscovery issued'); + } else { + debug('startScanning: already scanning, skipping StartDiscovery'); } + + // Re-read BlueZ's object tree and surface cached devices. + // BlueZ won't emit InterfacesAdded for devices already in its cache, + // so without this refresh a new scan would miss them entirely. + try { + const managed = await this._objectManager.GetManagedObjects(); + let surfacedCount = 0; + 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, Object.assign(this._objects.get(path) || {}, unwrapped)); + if (unwrapped[DEVICE_IFACE] && this._isUnderAdapter(path)) { + surfacedCount++; + const addr = unwrapped[DEVICE_IFACE].Address || 'unknown'; + const name = unwrapped[DEVICE_IFACE].Name || unwrapped[DEVICE_IFACE].Alias || ''; + const uuids = unwrapped[DEVICE_IFACE].UUIDs || []; + debug('startScanning: re-surfacing cached device %s addr=%s name=%s uuids=%j', path, addr, name, uuids); + this._handleDeviceProps(path, unwrapped[DEVICE_IFACE]); + } + } + debug('startScanning: re-surfaced %d cached device(s) from BlueZ', surfacedCount); + } catch (err) { + debug('startScanning: GetManagedObjects refresh failed: %s', err.message); + } + this.emit('scanStart', !!allowDuplicates); } @@ -341,6 +372,9 @@ class DbusBindings extends EventEmitter { this._objects.set(path, Object.assign(existing, unwrapped)); if (unwrapped[DEVICE_IFACE] && this._isUnderAdapter(path)) { + const addr = unwrapped[DEVICE_IFACE].Address || 'unknown'; + const name = unwrapped[DEVICE_IFACE].Name || unwrapped[DEVICE_IFACE].Alias || ''; + debug('InterfacesAdded: new device %s addr=%s name=%s', path, addr, name); this._handleDeviceProps(path, unwrapped[DEVICE_IFACE]); } // Trigger services-resolved processing if a device just gained ServicesResolved @@ -384,6 +418,10 @@ class DbusBindings extends EventEmitter { const address = props.Address || devicePathToAddress(path); if (!address) return; const id = addressToId(address); + const name = props.Name || props.Alias || ''; + const serviceData = props.ServiceData ? Object.keys(props.ServiceData) : []; + const uuids = props.UUIDs || []; + debug('handleDeviceProps: id=%s addr=%s name=%s uuids=%j serviceData=%j rssi=%s', id, address, name, uuids, serviceData, props.RSSI); let device = this._devices.get(id); if (!device) { device = { diff --git a/lib/hci-socket/gap.js b/lib/hci-socket/gap.js index 4659c09e..a3069faf 100644 --- a/lib/hci-socket/gap.js +++ b/lib/hci-socket/gap.js @@ -93,14 +93,19 @@ Gap.prototype.onHciLeScanEnableSet = function (status) { // Called when we see the actual command "LE Set Scan Enable" Gap.prototype.onLeScanEnableSetCmd = function (enable, filterDuplicates) { + debug('onLeScanEnableSetCmd: enable=%s filterDuplicates=%s scanState=%s ownFilterDuplicates=%s', + enable, filterDuplicates, this._scanState, this._scanFilterDuplicates); // Check to see if the new settings differ from what we expect. // If we are scanning, then a change happens if the new command stops // scanning or if duplicate filtering changes. // If we are not scanning, then a change happens if scanning was enabled. if (this._scanState === 'starting' || this._scanState === 'started') { if (!enable) { + debug('onLeScanEnableSetCmd: external process stopped our scan'); this.emit('scanStop'); } else if (this._scanFilterDuplicates !== filterDuplicates) { + debug('onLeScanEnableSetCmd: external process changed filterDuplicates from %s to %s', + this._scanFilterDuplicates, filterDuplicates); this._scanFilterDuplicates = filterDuplicates; this.emit('scanStart', this._scanFilterDuplicates); @@ -109,6 +114,7 @@ Gap.prototype.onLeScanEnableSetCmd = function (enable, filterDuplicates) { (this._scanState === 'stopping' || this._scanState === 'stopped') && enable ) { + debug('onLeScanEnableSetCmd: external process started scanning while we were %s', this._scanState); // Someone started scanning on us. this.emit('scanStart', this._scanFilterDuplicates); } @@ -165,12 +171,17 @@ Gap.prototype.onHciLeAdvertisingReport = function ( }; // only report after a scan response event or if non-connectable or more than one discovery without a scan response, so more data can be collected - if ( + const willEmit = type === LE_META_EVENT_TYPE_SCAN_RESPONSE || !connectable || (discoveryCount > 1 && !hasScanResponse) || - process.env.NOBLE_REPORT_ALL_HCI_EVENTS - ) { + process.env.NOBLE_REPORT_ALL_HCI_EVENTS; + + debug('adv: addr=%s type=0x%s name=%s rssi=%d count=%d connectable=%s hasScanResponse=%s → %s', + address, type.toString(16), advertisement.localName || '', rssi, discoveryCount, + connectable, hasScanResponse, willEmit ? 'EMIT' : 'SUPPRESS (waiting for scan response)'); + + if (willEmit) { this.emit( 'discover', status, @@ -238,12 +249,17 @@ Gap.prototype.onHciLeExtendedAdvertisingReport = function ( }; // only report after a scan response event or if non-connectable or more than one discovery without a scan response, so more data can be collected - if ( + const willEmit = type & LE_META_EXTENDED_EVENT_TYPE_SCAN_RESPONSE_MASK || (!connectable && !incomplete) || (discoveryCount > 1 && !hasScanResponse) || - process.env.NOBLE_REPORT_ALL_HCI_EVENTS - ) { + process.env.NOBLE_REPORT_ALL_HCI_EVENTS; + + debug('ext-adv: addr=%s type=0x%s name=%s rssi=%d count=%d connectable=%s incomplete=%s hasScanResponse=%s → %s', + address, type.toString(16), advertisement.localName || '', rssi, discoveryCount, + connectable, incomplete, hasScanResponse, willEmit ? 'EMIT' : 'SUPPRESS'); + + if (willEmit) { this.emit( 'discover', status,