Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion src/devices/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:<bleId>`); resolves with its parsed
* service data, or undefined when none arrives in time
*/
export type AdvertisementSource = (address: string, bleId: string | undefined, timeoutMs: number) => Promise<Record<string, unknown> | 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
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand Down Expand Up @@ -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<Record<string, unknown>> {
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<any> {
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()
Expand Down
5 changes: 5 additions & 0 deletions src/devices/wo-contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
5 changes: 5 additions & 0 deletions src/devices/wo-leak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
5 changes: 5 additions & 0 deletions src/devices/wo-presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
5 changes: 5 additions & 0 deletions src/devices/wo-sensor-th.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
5 changes: 5 additions & 0 deletions src/devices/wo-water-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
46 changes: 45 additions & 1 deletion src/switchbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -369,6 +369,7 @@ export class SwitchBot extends EventEmitter {
activeConnection: undefined,
battery: serviceData.battery,
bleServiceData: serviceData,
bleServiceDataAt: Date.now(),
rssi,
}

Expand All @@ -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})`)
Expand Down Expand Up @@ -546,13 +548,55 @@ 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)
return undefined
}
}

/** One short scan shared by every device waiting for an advertisement */
private advertisementScan?: Promise<void>

/**
* 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<Record<string, unknown> | 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<string, unknown> | 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)
*/
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ export interface DeviceInfo {
rssi?: number
/** Last parsed BLE advertisement service data */
bleServiceData?: Record<string, unknown>
/** 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) */
Expand Down
145 changes: 145 additions & 0 deletions test/devices/sensor-advertisement-status.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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)
})
})
Loading