From 1e9d1245bb929c98986f630598f91fe4e821e311 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:51:49 -0500 Subject: [PATCH] fix(ble): read meters and other sensors from advertisements Meters, contact, motion and leak sensors report their state only in their BLE advertisements, and pySwitchbot reads them that way. node-switchbot connected to them over GATT instead, which many sensors refuse ("Unknown Connection Identifier (0x2)"), so their readings stayed at 0. - Port pySwitchbot's meter, contact, motion and leak advertisement parsers, including manufacturer data (newer meters, Meter Pro CO2). - Keep non-connectable advertisements from these sensors. - Sensors read their status from the last advertisement (up to 5 minutes old), otherwise wait up to 10 s for the next one, running a short scan if none is running. They never connect; with no reading the API fallback runs. - SwitchBot updates known devices from every advertisement it hears, not only during discover(). - getStatusWithFallback no longer tries the same transport twice when it fails and there is nothing to fall back to. Fixes OpenWonderLabs/homebridge-switchbot#1437 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYWa2RXoWsaUTWh76oNv8s --- src/ble.ts | 31 +++-- src/devices/base.ts | 74 ++++++++-- src/devices/wo-contact.ts | 7 +- src/devices/wo-leak.ts | 3 + src/devices/wo-presence.ts | 5 +- src/devices/wo-sensor-th-pro-co2.ts | 1 + src/devices/wo-sensor-th.ts | 3 + src/switchbot.ts | 81 ++++++++++- src/types/index.ts | 2 + src/utils/index.ts | 1 + src/utils/sensor-advertisement.ts | 176 ++++++++++++++++++++++++ test/apis/passive-sensor-status.test.ts | 95 +++++++++++++ test/utils/sensor-advertisement.test.ts | 97 +++++++++++++ 13 files changed, 550 insertions(+), 26 deletions(-) create mode 100644 src/utils/sensor-advertisement.ts create mode 100644 test/apis/passive-sensor-status.test.ts create mode 100644 test/utils/sensor-advertisement.test.ts diff --git a/src/ble.ts b/src/ble.ts index dbcb5a4b..01f91ddf 100644 --- a/src/ble.ts +++ b/src/ble.ts @@ -11,7 +11,7 @@ import { EventEmitter } from 'node:events' import { BLENotAvailableError, CommandFailedError, DeviceNotFoundError } from './errors.js' import { BLE_COMMAND_TIMEOUT, BLE_CONNECT_TIMEOUT, BLE_NOTIFY_CHARACTERISTIC_UUID, BLE_SCAN_TIMEOUT, BLE_SERVICE_UUID, BLE_WRITE_CHARACTERISTIC_UUID, DEVICE_MODEL_MAP } from './settings.js' -import { extractMacFromManufacturerData, isValidMAC, Logger, macToDeviceId, mergeAdvertisement, normalizeMAC, withTimeout } from './utils/index.js' +import { extractMacFromManufacturerData, isPassiveSensorModel, isValidMAC, Logger, macToDeviceId, mergeAdvertisement, normalizeMAC, parseSensorAdvertisement, switchBotManufacturerPayload, withTimeout } from './utils/index.js' // Move RegExp to module scope to avoid re-compilation const CHARACTERISTIC_REGEX = /characteristic/i const UUID_DASH_REGEX = /-/g @@ -177,11 +177,6 @@ export class BLEScanner extends EventEmitter { const { advertisement, rssi, connectable } = peripheral - // Skip non-connectable devices - if (connectable === false) { - return - } - // Skip devices with invalid RSSI (typical range: -120 to 0 dBm) if (typeof rssi !== 'number' || rssi < -120 || rssi > 0) { return @@ -213,12 +208,21 @@ export class BLEScanner extends EventEmitter { continue } - const serviceData = this.parseServiceData(serviceDataItem.data) + const serviceData = this.parseServiceData( + serviceDataItem.data, + switchBotManufacturerPayload(peripheral.advertisement.manufacturerData), + ) if (!serviceData) { continue } + // Sensors may advertise as non-connectable; they are read from the advertisement only. + // Skip other non-connectable devices, which cannot be controlled. + if (connectable === false && !isPassiveSensorModel(serviceData.model)) { + continue + } + const normalizedAddress = resolvePeripheralMac(peripheral) const fallbackId = typeof peripheral.id === 'string' && peripheral.id.length > 0 @@ -272,7 +276,7 @@ export class BLEScanner extends EventEmitter { /** * Parse service data from BLE advertisement */ - private parseServiceData(data: Buffer): BLEServiceData | null { + private parseServiceData(data: Buffer, manufacturerPayload?: Buffer): BLEServiceData | null { if (!data || data.length < 1) { return null } @@ -332,6 +336,16 @@ export class BLEScanner extends EventEmitter { } } + // Meters, contact, motion and leak sensors: readings from the advertisement (pySwitchbot) + const sensor = parseSensorAdvertisement(model, data, manufacturerPayload) + if (sensor) { + for (const [key, value] of Object.entries(sensor)) { + if (value !== undefined) { + serviceData[key] = value + } + } + } + if ([';', '<', '\x1B', '\x1C', '\x0E'].includes(model) && data.length > 1) { // Relay Switch 1PM / Relay Switch 1 serviceData.state = (data[1] & 0x01) === 0x01 @@ -1234,7 +1248,6 @@ export class BLEConnection { } } - /** * Write data to device */ diff --git a/src/devices/base.ts b/src/devices/base.ts index 0eaa20bf..4dc3adbf 100644 --- a/src/devices/base.ts +++ b/src/devices/base.ts @@ -80,10 +80,27 @@ export const PASSIVE_POLL_INTERVAL = 60 * 60 * 24 * 1000 const ENCRYPTION_KEY_REGEX = /^[0-9a-f]{32}$/i const KEY_ID_REGEX = /^[0-9a-f]{2}$/i +/** How long a sensor's last advertisement counts as its current state */ +export const ADVERTISEMENT_MAX_AGE_MS = 5 * 60 * 1000 +/** How long to listen for a fresh advertisement when the last one is too old */ +export const ADVERTISEMENT_WAIT_MS = 10_000 + +/** + * Waits for the next advertisement from a device and returns its parsed service data. Provided by + * SwitchBot, which owns the BLE scanner. + */ +export type AdvertisementSource = (info: DeviceInfo, timeoutMs: number) => Promise> + export abstract class SwitchBotDevice extends EventEmitter { protected logger: Logger protected bleConnection?: BLEConnection protected apiClient?: OpenAPIClient + protected advertisementSource?: AdvertisementSource + /** + * Sensors (meters, contact, motion, leak) report their state only in BLE advertisements and are + * read without connecting, as in pySwitchbot. Many of them refuse GATT connections. + */ + protected readonly passiveBLE: boolean = false protected enableFallback: boolean protected preferredConnection: ConnectionType @@ -134,6 +151,7 @@ export abstract class SwitchBotDevice extends EventEmitter { retryConfig?: RetryConfig circuitBreakerConfig?: CircuitBreakerConfig logLevel?: number + advertisementSource?: AdvertisementSource } = {}, ) { super() @@ -143,6 +161,7 @@ export abstract class SwitchBotDevice extends EventEmitter { this.logger = new Logger(`${info.deviceType}:${info.id}`, options.logLevel) this.bleConnection = options.bleConnection this.apiClient = options.apiClient + this.advertisementSource = options.advertisementSource this.enableFallback = options.enableFallback ?? true this.preferredConnection = options.preferredConnection ?? 'ble' @@ -349,30 +368,28 @@ export abstract class SwitchBotDevice extends EventEmitter { try { return await tryBLE() } catch (err) { - if (this.enableFallback && this.hasAPI()) { - this.logger?.debug?.('BLE getStatus failed, falling back to API', err) - } else { + if (!this.enableFallback || !this.hasAPI()) { + // Nothing to fall back to: report this failure rather than trying BLE again below this.logger?.debug?.('BLE getStatus failed', err) + throw err } + this.logger?.debug?.('BLE getStatus failed, falling back to API', err) } - if (this.enableFallback && this.hasAPI()) { - return await tryAPI() - } + return await tryAPI() } // API-first if (!preferBLE && this.hasAPI()) { try { return await tryAPI() } catch (err) { - if (this.enableFallback && this.hasBLE()) { - this.logger?.debug?.('API getStatus failed, falling back to BLE', err) - } else { + if (!this.enableFallback || !this.hasBLE()) { + // Nothing to fall back to: report this failure rather than trying the API again below this.logger?.debug?.('API getStatus failed', err) + throw err } + this.logger?.debug?.('API getStatus failed, falling back to BLE', err) } - if (this.enableFallback && this.hasBLE()) { - return await tryBLE() - } + return await tryBLE() } // Neither preference branch applied, which happens when the preferred // transport is unavailable: a BLE-preferring device with no BLE, or an @@ -700,6 +717,9 @@ export abstract class SwitchBotDevice extends EventEmitter { if (!this.hasBLE()) { throw new BLENotAvailableError('BLE not available for this device') } + if (this.passiveBLE) { + return this.getAdvertisedStatus() + } try { this.logger.debug('Reading BLE status') @@ -731,6 +751,36 @@ export abstract class SwitchBotDevice extends EventEmitter { } } + /** + * Status of a passive sensor from its advertisements: the last one if it is recent, otherwise the + * next one. Never connects. Throws when no reading arrives, so the caller can fall back to the API. + */ + protected async getAdvertisedStatus(): Promise> { + const last = this.info.bleServiceData + const updatedAt = this.info.bleUpdatedAt + if (last?.sensor && updatedAt !== undefined && Date.now() - updatedAt <= ADVERTISEMENT_MAX_AGE_MS) { + this.info.activeConnection = 'ble' + return last + } + if (this.advertisementSource) { + try { + const fresh = await this.advertisementSource(this.info, ADVERTISEMENT_WAIT_MS) + this.updateInfo({ + bleServiceData: fresh, + bleUpdatedAt: Date.now(), + ...(typeof fresh.battery === 'number' ? { battery: fresh.battery } : {}), + }) + if (fresh.sensor) { + this.info.activeConnection = 'ble' + return fresh + } + } catch (error) { + this.logger.debug('No BLE advertisement received', error) + } + } + throw new BLENotAvailableError(`No recent BLE advertisement with a reading from ${this.info.id}`) + } + private recordBLESuccess(startTime: number, response: Buffer | undefined): CommandResult { const latencyMs = Date.now() - startTime if (this.enableConnectionIntelligence) { diff --git a/src/devices/wo-contact.ts b/src/devices/wo-contact.ts index c7eb0f5c..15d0988a 100644 --- a/src/devices/wo-contact.ts +++ b/src/devices/wo-contact.ts @@ -11,6 +11,9 @@ import { SwitchBotDevice } from './base.js' * Contact Sensor (Door/Window Sensor) */ export class WoContact extends SwitchBotDevice { + /** Read from BLE advertisements, without connecting (pySwitchbot) */ + protected override readonly passiveBLE = true + /** * Get device status (BLE-first, API-fallback) */ @@ -19,9 +22,9 @@ export class WoContact extends SwitchBotDevice { bleData => ({ deviceId: this.info.id, connectionType: 'ble', - openState: bleData.position || 'closed', + openState: bleData.openState ?? 'closed', moveDetected: bleData.movement, - brightness: bleData.lightLevel, + brightness: bleData.brightness, battery: bleData.battery, updatedAt: new Date(), }), diff --git a/src/devices/wo-leak.ts b/src/devices/wo-leak.ts index dfba1086..0ac236ed 100644 --- a/src/devices/wo-leak.ts +++ b/src/devices/wo-leak.ts @@ -11,6 +11,9 @@ import { SwitchBotDevice } from './base.js' * Water Leak Detector Device */ export class WoLeak extends SwitchBotDevice { + /** Read from BLE advertisements, without connecting (pySwitchbot) */ + protected override readonly passiveBLE = true + /** * Get device status (BLE-first, API-fallback) */ diff --git a/src/devices/wo-presence.ts b/src/devices/wo-presence.ts index 05e5f22c..fc471049 100644 --- a/src/devices/wo-presence.ts +++ b/src/devices/wo-presence.ts @@ -11,6 +11,9 @@ import { SwitchBotDevice } from './base.js' * Motion/Presence Sensor */ export class WoPresence extends SwitchBotDevice { + /** Read from BLE advertisements, without connecting (pySwitchbot) */ + protected override readonly passiveBLE = true + /** * Get device status (BLE-first, API-fallback) */ @@ -20,7 +23,7 @@ export class WoPresence extends SwitchBotDevice { deviceId: this.info.id, connectionType: 'ble', moveDetected: bleData.movement || false, - brightness: bleData.lightLevel, + brightness: bleData.brightness, battery: bleData.battery, updatedAt: new Date(), }), diff --git a/src/devices/wo-sensor-th-pro-co2.ts b/src/devices/wo-sensor-th-pro-co2.ts index bea011eb..4390daa4 100644 --- a/src/devices/wo-sensor-th-pro-co2.ts +++ b/src/devices/wo-sensor-th-pro-co2.ts @@ -32,6 +32,7 @@ export class WoSensorTHProCO2 extends WoSensorTH { humidity: bleData.humidity || 0, temperatureScale: bleData.fahrenheit ? 'f' : 'c', battery: bleData.battery, + ...(typeof bleData.co2 === 'number' ? { co2: bleData.co2 } : {}), updatedAt: new Date(), }), (apiStatus) => { diff --git a/src/devices/wo-sensor-th.ts b/src/devices/wo-sensor-th.ts index b8ffe288..5ab9dbf3 100644 --- a/src/devices/wo-sensor-th.ts +++ b/src/devices/wo-sensor-th.ts @@ -11,6 +11,9 @@ import { SwitchBotDevice } from './base.js' * Meter (Temperature/Humidity Sensor) */ export class WoSensorTH extends SwitchBotDevice { + /** Read from BLE advertisements, without connecting (pySwitchbot) */ + protected override readonly passiveBLE = true + /** * Get device status (BLE-first, API-fallback) */ diff --git a/src/switchbot.ts b/src/switchbot.ts index a0441a4d..43c8daa0 100644 --- a/src/switchbot.ts +++ b/src/switchbot.ts @@ -4,7 +4,8 @@ */ import type { SwitchBotDevice } from './devices/base.js' -import type { ConnectionType, DiscoveryOptions, LogLevel, SwitchBotConfig } from './types/index.js' +import type { BLEAdvertisement } from './types/ble.js' +import type { ConnectionType, DeviceInfo, DiscoveryOptions, LogLevel, SwitchBotConfig } from './types/index.js' import { EventEmitter } from 'node:events' @@ -73,8 +74,9 @@ import { WoVacuumS10 } from './devices/wo-vacuum-s10.js' import { WoVacuumS20 } from './devices/wo-vacuum-s20.js' import { WoVacuum } from './devices/wo-vacuum.js' import { WoWaterDetector } from './devices/wo-water-detector.js' +import { BLENotAvailableError } from './errors.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 @@ -163,6 +165,12 @@ export class SwitchBot extends EventEmitter { private initialized = false // Track pending device creations for async API discovery private _pendingDeviceCreations: Promise[] = [] + // A short scan started to hear a sensor's advertisement runs until this time (ms) + private passiveScanUntil = 0 + // Keeps known devices' advertisement data current whenever the scanner is running + private readonly advertisementListener = (advertisement: BLEAdvertisement): void => { + this.applyAdvertisement(advertisement) + } constructor(config: SwitchBotConfig = {}) { super() @@ -220,6 +228,8 @@ export class SwitchBot extends EventEmitter { logLevel: this.config.logLevel, }) + this.bleScanner.on('discover', this.advertisementListener) + this.logger.info('BLE initialized') } catch (error) { this.logger.error('Failed to initialize BLE', error) @@ -369,6 +379,7 @@ export class SwitchBot extends EventEmitter { activeConnection: undefined, battery: serviceData.battery, bleServiceData: serviceData, + bleUpdatedAt: Date.now(), rssi, } @@ -382,6 +393,7 @@ export class SwitchBot extends EventEmitter { connectionTypes: [...new Set([...device.getInfo().connectionTypes, 'ble' as ConnectionType])], battery: serviceData.battery, bleServiceData: serviceData, + bleUpdatedAt: info.bleUpdatedAt, rssi, }) this.logger.debug(`Updated device: ${info.name} (${info.id})`) @@ -546,6 +558,9 @@ export class SwitchBot extends EventEmitter { ...extractDeviceOptionsFromConfig(this.config), apiClient: this.apiClient, bleConnection: this.bleConnection, + advertisementSource: this.bleScanner + ? (deviceInfo: DeviceInfo, timeoutMs: number) => this.waitForAdvertisement(deviceInfo, timeoutMs) + : undefined, }) } catch (error) { this.logger.error(`createDevice: Failed to create device ${className}`, error) @@ -608,6 +623,67 @@ export class SwitchBot extends EventEmitter { return !!this.apiClient } + /** Find the known device an advertisement belongs to */ + private findAdvertisedDevice(advertisement: BLEAdvertisement): SwitchBotDevice | undefined { + if (advertisement.address) { + const byMac = this.deviceManager.get(macToDeviceId(advertisement.address)) + if (byMac) { + return byMac + } + } + return this.deviceManager.list().find(device => device.getInfo().bleId === advertisement.id) + } + + /** Store an advertisement's data on the known device it belongs to */ + private applyAdvertisement(advertisement: BLEAdvertisement): void { + const device = this.findAdvertisedDevice(advertisement) + if (!device) { + return + } + const { serviceData, rssi } = advertisement + device.updateInfo({ + bleServiceData: serviceData as Record, + bleUpdatedAt: Date.now(), + rssi, + ...(typeof serviceData.battery === 'number' ? { battery: serviceData.battery } : {}), + }) + } + + /** + * Wait for the next advertisement from a device, running a short scan if none is running. + * Used to read sensors (meters, contact, motion, leak) without connecting to them. + */ + private async waitForAdvertisement(info: DeviceInfo, timeoutMs: number): Promise> { + const scanner = this.bleScanner + if (!scanner) { + throw new BLENotAvailableError('BLE not available') + } + const mac = info.mac ? normalizeMAC(info.mac) : undefined + const received = new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + scanner.off('discover', handler) + reject(new BLENotAvailableError(`No BLE advertisement from ${info.id} within ${timeoutMs}ms`)) + }, timeoutMs) + function handler(advertisement: BLEAdvertisement): void { + const matches = (mac && advertisement.address && normalizeMAC(advertisement.address) === mac) + || (info.bleId && advertisement.id === info.bleId) + if (matches) { + clearTimeout(timer) + scanner!.off('discover', handler) + resolve(advertisement.serviceData as Record) + } + } + scanner.on('discover', handler) + }) + if (!scanner.isScanning() && Date.now() >= this.passiveScanUntil) { + this.passiveScanUntil = Date.now() + timeoutMs + scanner.startScan({ duration: timeoutMs }).catch((error) => { + this.logger.debug('BLE scan for a sensor advertisement failed', error) + }) + } + return received + } + /** * Cleanup and disconnect */ @@ -615,6 +691,7 @@ export class SwitchBot extends EventEmitter { this.logger.info('Cleaning up') if (this.bleScanner) { + this.bleScanner.off('discover', this.advertisementListener) this.bleScanner.destroy() } diff --git a/src/types/index.ts b/src/types/index.ts index bd48b44d..e7fda268 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 received (ms since epoch) */ + bleUpdatedAt?: 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/src/utils/index.ts b/src/utils/index.ts index b8ed2cc6..aa15984a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -394,3 +394,4 @@ export { } from './fallback-handler.js' // Export advanced utilities export { type RetryConfig, RetryExecutor, type RetryResult } from './retry.js' +export { isPassiveSensorModel, parseSensorAdvertisement, type SensorAdvertisement, switchBotManufacturerPayload } from './sensor-advertisement.js' diff --git a/src/utils/sensor-advertisement.ts b/src/utils/sensor-advertisement.ts new file mode 100644 index 00000000..034b6e8a --- /dev/null +++ b/src/utils/sensor-advertisement.ts @@ -0,0 +1,176 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * utils/sensor-advertisement.ts: Passive sensor readings from BLE advertisements + * + * Meters, contact, motion and leak sensors report their state in their advertisements, and + * pySwitchbot reads them that way without ever connecting (switchbot/adv_parsers/meter.py, + * contact.py, motion.py, leak.py). Many of these sensors refuse GATT connections. + * + * `mfrData` is the SwitchBot manufacturer data payload after the 2-byte company ID (0x0969), as + * pySwitchbot's `mfr_data`. + */ + +import { Buffer } from 'node:buffer' + +/** Readings parsed from a passive sensor advertisement */ +export interface SensorAdvertisement { + /** Set when the advertisement carried a sensor reading */ + sensor: true + battery?: number + temperature?: number + humidity?: number + fahrenheit?: boolean + co2?: number + openState?: 'open' | 'closed' | 'timeout' + movement?: boolean + brightness?: 'bright' | 'dim' + waterLeakDetected?: boolean + tampered?: boolean +} + +const METER_MODELS = new Set(['T', 't', 'i', 'I', '4', '\x14', '5', '\x15', 'w', 'W']) +const CO2_MODELS = new Set(['5', '\x15']) +const CONTACT_MODELS = new Set(['d', 'D']) +const MOTION_MODELS = new Set(['s', 'S']) +const LEAK_MODELS = new Set(['&', '\x06']) + +// Meter Pro CO2 spec range is 400-9999 ppm; higher values are parsing artifacts (pySwitchbot) +const CO2_MAX_PPM = 9999 + +/** Advertisement model codes whose state is read passively, without connecting */ +export function isPassiveSensorModel(model: string): boolean { + return METER_MODELS.has(model) || CONTACT_MODELS.has(model) || MOTION_MODELS.has(model) || LEAK_MODELS.has(model) +} + +/** + * The SwitchBot manufacturer data payload after the company ID, or undefined for other vendors. + * noble includes the little-endian company ID (0x69 0x09) in `manufacturerData`. + */ +export function switchBotManufacturerPayload(manufacturerData: unknown): Buffer | undefined { + if (!Buffer.isBuffer(manufacturerData) || manufacturerData.length < 2) { + return undefined + } + if (manufacturerData[0] !== 0x69 || manufacturerData[1] !== 0x09) { + return undefined + } + return manufacturerData.subarray(2) +} + +function decodeTempHumidity(t: Buffer): Pick | undefined { + const sign = (t[1] & 0x80) ? 1 : -1 + const temperature = sign * ((t[1] & 0x7F) + (t[0] & 0x0F) / 10) + const humidity = t[2] & 0x7F + if (temperature === 0 && humidity === 0) { + return undefined + } + return { temperature: Math.round(temperature * 10) / 10, humidity, fahrenheit: (t[2] & 0x80) !== 0 } +} + +function parseMeter(model: string, data: Buffer, mfr?: Buffer): SensorAdvertisement | undefined { + let tempData: Buffer | undefined + if (mfr && mfr.length >= 11) { + tempData = mfr.subarray(8, 11) + } else if (data.length >= 6) { + tempData = data.subarray(3, 6) + } + if (!tempData) { + return undefined + } + const reading = decodeTempHumidity(tempData) + if (!reading) { + return undefined + } + const result: SensorAdvertisement = { sensor: true, ...reading } + if (data.length >= 3) { + result.battery = data[2] & 0x7F + } + if (CO2_MODELS.has(model) && mfr && mfr.length >= 15) { + const co2 = mfr.readUInt16BE(13) + if (co2 <= CO2_MAX_PPM) { + result.co2 = co2 + } + } + return result +} + +function parseContact(data: Buffer, mfr?: Buffer): SensorAdvertisement | undefined { + const fullMfr = !!mfr && mfr.length >= 13 + const fullData = data.length >= 9 + if (!fullMfr && !fullData) { + return undefined + } + let open: boolean + let timeout: boolean + let movement: boolean + let light: boolean + if (fullMfr) { + movement = (mfr![7] & 0x80) !== 0 + open = (mfr![7] & 0x10) !== 0 + timeout = (mfr![7] & 0x20) !== 0 + light = (mfr![7] & 0x40) !== 0 + } else { + movement = (data[1] & 0x40) !== 0 + open = (data[3] & 0x02) !== 0 + timeout = (data[3] & 0x04) !== 0 + light = (data[3] & 0x01) !== 0 + } + return { + sensor: true, + battery: data.length >= 3 ? data[2] & 0x7F : undefined, + // A timeout still means the door is open (pySwitchbot) + openState: timeout ? 'timeout' : open ? 'open' : 'closed', + movement, + brightness: light ? 'bright' : 'dim', + } +} + +function parseMotion(data: Buffer, mfr?: Buffer): SensorAdvertisement | undefined { + let movement: boolean | undefined + let light: boolean | undefined + let battery: number | undefined + if (data.length >= 6) { + movement = (data[1] & 0x40) !== 0 + battery = data[2] & 0x7F + light = (data[5] & 0x02) !== 0 + } + if (mfr && mfr.length >= 8) { + movement = (mfr[7] & 0x40) !== 0 + light = (mfr[7] & 0x20) !== 0 + } + if (movement === undefined) { + return undefined + } + return { sensor: true, battery, movement, brightness: light ? 'bright' : 'dim' } +} + +function parseLeak(mfr?: Buffer): SensorAdvertisement | undefined { + if (!mfr || mfr.length < 9) { + return undefined + } + return { + sensor: true, + battery: mfr[7] & 0x7F, + waterLeakDetected: (mfr[8] & 0x01) !== 0, + tampered: (mfr[8] & 0x02) !== 0, + } +} + +/** + * Parse a passive sensor's readings from its service data and manufacturer data payload. + * Returns undefined for other models, or when the advertisement carries no reading. + */ +export function parseSensorAdvertisement(model: string, data: Buffer, mfr?: Buffer): SensorAdvertisement | undefined { + if (METER_MODELS.has(model)) { + return parseMeter(model, data, mfr) + } + if (CONTACT_MODELS.has(model)) { + return parseContact(data, mfr) + } + if (MOTION_MODELS.has(model)) { + return parseMotion(data, mfr) + } + if (LEAK_MODELS.has(model)) { + return parseLeak(mfr) + } + return undefined +} diff --git a/test/apis/passive-sensor-status.test.ts b/test/apis/passive-sensor-status.test.ts new file mode 100644 index 00000000..eda25239 --- /dev/null +++ b/test/apis/passive-sensor-status.test.ts @@ -0,0 +1,95 @@ +import { Buffer } from 'node:buffer' +import { EventEmitter } from 'node:events' + +import { describe, expect, it, vi } from 'vitest' + +import { SwitchBot } from '../../src/switchbot.js' + +// Meters and other sensors were read by connecting over GATT, which many of them refuse +// ("Unknown Connection Identifier (0x2)"), so their values stayed at 0. +// https://github.com/OpenWonderLabs/homebridge-switchbot/issues/1437 + +class MockNoble extends EventEmitter { + state = 'poweredOn' + peripherals: any[] = [] + startScanning = vi.fn((): void => { + this.emit('scanStart') + }) + + stopScanning = vi.fn((): void => { + this.emit('scanStop') + }) +} + +const MAC = 'aa:bb:cc:dd:ee:ff' + +// pySwitchbot fixture: 24.6 °C, 53 %, battery 100 +function meterPeripheral(tempByte = 0x98) { + return { + id: 'meter-1', + address: MAC, + rssi: -60, + connectable: false, + connect: vi.fn((cb: (error?: Error) => void) => cb(new Error('Unknown Connection Identifier (0x2)'))), + advertisement: { + serviceData: [{ uuid: 'fd3d', data: Buffer.from('5400e4069835', 'hex') }], + manufacturerData: Buffer.from(`6909d7c17d5deb43de0306${tempByte.toString(16)}35`, 'hex'), + }, + } +} + +async function discoverMeter() { + const noble = new MockNoble() + const peripheral = meterPeripheral() + noble.peripherals = [peripheral] + const switchbot = new SwitchBot({ enableBLE: true, noble, scanTimeout: 20 } as any) + noble.startScanning.mockImplementation(() => { + noble.emit('scanStart') + setImmediate(() => noble.emit('discover', peripheral)) + }) + const devices = await switchbot.discover({ timeout: 20 } as any) + return { noble, peripheral, switchbot, device: devices[0] as any } +} + +describe('passive sensor status', () => { + it('discovers a non-connectable meter and reads it from the advertisement', async () => { + const { device, peripheral, switchbot } = await discoverMeter() + expect(device).toBeDefined() + const status = await device.getStatus() + expect(status).toMatchObject({ connectionType: 'ble', temperature: 24.6, humidity: 53, battery: 100, temperatureScale: 'c' }) + expect(peripheral.connect).not.toHaveBeenCalled() + await switchbot.cleanup() + }) + + it('listens for a fresh advertisement when the last one is stale', async () => { + const { device, noble, peripheral, switchbot } = await discoverMeter() + device.updateInfo({ bleUpdatedAt: Date.now() - 10 * 60 * 1000 }) + const warmer = meterPeripheral(0x99) // 25.6 °C + noble.startScanning.mockImplementation(() => { + noble.emit('scanStart') + setImmediate(() => noble.emit('discover', warmer)) + }) + const status = await device.getStatus() + expect(status.temperature).toBe(25.6) + expect(peripheral.connect).not.toHaveBeenCalled() + await switchbot.cleanup() + }) + + it('keeps readings current from any running scan', async () => { + const { device, noble, switchbot } = await discoverMeter() + noble.emit('discover', meterPeripheral(0x99)) + expect((await device.getStatus()).temperature).toBe(25.6) + await switchbot.cleanup() + }) + + it('fails without connecting when no advertisement arrives and there is no API', async () => { + const { device, noble, peripheral, switchbot } = await discoverMeter() + device.updateInfo({ bleUpdatedAt: Date.now() - 10 * 60 * 1000 }) + noble.startScanning.mockImplementation(() => { + noble.emit('scanStart') + }) + await expect(device.getStatus()).rejects.toThrow() + expect(peripheral.connect).not.toHaveBeenCalled() + await switchbot.cleanup() + }, 15_000) +}) diff --git a/test/utils/sensor-advertisement.test.ts b/test/utils/sensor-advertisement.test.ts new file mode 100644 index 00000000..d33064f3 --- /dev/null +++ b/test/utils/sensor-advertisement.test.ts @@ -0,0 +1,97 @@ +import { Buffer } from 'node:buffer' + +import { describe, expect, it } from 'vitest' + +import { isPassiveSensorModel, parseSensorAdvertisement, switchBotManufacturerPayload } from '../../src/utils/sensor-advertisement.js' + +// Fixtures are pySwitchbot's own (tests/test_adv_parser.py), with the values it expects. +const hex = (value: string) => Buffer.from(value, 'hex') +const COMPANY_ID = hex('6909') + +describe('parseSensorAdvertisement (pySwitchbot fixtures)', () => { + it('meter: reads temperature and humidity from manufacturer data', () => { + expect(parseSensorAdvertisement('T', hex('5400e4069835'), hex('d7c17d5deb43de03069835'))).toEqual({ + sensor: true, + battery: 100, + temperature: 24.6, + humidity: 53, + fahrenheit: false, + }) + }) + + it('meter: falls back to service data without manufacturer data', () => { + expect(parseSensorAdvertisement('T', hex('5400e4069835'))).toMatchObject({ temperature: 24.6, humidity: 53, battery: 100 }) + }) + + it('meter: no reading from an all-zero payload', () => { + expect(parseSensorAdvertisement('T', hex('5400e4000000'))).toBeUndefined() + }) + + it('meter pro CO2: reads CO2 and the fahrenheit flag', () => { + expect(parseSensorAdvertisement('5', hex('350064'), hex('b0e9fe543215b7e4079ba4003702d500'))).toEqual({ + sensor: true, + battery: 100, + temperature: 27.7, + humidity: 36, + fahrenheit: true, + co2: 725, + }) + }) + + it('meter pro CO2: drops out-of-range CO2', () => { + const result = parseSensorAdvertisement('5', hex('350064'), hex('b0e9fe543215b7e4079ba400379c4000')) + expect(result?.co2).toBeUndefined() + expect(result?.temperature).toBe(27.7) + }) + + it('contact: a timeout reports the door as open', () => { + expect(parseSensorAdvertisement('d', hex('6400da040046018fc4'), hex('cb39cdc43d46412c0046018fc4'))).toEqual({ + sensor: true, + battery: 90, + openState: 'timeout', + movement: false, + brightness: 'dim', + }) + }) + + it('motion: motion detected', () => { + expect(parseSensorAdvertisement('s', hex('7340e2003801'), hex('c0219ae8bc49695c0038'))).toMatchObject({ + movement: true, + brightness: 'dim', + battery: 98, + }) + }) + + it('leak: no leak, battery 78', () => { + expect(parseSensorAdvertisement('&', hex('26004e'), hex('c43430374c7a184e98675e94513c0500000000'))).toEqual({ + sensor: true, + battery: 78, + waterLeakDetected: false, + tampered: false, + }) + }) + + it('leak: leak detected', () => { + expect(parseSensorAdvertisement('&', hex('260064'), hex('c43430374c7a4764f96761c4083c0500000000'))).toMatchObject({ + waterLeakDetected: true, + battery: 100, + }) + }) + + it('ignores other models', () => { + expect(parseSensorAdvertisement('H', hex('48000064'))).toBeUndefined() + expect(isPassiveSensorModel('H')).toBe(false) + expect(isPassiveSensorModel('T')).toBe(true) + }) +}) + +describe('switchBotManufacturerPayload', () => { + it('strips the SwitchBot company ID', () => { + expect(switchBotManufacturerPayload(Buffer.concat([COMPANY_ID, hex('aabb')]))?.toString('hex')).toBe('aabb') + }) + + it('ignores other vendors', () => { + expect(switchBotManufacturerPayload(hex('4c000215'))).toBeUndefined() + expect(switchBotManufacturerPayload(undefined)).toBeUndefined() + }) +})