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) + }) +})