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 4c673d30..e8224a14 100644 --- a/src/devices/base.ts +++ b/src/devices/base.ts @@ -364,30 +364,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 diff --git a/src/devices/wo-contact.ts b/src/devices/wo-contact.ts index 205bdba8..d66a8873 100644 --- a/src/devices/wo-contact.ts +++ b/src/devices/wo-contact.ts @@ -24,9 +24,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-presence.ts b/src/devices/wo-presence.ts index 632ecbbd..29c8537e 100644 --- a/src/devices/wo-presence.ts +++ b/src/devices/wo-presence.ts @@ -25,7 +25,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/switchbot.ts b/src/switchbot.ts index f88458e1..af7adde3 100644 --- a/src/switchbot.ts +++ b/src/switchbot.ts @@ -4,6 +4,7 @@ */ import type { SwitchBotDevice } from './devices/base.js' +import type { BLEAdvertisement } from './types/ble.js' import type { ConnectionType, DiscoveryOptions, LogLevel, SwitchBotConfig } from './types/index.js' import { EventEmitter } from 'node:events' @@ -163,6 +164,11 @@ export class SwitchBot extends EventEmitter { private initialized = false // Track pending device creations for async API discovery private _pendingDeviceCreations: Promise[] = [] + // Keeps known devices' advertisement data current whenever any scan is running, not only during + // discover(), so sensors read from advertisements stay fresh + private readonly advertisementListener = (advertisement: BLEAdvertisement): void => { + this.applyAdvertisement(advertisement) + } constructor(config: SwitchBotConfig = {}) { super() @@ -220,6 +226,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) @@ -652,6 +660,32 @@ 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, + bleServiceDataAt: Date.now(), + rssi, + ...(typeof serviceData.battery === 'number' ? { battery: serviceData.battery } : {}), + }) + } + /** * Cleanup and disconnect */ @@ -659,6 +693,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/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..d124c49a --- /dev/null +++ b/test/apis/passive-sensor-status.test.ts @@ -0,0 +1,96 @@ +import { Buffer } from 'node:buffer' +import { EventEmitter } from 'node:events' + +import { describe, expect, it, vi } from 'vitest' + +import { SwitchBot } from '../../src/switchbot.js' + +// End to end with a fake adapter: real advertisement bytes must produce readings. Reading sensors +// from advertisements (6.0.1) still returned 0 because the scanner never parsed their readings, +// ignored manufacturer data and dropped non-connectable advertisements. +// 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({ bleServiceDataAt: 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('keeps the last reading, without connecting, when no new advertisement arrives', async () => { + const { device, noble, peripheral, switchbot } = await discoverMeter() + device.updateInfo({ bleServiceDataAt: Date.now() - 10 * 60 * 1000 }) + noble.startScanning.mockImplementation(() => { + noble.emit('scanStart') + }) + expect((await device.getStatus()).temperature).toBe(24.6) + 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() + }) +})