From 0b386e34df3fb519222061069b628a4bb74e10d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:52:11 -0500 Subject: [PATCH] fix(ble): read sensors from advertisements instead of connecting Status over BLE was read by connecting to the device for every device type. Meters and other sensors report their state in advertisements and often refuse connections ("Unknown Connection Identifier (0x2)", "Command Disallowed"), so reads failed, the BLE circuit breaker opened and values stayed at 0 (homebridge-switchbot#1437). The reply from the connection was also discarded: the status came from the cached advertisement anyway. As in pySwitchbot, meters (Meter, Plus, Pro, Pro CO2, Indoor/Outdoor), contact, motion, leak and water sensors are now read from their latest advertisement. When it is older than 60 s, the device listens up to 10 s for a new one, sharing a single short scan between devices; if none arrives it returns the last advertisement, or fails over to the API when there has never been one. Sensors are never connected to. DeviceInfo gains bleServiceDataAt (when the advertisement was received). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYWa2RXoWsaUTWh76oNv8s --- src/devices/base.ts | 57 ++++++- src/devices/wo-contact.ts | 5 + src/devices/wo-leak.ts | 5 + src/devices/wo-presence.ts | 5 + src/devices/wo-sensor-th.ts | 5 + src/devices/wo-water-detector.ts | 5 + src/switchbot.ts | 46 +++++- src/types/index.ts | 2 + .../sensor-advertisement-status.test.ts | 145 ++++++++++++++++++ 9 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 test/devices/sensor-advertisement-status.test.ts diff --git a/src/devices/base.ts b/src/devices/base.ts index 0eaa20bf..4c673d30 100644 --- a/src/devices/base.ts +++ b/src/devices/base.ts @@ -11,7 +11,7 @@ import type { CircuitBreakerConfig, FallbackHandler, FallbackHandlerOptions, Ret import { Buffer } from 'node:buffer' import { EventEmitter } from 'node:events' -import { APINotAvailableError, BLENotAvailableError } from '../errors.js' +import { APINotAvailableError, BLENotAvailableError, DeviceNotFoundError } from '../errors.js' import { DEVICE_COMMANDS } from '../settings.js' import { CircuitBreaker, @@ -27,6 +27,17 @@ import { // Passive polling interval (24 hours) export const PASSIVE_POLL_INTERVAL = 60 * 60 * 24 * 1000 +/** + * Wait for the next advertisement from a device (by MAC, or `id:`); resolves with its parsed + * service data, or undefined when none arrives in time + */ +export type AdvertisementSource = (address: string, bleId: string | undefined, timeoutMs: number) => Promise | undefined> + +/** Advertisement data newer than this is used as is */ +const ADVERTISEMENT_MAX_AGE_MS = 60_000 +/** How long to listen for a new advertisement (sensors advertise every few seconds) */ +const ADVERTISEMENT_WAIT_MS = 10_000 + /** * Base class for all SwitchBot devices * @@ -83,6 +94,7 @@ const KEY_ID_REGEX = /^[0-9a-f]{2}$/i export abstract class SwitchBotDevice extends EventEmitter { protected logger: Logger protected bleConnection?: BLEConnection + private advertisementSource?: AdvertisementSource protected apiClient?: OpenAPIClient protected enableFallback: boolean protected preferredConnection: ConnectionType @@ -126,6 +138,8 @@ export abstract class SwitchBotDevice extends EventEmitter { options: { bleConnection?: BLEConnection apiClient?: OpenAPIClient + /** Listen for this device's next BLE advertisement (used by sensors, which are not connected to) */ + advertisementSource?: AdvertisementSource enableFallback?: boolean preferredConnection?: ConnectionType enableConnectionIntelligence?: boolean @@ -142,6 +156,7 @@ export abstract class SwitchBotDevice extends EventEmitter { this.logger = new Logger(`${info.deviceType}:${info.id}`, options.logLevel) this.bleConnection = options.bleConnection + this.advertisementSource = options.advertisementSource this.apiClient = options.apiClient this.enableFallback = options.enableFallback ?? true this.preferredConnection = options.preferredConnection ?? 'ble' @@ -696,11 +711,51 @@ export abstract class SwitchBotDevice extends EventEmitter { /** * Get device status via BLE */ + /** + * Sensors (meters, contact, motion, leak) report their state in BLE advertisements and are not + * connected to, as in pySwitchbot. Many refuse connections, so connecting to read them fails. + */ + protected get readsFromAdvertisement(): boolean { + return false + } + + /** Read status from the latest advertisement, listening for a new one when it is stale */ + private async getAdvertisedStatus(): Promise> { + const startTime = Date.now() + const age = this.info.bleServiceDataAt ? startTime - this.info.bleServiceDataAt : Number.POSITIVE_INFINITY + if (this.info.bleServiceData && age <= ADVERTISEMENT_MAX_AGE_MS) { + return this.info.bleServiceData + } + + const address = this.info.mac ?? (this.info.bleId ? `id:${this.info.bleId}` : undefined) + if (address && this.advertisementSource) { + const serviceData = await this.advertisementSource(address, this.info.bleId, ADVERTISEMENT_WAIT_MS).catch((error) => { + this.logger.debug('Listening for advertisement failed', error) + return undefined + }) + if (serviceData) { + this.updateInfo({ bleServiceData: serviceData, bleServiceDataAt: Date.now(), battery: (serviceData.battery as number | undefined) ?? this.info.battery }) + this.recordBLESuccess(startTime, undefined) + return serviceData + } + } + + if (this.info.bleServiceData) { + this.logger.debug('No new advertisement; using the last one received') + return this.info.bleServiceData + } + throw new DeviceNotFoundError(this.info.mac ?? this.info.id) + } + protected async getBLEStatus(): Promise { if (!this.hasBLE()) { throw new BLENotAvailableError('BLE not available for this device') } + if (this.readsFromAdvertisement) { + return this.getAdvertisedStatus() + } + try { this.logger.debug('Reading BLE status') const startTime = Date.now() diff --git a/src/devices/wo-contact.ts b/src/devices/wo-contact.ts index c7eb0f5c..205bdba8 100644 --- a/src/devices/wo-contact.ts +++ b/src/devices/wo-contact.ts @@ -11,6 +11,11 @@ import { SwitchBotDevice } from './base.js' * Contact Sensor (Door/Window Sensor) */ export class WoContact extends SwitchBotDevice { + /** Read from BLE advertisements; sensors are not connected to */ + protected override get readsFromAdvertisement(): boolean { + return true + } + /** * Get device status (BLE-first, API-fallback) */ diff --git a/src/devices/wo-leak.ts b/src/devices/wo-leak.ts index dfba1086..55bd73c7 100644 --- a/src/devices/wo-leak.ts +++ b/src/devices/wo-leak.ts @@ -11,6 +11,11 @@ import { SwitchBotDevice } from './base.js' * Water Leak Detector Device */ export class WoLeak extends SwitchBotDevice { + /** Read from BLE advertisements; sensors are not connected to */ + protected override get readsFromAdvertisement(): boolean { + return true + } + /** * Get device status (BLE-first, API-fallback) */ diff --git a/src/devices/wo-presence.ts b/src/devices/wo-presence.ts index 05e5f22c..632ecbbd 100644 --- a/src/devices/wo-presence.ts +++ b/src/devices/wo-presence.ts @@ -11,6 +11,11 @@ import { SwitchBotDevice } from './base.js' * Motion/Presence Sensor */ export class WoPresence extends SwitchBotDevice { + /** Read from BLE advertisements; sensors are not connected to */ + protected override get readsFromAdvertisement(): boolean { + return true + } + /** * Get device status (BLE-first, API-fallback) */ diff --git a/src/devices/wo-sensor-th.ts b/src/devices/wo-sensor-th.ts index b8ffe288..3d798f5b 100644 --- a/src/devices/wo-sensor-th.ts +++ b/src/devices/wo-sensor-th.ts @@ -11,6 +11,11 @@ import { SwitchBotDevice } from './base.js' * Meter (Temperature/Humidity Sensor) */ export class WoSensorTH extends SwitchBotDevice { + /** Read from BLE advertisements; sensors are not connected to */ + protected override get readsFromAdvertisement(): boolean { + return true + } + /** * Get device status (BLE-first, API-fallback) */ diff --git a/src/devices/wo-water-detector.ts b/src/devices/wo-water-detector.ts index 6ca37a64..522609a3 100644 --- a/src/devices/wo-water-detector.ts +++ b/src/devices/wo-water-detector.ts @@ -3,6 +3,11 @@ import type { LeakStatus } from '../types/device.js' import { SwitchBotDevice } from './base.js' export class WoWaterDetector extends SwitchBotDevice { + /** Read from BLE advertisements; sensors are not connected to */ + protected override get readsFromAdvertisement(): boolean { + return true + } + /** * Get device status (BLE-first, API-fallback) */ diff --git a/src/switchbot.ts b/src/switchbot.ts index a0441a4d..f88458e1 100644 --- a/src/switchbot.ts +++ b/src/switchbot.ts @@ -74,7 +74,7 @@ import { WoVacuumS20 } from './devices/wo-vacuum-s20.js' import { WoVacuum } from './devices/wo-vacuum.js' import { WoWaterDetector } from './devices/wo-water-detector.js' import { BLE_SUPPORTED, DEFAULTS, resolveDeviceClassName } from './settings.js' -import { Logger, macToDeviceId } from './utils/index.js' +import { Logger, macToDeviceId, normalizeMAC } from './utils/index.js' /** * Device class constructor type @@ -369,6 +369,7 @@ export class SwitchBot extends EventEmitter { activeConnection: undefined, battery: serviceData.battery, bleServiceData: serviceData, + bleServiceDataAt: Date.now(), rssi, } @@ -382,6 +383,7 @@ export class SwitchBot extends EventEmitter { connectionTypes: [...new Set([...device.getInfo().connectionTypes, 'ble' as ConnectionType])], battery: serviceData.battery, bleServiceData: serviceData, + bleServiceDataAt: Date.now(), rssi, }) this.logger.debug(`Updated device: ${info.name} (${info.id})`) @@ -546,6 +548,7 @@ export class SwitchBot extends EventEmitter { ...extractDeviceOptionsFromConfig(this.config), apiClient: this.apiClient, bleConnection: this.bleConnection, + advertisementSource: this.bleScanner ? this.listenForAdvertisement.bind(this) : undefined, }) } catch (error) { this.logger.error(`createDevice: Failed to create device ${className}`, error) @@ -553,6 +556,47 @@ export class SwitchBot extends EventEmitter { } } + /** One short scan shared by every device waiting for an advertisement */ + private advertisementScan?: Promise + + /** + * Wait for the next advertisement from one device, scanning briefly if no scan is running. + * Used to read sensors, which report their state in advertisements and are not connected to. + */ + private async listenForAdvertisement(address: string, bleId: string | undefined, timeoutMs: number): Promise | undefined> { + const scanner = this.bleScanner + if (!scanner) { + return undefined + } + const normalized = address.startsWith('id:') ? undefined : normalizeMAC(address) + return new Promise((resolve) => { + const timer = setTimeout(() => finish(undefined), timeoutMs) + timer.unref?.() + const handler = (advertisement: any) => { + const matches = (normalized && advertisement.address && normalizeMAC(advertisement.address) === normalized) + || (bleId && advertisement.id === bleId) + if (matches) { + finish(advertisement.serviceData) + } + } + function finish(result: Record | undefined): void { + clearTimeout(timer) + scanner!.off('discover', handler) + resolve(result) + } + scanner.on('discover', handler) + if (!scanner.isScanning() && !this.advertisementScan) { + this.advertisementScan = scanner.startScan({ duration: timeoutMs }) + .catch(error => this.logger.debug('Advertisement scan failed', error)) + .finally(() => { + setTimeout(() => { + this.advertisementScan = undefined + }, timeoutMs).unref?.() + }) + } + }) + } + /** * Get device manager (for accessing devices) */ diff --git a/src/types/index.ts b/src/types/index.ts index bd48b44d..11d07821 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -119,6 +119,8 @@ export interface DeviceInfo { rssi?: number /** Last parsed BLE advertisement service data */ bleServiceData?: Record + /** When bleServiceData was last received (ms since epoch) */ + bleServiceDataAt?: number /** Optional BLE encryption key as hex (16 bytes / 32 hex chars) */ encryptionKey?: string /** BLE encryption key id, 2 hex characters (with encryptionKey; from the SwitchBot account) */ diff --git a/test/devices/sensor-advertisement-status.test.ts b/test/devices/sensor-advertisement-status.test.ts new file mode 100644 index 00000000..fe2a3c60 --- /dev/null +++ b/test/devices/sensor-advertisement-status.test.ts @@ -0,0 +1,145 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * sensor-advertisement-status.test.ts: sensors are read from BLE advertisements, never connected to + */ + +import type { DeviceInfo } from '../../src/types/index.js' + +import { describe, expect, it, vi } from 'vitest' + +import { WoContact } from '../../src/devices/wo-contact.js' +import { WoHand } from '../../src/devices/wo-hand.js' +import { WoLeak } from '../../src/devices/wo-leak.js' +import { WoPresence } from '../../src/devices/wo-presence.js' +import { WoSensorTHPro } from '../../src/devices/wo-sensor-th-pro.js' +import { WoSensorTH } from '../../src/devices/wo-sensor-th.js' +import { WoWaterDetector } from '../../src/devices/wo-water-detector.js' + +// homebridge-switchbot#1437: a Meter's temperature and humidity stayed at 0. Status was read by +// connecting to the meter, which it refused ("Unknown Connection Identifier (0x2)"), so the circuit +// breaker opened. pySwitchbot reads meters and other sensors from their advertisements only. + +const METER_DATA = { model: 'T', modelName: 'Meter', temperature: 21.5, fahrenheit: false, humidity: 48, battery: 90 } + +function meterInfo(extra: Partial = {}): DeviceInfo { + return { id: 'AABBCCDDEEFF', name: 'Meter', deviceType: 'Meter', mac: 'AA:BB:CC:DD:EE:FF', connectionTypes: ['ble'], ...extra } +} + +function refusingConnection() { + return { + read: vi.fn(async () => { + throw new Error('Unknown Connection Identifier (0x2)') + }), + connect: vi.fn(async () => { + throw new Error('Unknown Connection Identifier (0x2)') + }), + } as any +} + +describe('sensor status from advertisements', () => { + it('reads a meter from a recent advertisement without connecting', async () => { + const bleConnection = refusingConnection() + const meter = new WoSensorTH(meterInfo({ bleServiceData: METER_DATA, bleServiceDataAt: Date.now() }), { bleConnection, enableFallback: false }) + + const status = await meter.getStatus() + + expect(status).toMatchObject({ connectionType: 'ble', temperature: 21.5, humidity: 48, battery: 90, temperatureScale: 'c' }) + expect(bleConnection.read).not.toHaveBeenCalled() + expect(bleConnection.connect).not.toHaveBeenCalled() + }) + + it('listens for a new advertisement when the cached one is stale', async () => { + const bleConnection = refusingConnection() + const advertisementSource = vi.fn(async () => ({ ...METER_DATA, temperature: 23.1 })) + const meter = new WoSensorTH( + meterInfo({ bleServiceData: METER_DATA, bleServiceDataAt: Date.now() - 10 * 60_000 }), + { bleConnection, advertisementSource, enableFallback: false }, + ) + + const status = await meter.getStatus() + + expect(advertisementSource).toHaveBeenCalledWith('AA:BB:CC:DD:EE:FF', undefined, expect.any(Number)) + expect(status.temperature).toBe(23.1) + expect(meter.getInfo().bleServiceData).toMatchObject({ temperature: 23.1 }) + expect(bleConnection.read).not.toHaveBeenCalled() + }) + + it('uses the last advertisement when no new one arrives', async () => { + const advertisementSource = vi.fn(async () => undefined) + const meter = new WoSensorTHPro( + meterInfo({ bleServiceData: METER_DATA, bleServiceDataAt: Date.now() - 10 * 60_000 }), + { bleConnection: refusingConnection(), advertisementSource, enableFallback: false }, + ) + + await expect(meter.getStatus()).resolves.toMatchObject({ temperature: 21.5 }) + }) + + it('falls back to the API when no advertisement has ever been received', async () => { + const apiClient = { getStatus: vi.fn(async () => ({ temperature: 19, humidity: 40, battery: 80 })) } as any + const meter = new WoSensorTH( + meterInfo({ connectionTypes: ['ble', 'api'] }), + { bleConnection: refusingConnection(), advertisementSource: async () => undefined, apiClient }, + ) + ;(meter as any).getAPIStatus = async () => ({ temperature: 19, humidity: 40, battery: 80 }) + + await expect(meter.getStatus()).resolves.toMatchObject({ connectionType: 'api', temperature: 19 }) + }) + + it.each([ + ['contact sensor', WoContact], + ['motion sensor', WoPresence], + ['leak detector', WoLeak], + ['water detector', WoWaterDetector], + ])('does not connect to a %s', async (_name, DeviceClass: any) => { + const bleConnection = refusingConnection() + const device = new DeviceClass(meterInfo({ bleServiceData: { battery: 70 }, bleServiceDataAt: Date.now() }), { bleConnection, enableFallback: false }) + + await device.getStatus() + + expect(bleConnection.read).not.toHaveBeenCalled() + }) + + it('still connects to devices that are not sensors', async () => { + const bleConnection = { read: vi.fn(async () => ({ battery: 50 })) } as any + const bot = new WoHand(meterInfo({ deviceType: 'Bot', bleServiceData: { battery: 50 }, bleServiceDataAt: Date.now() }), { bleConnection, enableFallback: false }) + + await (bot as any).getBLEStatus() + + expect(bleConnection.read).toHaveBeenCalled() + }) +}) + +describe('switchBot.listenForAdvertisement', () => { + async function setup() { + const { EventEmitter } = await import('node:events') + const { SwitchBot } = await import('../../src/switchbot.js') + const scanner: any = new EventEmitter() + scanner.scanning = false + scanner.isScanning = () => scanner.scanning + scanner.startScan = vi.fn(async () => { + scanner.scanning = true + }) + const sb = Object.create(SwitchBot.prototype) as any + sb.logger = { debug: vi.fn() } + sb.bleScanner = scanner + return { sb, scanner } + } + + it('resolves with the matching device advertisement and starts one shared scan', async () => { + const { sb, scanner } = await setup() + const a = sb.listenForAdvertisement('AA:BB:CC:DD:EE:FF', undefined, 1000) + const b = sb.listenForAdvertisement('11:22:33:44:55:66', undefined, 1000) + scanner.emit('discover', { address: '11:22:33:44:55:66', serviceData: { temperature: 1 } }) + scanner.emit('discover', { address: 'aa:bb:cc:dd:ee:ff', serviceData: { temperature: 2 } }) + await expect(a).resolves.toEqual({ temperature: 2 }) + await expect(b).resolves.toEqual({ temperature: 1 }) + expect(scanner.startScan).toHaveBeenCalledOnce() + expect(scanner.listenerCount('discover')).toBe(0) + }) + + it('resolves undefined when no advertisement arrives in time', async () => { + const { sb, scanner } = await setup() + await expect(sb.listenForAdvertisement('AA:BB:CC:DD:EE:FF', undefined, 20)).resolves.toBeUndefined() + expect(scanner.listenerCount('discover')).toBe(0) + }) +})