diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2c989f7a..6d769369 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -44,7 +44,13 @@ This library tracks pySwitchbot. To port a release: `DEVICE_MODEL_MAP` mirrors pySwitchbot's single-byte `SUPPORTED_TYPES` codes. A few extra codes (Hub 2 `0x01`, Hub 3 `0x02`, `0x09`, `0x0E`, `0x10`, `0x11`, `k`, `p`) come from earlier node-switchbot releases and have no pySwitchbot equivalent. `SwitchBotBLEModel` in -`src/types/ble.ts` still has older values for some products; changing it is a breaking change. +`src/types/ble.ts` holds each product's primary pySwitchbot code; changing a value is a breaking +change. + +Encrypted devices (relay switches, locks, air purifiers...) use pySwitchbot's +`SwitchbotEncryptedDevice` protocol, implemented by `BLEConnection.sendEncryptedCommand`: set the +account's key id and key with `device.setKey({ keyId, encryptionKey })`. Command replies arrive as +notifications on the notify characteristic, as in pySwitchbot. OpenAPI command names and parameters must match the device pages in [SwitchBotAPI](https://github.com/OpenWonderLabs/SwitchBotAPI/tree/latest/devices); add a test in diff --git a/src/ble.ts b/src/ble.ts index 3c334355..dbcb5a4b 100644 --- a/src/ble.ts +++ b/src/ble.ts @@ -6,7 +6,7 @@ import type { BLEAdvertisement, BLEScanOptions, BLEServiceData } from './types/ble.js' import { Buffer } from 'node:buffer' -import { createCipheriv } from 'node:crypto' +import { createCipheriv, createDecipheriv } from 'node:crypto' import { EventEmitter } from 'node:events' import { BLENotAvailableError, CommandFailedError, DeviceNotFoundError } from './errors.js' @@ -474,6 +474,71 @@ export class BLEScanner extends EventEmitter { } } +interface EncryptedSession { + mode: 'ctr' | 'gcm' + iv: Buffer +} + +/** Parse the reply to the encryption init command (pySwitchbot _ensure_encryption_initialized) */ +export function parseEncryptionInit(reply: Buffer | undefined, mac: string): EncryptedSession { + if (!reply || reply[0] !== 0x01) { + throw new CommandFailedError(`Encryption init rejected by ${mac}${reply?.length ? `: ${reply.toString('hex')}` : ''}`, 'ble') + } + const modeByte = reply[2] + if (modeByte !== 0 && modeByte !== 1) { + throw new CommandFailedError(`Unsupported encryption mode ${modeByte} from ${mac}`, 'ble') + } + const mode = modeByte === 1 ? 'gcm' : 'ctr' + const iv = mode === 'gcm' ? reply.subarray(4, reply.length - 4) : reply.subarray(4) + const expected = mode === 'gcm' ? 12 : 16 + if (iv.length !== expected) { + throw new CommandFailedError(`Invalid IV length ${iv.length} for ${mode.toUpperCase()} from ${mac} (expected ${expected})`, 'ble') + } + return { mode, iv: Buffer.from(iv) } +} + +/** Encrypt a command body; returns the ciphertext and the 2-byte packet header */ +export function encryptPayload(key: Buffer, session: EncryptedSession, data: Buffer): { ciphertext: Buffer, header: Buffer } { + if (data.length === 0) { + return { ciphertext: Buffer.alloc(0), header: Buffer.alloc(0) } + } + if (session.mode === 'gcm') { + const cipher = createCipheriv('aes-128-gcm', key, session.iv) + const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]) + return { ciphertext, header: cipher.getAuthTag().subarray(0, 2) } + } + const cipher = createCipheriv('aes-128-ctr', key, session.iv) + return { ciphertext: Buffer.concat([cipher.update(data), cipher.final()]), header: session.iv.subarray(0, 2) } +} + +/** Decrypt a reply payload. GCM replies carry only a partial tag, so the tag is not checked. */ +export function decryptPayload(key: Buffer, session: EncryptedSession, data: Buffer): Buffer { + if (data.length === 0) { + return Buffer.alloc(0) + } + if (session.mode === 'gcm') { + const decipher = createDecipheriv('aes-128-gcm', key, session.iv) + decipher.setAuthTag(Buffer.alloc(16)) + return decipher.update(data) + } + const decipher = createDecipheriv('aes-128-ctr', key, session.iv) + return Buffer.concat([decipher.update(data), decipher.final()]) +} + +/** Increment an IV as a big-endian integer */ +function incrementIv(iv: Buffer): Buffer { + const next = Buffer.from(iv) + for (let i = next.length - 1; i >= 0; i--) { + if (next[i] === 0xFF) { + next[i] = 0 + } else { + next[i]++ + break + } + } + return next +} + /** * BLE Connection for communicating with SwitchBot devices */ @@ -485,6 +550,8 @@ export class BLEConnection { private disconnectTimers: Map = new Map() private operationLocks: Map> = new Map() private encryptionConfig: Map = new Map() + // pySwitchbot-style encrypted sessions (IV and mode reported by the device), reset on disconnect + private encryptedSessions: Map = new Map() private notificationHandlers: Map void>> = new Map() // noble keeps only the latest pending subscribe callback per characteristic, so share in-flight subscribes private pendingSubscriptions: WeakMap> = new WeakMap() @@ -493,6 +560,8 @@ export class BLEConnection { // Notification handling private notificationFutures: Map void, reject: (err: Error) => void, timer: NodeJS.Timeout }> = new Map() + // Notify characteristics that already have our 'data' listener (rediscovery yields new objects) + private notifyListeners: WeakSet = new WeakSet() // Expected disconnect tracking private expectedDisconnects: Set = new Set() @@ -720,56 +789,193 @@ export class BLEConnection { const { expectResponse = true, validateResponse = true, - responseTimeoutMs = 1200, + responseTimeoutMs = 5000, expectNotification = false, notificationTimeoutMs = 5000, } = options return this.withMacLock(normalizedMac, async () => { const payload = this.encryptIfConfigured(normalizedMac, data) + return this.exchangeLocked(normalizedMac, payload, { expectResponse, validateResponse, responseTimeoutMs, expectNotification, notificationTimeoutMs }) + }) + } + + /** + * Write a packet and return the device's reply. The caller holds the MAC lock. + */ + private async exchangeLocked( + normalizedMac: string, + payload: Buffer, + options: { expectResponse: boolean, validateResponse: boolean, responseTimeoutMs: number, expectNotification: boolean, notificationTimeoutMs: number }, + ): Promise { + const { expectResponse, validateResponse, responseTimeoutMs, expectNotification, notificationTimeoutMs } = options + if (!expectNotification && !expectResponse && !validateResponse) { await this.write(normalizedMac, payload) + return undefined + } - if (expectNotification) { - // DEBUG: Log future creation - this.logger.debug(`[DEBUG] Creating notification future for ${normalizedMac}`) - // Wait for notification (per-command future) - return await new Promise((resolve, reject) => { - if (this.notificationFutures.has(normalizedMac)) { - this.logger.warn(`Notification future already exists for ${normalizedMac}, overwriting`) - const prev = this.notificationFutures.get(normalizedMac) - if (prev) { - clearTimeout(prev.timer) - } - } - const timer = setTimeout(() => { - this.logger.debug(`[DEBUG] Notification future timed out for ${normalizedMac}`) - this.notificationFutures.delete(normalizedMac) - reject(new Error(`Notification timeout (${notificationTimeoutMs}ms) for ${normalizedMac}`)) - }, notificationTimeoutMs) - this.notificationFutures.set(normalizedMac, { resolve, reject, timer }) - // TEST HOOK: Notify when the notification future is set - if (typeof (this as any)._onNotificationFutureSet === 'function') { - (this as any)._onNotificationFutureSet(normalizedMac) - } - this.logger.debug(`[DEBUG] notificationFutures after set: ${[...this.notificationFutures.keys()].join(',')}`) - }) + // A SwitchBot device answers a command with a notification on the notify characteristic + // (pySwitchbot _execute_command_locked). Start waiting before writing, so a fast reply is + // not missed. Backends without notification events fall back to reading the characteristic. + const reply = await this.prepareReply(normalizedMac, expectNotification ? notificationTimeoutMs : responseTimeoutMs) + try { + await this.write(normalizedMac, payload) + } catch (error) { + reply?.cancel() + throw error + } + + const response = reply + ? await reply.promise + : await withTimeout(this.read(normalizedMac), responseTimeoutMs, `BLE response timeout from ${normalizedMac}`) + + if (validateResponse && !expectNotification) { + this.validateCommandResult(response, normalizedMac) + } + + return response + } + + /** + * Send a command to an encrypted device and return its decrypted reply, using the protocol in + * pySwitchbot's SwitchbotEncryptedDevice: + * + * - Session setup, once per connection: send `57 000000 0F2103 ` unencrypted. The reply is + * status (0x01), byte 2 the AES mode (0 CTR, 1 GCM), then the IV (CTR: bytes 4..; GCM: 12 bytes + * from byte 4). + * - Command `57 ` is sent as `57 <2-byte header> `, where the header is + * IV[0..2] for CTR or the first 2 bytes of the GCM tag. + * - The reply is ` <3 bytes> `; this returns ` `. In GCM + * mode the IV is incremented after every command. + */ + async sendEncryptedCommand( + mac: string, + command: Buffer, + options: { + keyId: string + encryptionKey: string + validateResponse?: boolean + responseTimeoutMs?: number + }, + ): Promise { + const normalizedMac = normalizeMAC(mac) + const key = Buffer.from(options.encryptionKey, 'hex') + const keyId = Buffer.from(options.keyId, 'hex') + if (key.length !== 16) { + throw new CommandFailedError('Invalid BLE encryption key (expected 32 hex characters)', 'ble') + } + if (keyId.length !== 1 || options.keyId.length !== 2) { + throw new CommandFailedError('Invalid BLE key id (expected 2 hex characters)', 'ble') + } + if (command.length < 1) { + throw new CommandFailedError('Empty BLE command', 'ble') + } + const exchange = { + expectResponse: true, + validateResponse: options.validateResponse ?? true, + responseTimeoutMs: options.responseTimeoutMs ?? 5000, + expectNotification: false, + notificationTimeoutMs: 5000, + } + + return this.withMacLock(normalizedMac, async () => { + let session = this.encryptedSessions.get(normalizedMac) + if (!session) { + const init = await this.exchangeLocked( + normalizedMac, + Buffer.from([0x57, 0x00, 0x00, 0x00, 0x0F, 0x21, 0x03, keyId[0]]), + { ...exchange, validateResponse: false }, + ) + session = parseEncryptionInit(init, normalizedMac) + this.encryptedSessions.set(normalizedMac, session) } - if (!expectResponse && !validateResponse) { - return undefined + const { ciphertext, header } = encryptPayload(key, session, command.subarray(1)) + const packet = Buffer.concat([command.subarray(0, 1), keyId, header, ciphertext]) + const reply = await this.exchangeLocked(normalizedMac, packet, exchange) + if (!reply || reply.length === 0) { + throw new CommandFailedError(`Empty BLE response from ${normalizedMac}`, 'ble') + } + const decrypted = Buffer.concat([reply.subarray(0, 1), decryptPayload(key, session, reply.subarray(4))]) + if (session.mode === 'gcm') { + session.iv = incrementIv(session.iv) } + return decrypted + }) + } - const response = await withTimeout( - this.read(normalizedMac), - responseTimeoutMs, - `BLE response timeout from ${normalizedMac}`, - ) + /** + * Register a wait for the next notification from a device, before the command is written. + * Returns undefined when the notify characteristic emits no events (then the caller reads it). + */ + private async prepareReply(mac: string, timeoutMs: number): Promise<{ promise: Promise, cancel: () => void } | undefined> { + if (!this.connections.has(mac)) { + await this.connect(mac) + } + let chars = this.characteristics.get(mac) + if (!chars) { + await this.discoverCharacteristics(mac, this.connections.get(mac)) + chars = this.characteristics.get(mac) + } + if (!chars?.notify || typeof chars.notify.on !== 'function') { + return undefined + } + this.ensureNotifyListener(mac, chars.notify) + + const previous = this.notificationFutures.get(mac) + if (previous) { + clearTimeout(previous.timer) + previous.reject(new Error(`Superseded by a newer command for ${mac}`)) + } - if (validateResponse) { - this.validateCommandResult(response, normalizedMac) + let future!: { resolve: (payload: Buffer) => void, reject: (err: Error) => void, timer: NodeJS.Timeout } + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.notificationFutures.get(mac) === future) { + this.notificationFutures.delete(mac) + } + reject(new Error(`Notification timeout (${timeoutMs}ms) for ${mac}`)) + }, timeoutMs) + future = { resolve, reject, timer } + }) + // Avoid an unhandled rejection if the caller stops waiting (e.g. the write failed) + promise.catch(() => {}) + this.notificationFutures.set(mac, future) + // TEST HOOK: Notify when the notification future is set + if (typeof (this as any)._onNotificationFutureSet === 'function') { + (this as any)._onNotificationFutureSet(mac) + } + + const cancel = () => { + clearTimeout(future.timer) + if (this.notificationFutures.get(mac) === future) { + this.notificationFutures.delete(mac) } + } + return { promise, cancel } + } - return response + /** + * Attach one 'data' listener per notify characteristic: a notification answers the pending + * command if there is one, otherwise it goes to subscribeNotifications() handlers. + */ + private ensureNotifyListener(mac: string, notifyChar: any): void { + if (this.notifyListeners.has(notifyChar)) { + return + } + this.notifyListeners.add(notifyChar) + notifyChar.on('data', (payload: Buffer) => { + const future = this.notificationFutures.get(mac) + if (future) { + clearTimeout(future.timer) + this.notificationFutures.delete(mac) + future.resolve(payload) + return + } + this.logger.debug(`Unsolicited notification from ${mac}: ${payload.toString('hex')}`) + for (const listener of this.notificationHandlers.get(mac) ?? []) { + listener(payload) + } }) } @@ -787,34 +993,9 @@ export class BLEConnection { if (!this.notificationHandlers.has(normalizedMac)) { this.notificationHandlers.set(normalizedMac, new Set()) - - if (typeof chars.notify.on === 'function') { - chars.notify.on('data', (payload: Buffer) => { - // DEBUG: Log notification future state - this.logger.debug(`[DEBUG] notifyChar handler called for ${normalizedMac}`) - this.logger.debug(`[DEBUG] notificationFutures keys at handler: ${[...this.notificationFutures.keys()].join(',')}`) - const future = this.notificationFutures.get(normalizedMac) - if (future) { - this.logger.debug(`[DEBUG] notificationFutures present, resolving as solicited`) - clearTimeout(future.timer) - this.notificationFutures.delete(normalizedMac) - this.logger.debug(`[DEBUG] notificationFutures after delete: ${[...this.notificationFutures.keys()].join(',')}`) - future.resolve(payload) - return - } - this.logger.debug(`[DEBUG] unsolicited notification branch`) - this.logger.debug(`[DEBUG] about to call logger.info for unsolicited notification`) - this.logger.info(`Unsolicited notification from ${normalizedMac}: ${payload.toString('hex')}`) - this.logger.debug(`[DEBUG] after logger.info for unsolicited notification`) - const handlers = this.notificationHandlers.get(normalizedMac) - if (!handlers) { - return - } - for (const listener of handlers) { - listener(payload) - } - }) - } + } + if (typeof chars.notify.on === 'function') { + this.ensureNotifyListener(normalizedMac, chars.notify) } // Register first so notifications arriving as soon as the CCCD is enabled are delivered. @@ -992,6 +1173,7 @@ export class BLEConnection { this.clearDisconnectTimer(normalizedMac) // Reset encryption state (IV, cipher, mode) for this device this.clearEncryption(normalizedMac) + this.encryptedSessions.delete(normalizedMac) this.notificationHandlers.delete(normalizedMac) const pending = this.notificationFutures.get(normalizedMac) if (pending) { diff --git a/src/devices/air-purifier-api.ts b/src/devices/air-purifier-api.ts index 2e9028f1..df343f4f 100644 --- a/src/devices/air-purifier-api.ts +++ b/src/devices/air-purifier-api.ts @@ -49,16 +49,16 @@ type SendOnce = (command: readonly number[], options: { retry?: boolean, validat * `reset_filter_usage` does: send the reset once (never retried), require status 0x01, then read * the usage back and succeed only if it is 0 hours. Throws CommandFailedError otherwise. * - * OpenAPI has no filter reset command. node-switchbot cannot decrypt replies yet, so on an - * encrypted connection the readback cannot be checked; the reset is refused before anything is - * sent rather than reported as done. + * OpenAPI has no filter reset command. On an encrypted device the transport decrypts the reply + * (setKey with key id and key). An encryption key without a key id uses the legacy scheme, whose + * replies cannot be read, so the reset is refused before anything is sent. */ export async function resetAirPurifierFilterUsage(device: { encrypted: boolean, hasBLE: boolean, send: SendOnce }): Promise { if (!device.hasBLE) { throw new CommandFailedError('Filter usage reset needs a BLE connection (OpenAPI has no reset command)', 'ble') } if (device.encrypted) { - throw new CommandFailedError('Filter usage reset cannot be verified on an encrypted connection: node-switchbot does not decrypt replies yet', 'ble') + throw new CommandFailedError('Filter usage reset cannot be verified with an encryption key but no key id: set both with setKey()', 'ble') } const reset = await device.send(DEVICE_COMMANDS.AIR_PURIFIER.RESET_FILTER_USAGE, { retry: false, validateResponse: false }) diff --git a/src/devices/base.ts b/src/devices/base.ts index 3043797a..0eaa20bf 100644 --- a/src/devices/base.ts +++ b/src/devices/base.ts @@ -77,6 +77,9 @@ export const PASSIVE_POLL_INTERVAL = 60 * 60 * 24 * 1000 * * This pattern ensures all device classes benefit from robust, testable, and consistent connection logic. */ +const ENCRYPTION_KEY_REGEX = /^[0-9a-f]{32}$/i +const KEY_ID_REGEX = /^[0-9a-f]{2}$/i + export abstract class SwitchBotDevice extends EventEmitter { protected logger: Logger protected bleConnection?: BLEConnection @@ -425,6 +428,16 @@ export abstract class SwitchBotDevice extends EventEmitter { const mac = this.bleAddress() let response: Buffer | undefined + if (this.info.encryptionKey && this.info.keyId && !writeOnly && this.bleConnection?.sendEncryptedCommand) { + // Encrypted device: pySwitchbot protocol, reply decrypted by the transport + response = await this.bleConnection.sendEncryptedCommand(mac, buffer, { + keyId: this.info.keyId, + encryptionKey: this.info.encryptionKey, + validateResponse, + responseTimeoutMs: 5000, + }) + return this.recordBLESuccess(startTime, response) + } if (this.info.encryptionKey && this.info.encryptionIV && this.bleConnection?.setEncryption) { this.bleConnection.setEncryption( mac, @@ -440,30 +453,13 @@ export abstract class SwitchBotDevice extends EventEmitter { response = await this.bleConnection.sendCommand(mac, buffer, { expectResponse: true, validateResponse, - responseTimeoutMs: 1200, + // Same reply timeout as pySwitchbot + responseTimeoutMs: 5000, }) } else { await this.bleConnection!.write(mac, buffer) } - const latencyMs = Date.now() - startTime - - // Record success - if (this.enableConnectionIntelligence) { - this.connectionTracker.recordSuccess('ble', latencyMs) - } - - if (this.enableCircuitBreaker) { - this.circuitBreakerBLE.recordSuccess() - } - - this.info.activeConnection = 'ble' - this.emit('command', { type: 'ble', success: true }) - - return { - success: true, - connectionType: 'ble' as const, - data: response, - } + return this.recordBLESuccess(startTime, response) } try { @@ -735,6 +731,36 @@ export abstract class SwitchBotDevice extends EventEmitter { } } + private recordBLESuccess(startTime: number, response: Buffer | undefined): CommandResult { + const latencyMs = Date.now() - startTime + if (this.enableConnectionIntelligence) { + this.connectionTracker.recordSuccess('ble', latencyMs) + } + if (this.enableCircuitBreaker) { + this.circuitBreakerBLE.recordSuccess() + } + this.info.activeConnection = 'ble' + this.emit('command', { type: 'ble', success: true }) + return { success: true, connectionType: 'ble', data: response } + } + + /** + * Set the BLE encryption key for an encrypted device (relay switches, locks, air purifiers...). + * Both values come from the SwitchBot account, as for pySwitchbot: `keyId` is 2 hex characters + * and `encryptionKey` 32. + */ + setKey(key: { encryptionKey: string, keyId: string }): void { + const encryptionKey = String(key?.encryptionKey ?? '').trim() + const keyId = String(key?.keyId ?? '').trim() + if (!ENCRYPTION_KEY_REGEX.test(encryptionKey)) { + throw new Error('encryptionKey must be 32 hex characters') + } + if (!KEY_ID_REGEX.test(keyId)) { + throw new Error('keyId must be 2 hex characters') + } + this.updateInfo({ encryptionKey, keyId }) + } + /** * The address BLEConnection uses for this device: its MAC, or `id:` when there is no * MAC (macOS reports a peripheral id instead). diff --git a/src/devices/wo-air-purifier-pm25.ts b/src/devices/wo-air-purifier-pm25.ts index ec46403f..011fbc4a 100644 --- a/src/devices/wo-air-purifier-pm25.ts +++ b/src/devices/wo-air-purifier-pm25.ts @@ -127,7 +127,8 @@ export class WoAirPurifierPM25 extends SwitchBotDevice { */ async resetFilterUsage(): Promise { return resetAirPurifierFilterUsage({ - encrypted: !!this.info.encryptionKey, + // Encrypted replies are only readable with a key id (pySwitchbot protocol) + encrypted: !!this.info.encryptionKey && !this.info.keyId, hasBLE: this.hasBLE(), send: (command, options) => this.sendBLECommand(command, false, options), }) diff --git a/src/devices/wo-air-purifier.ts b/src/devices/wo-air-purifier.ts index 85179885..a000ecba 100644 --- a/src/devices/wo-air-purifier.ts +++ b/src/devices/wo-air-purifier.ts @@ -24,6 +24,10 @@ export class WoAirPurifier extends SequenceDevice implements AirPurifierCommands */ private maybeEncryptCommand(cmd: Buffer | readonly number[]): Buffer { const arr = Buffer.isBuffer(cmd) ? cmd : Buffer.from([...cmd]) + // With a key id the BLE transport encrypts (pySwitchbot protocol); do not encrypt twice + if (this.info.keyId) { + return arr + } if (!this.needsEncryption()) { return arr } @@ -149,7 +153,8 @@ export class WoAirPurifier extends SequenceDevice implements AirPurifierCommands */ async resetFilterUsage(): Promise { return resetAirPurifierFilterUsage({ - encrypted: !!this.info.encryptionKey, + // Encrypted replies are only readable with a key id (pySwitchbot protocol) + encrypted: !!this.info.encryptionKey && !this.info.keyId, hasBLE: this.hasBLE(), send: (command, options) => this.sendBLECommand(command, false, options), }) diff --git a/src/devices/wo-bulb.ts b/src/devices/wo-bulb.ts index 1fc0d743..741d9ac8 100644 --- a/src/devices/wo-bulb.ts +++ b/src/devices/wo-bulb.ts @@ -23,6 +23,10 @@ export class WoBulb extends SwitchBotDevice implements BulbCommands { */ private maybeEncryptCommand(cmd: Buffer | readonly number[]): Buffer { const arr = Buffer.isBuffer(cmd) ? cmd : Buffer.from([...cmd]) + // With a key id the BLE transport encrypts (pySwitchbot protocol); do not encrypt twice + if (this.info.keyId) { + return arr + } if (!this.needsEncryption()) { return arr } diff --git a/src/devices/wo-relay-switch-1.ts b/src/devices/wo-relay-switch-1.ts index adabaec0..6d096a80 100644 --- a/src/devices/wo-relay-switch-1.ts +++ b/src/devices/wo-relay-switch-1.ts @@ -24,6 +24,10 @@ export class WoRelaySwitch1 extends SequenceDevice implements RelaySwitchCommand protected maybeEncryptCommand(cmd: Buffer | readonly number[]): Buffer { // Convert readonly arrays to mutable arrays for compatibility const arr = Buffer.isBuffer(cmd) ? cmd : Buffer.from([...cmd]) + // With a key id the BLE transport encrypts (pySwitchbot protocol); do not encrypt twice + if (this.info.keyId) { + return arr + } if (!this.needsEncryption()) { return arr } diff --git a/src/types/ble.ts b/src/types/ble.ts index 0794c598..ac9d8a22 100644 --- a/src/types/ble.ts +++ b/src/types/ble.ts @@ -239,51 +239,61 @@ export interface BLEScanOptions { /** * SwitchBot BLE Model identifiers */ +/** + * SwitchBot BLE model codes: byte 0 of the service data with bit 7 cleared, as in pySwitchbot's + * SUPPORTED_TYPES. Most products also have a second code; DEVICE_MODEL_MAP lists every code. + */ export enum SwitchBotBLEModel { Bot = 'H', Curtain = 'c', Curtain3 = '{', + BlindTilt = 'x', + RollerShade = ',', /** @deprecated Import-only alias kept for compatibility; use {@link SwitchBotBLEModel.PlugMiniUS} or {@link SwitchBotBLEModel.PlugMiniJP}. */ Plug = 'g', // eslint-disable-next-line ts/no-duplicate-enum-values PlugMiniUS = 'g', PlugMiniJP = 'j', + PlugMiniEU = '?', + RelaySwitch1 = ';', + RelaySwitch1PM = '<', + RelaySwitch2PM = '=', + GarageDoorOpener = '>', Meter = 'T', MeterPlus = 'i', - MeterPro = 'o', + MeterPro = '4', MeterProCO2 = '5', IndoorOutdoorThermoHygrometer = 'w', /** @deprecated Use {@link SwitchBotBLEModel.IndoorOutdoorThermoHygrometer}. */ // eslint-disable-next-line ts/no-duplicate-enum-values OutdoorMeter = 'w', - // eslint-disable-next-line ts/no-duplicate-enum-values Lock = 'o', - LockPro = '\x11', - Keypad = 'k', - KeypadTouch = '\x0B', + LockPro = '$', + LockLite = '-', + Keypad = 'y', MotionSensor = 's', ContactSensor = 'd', + WaterLeakDetector = '&', CeilingLight = 'q', CeilingLightPro = 'n', - StripLight = 'p', + StripLight = 'r', ColorBulb = 'u', - RobotVacuumCleanerS1 = '\x0A', - RobotVacuumCleanerS1Plus = '\x0C', - RobotVacuumCleanerK10Plus = '\x0F', Humidifier = 'e', - Humidifier2 = '\x07', - BlindTilt = 'x', - Hub2 = '\x01', + /** Evaporative Humidifier */ + Humidifier2 = '#', + AirPurifier = '*', + AirPurifierTable = '7', + BatteryCirculatorFan = '~', + Hub2 = 'v', + /** Hub 3 has no pySwitchbot single-byte code; this is the code earlier releases used */ Hub3 = '\x02', - Remote = '\x05', - BatteryCirculatorFan = '\x04', - AirPurifier = '\x08', - AirPurifierTable = '\x09', - WaterLeakDetector = 'y', - PresenceSensor = '\x06', - RelaySwitch1PM = '\x0D', - RelaySwitch1 = '\x0E', - K10ProComboK10Pro = '\x10', + HubMiniMatter = '%', + Remote = 'b', + RobotVacuumCleanerS10 = 'z', + RobotVacuumCleanerK10Plus = '}', + RobotVacuumCleanerK10PlusPro = '(', + RobotVacuumCleanerK20 = '.', + K10ProComboK10Pro = '3', } /** diff --git a/src/types/index.ts b/src/types/index.ts index 4720aa5b..bd48b44d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -121,6 +121,8 @@ export interface DeviceInfo { bleServiceData?: Record /** 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) */ + keyId?: string /** Optional BLE encryption IV as hex */ encryptionIV?: string /** Optional BLE encryption mode */ diff --git a/test/apis/ble-command-reply.test.ts b/test/apis/ble-command-reply.test.ts new file mode 100644 index 00000000..28b18a0a --- /dev/null +++ b/test/apis/ble-command-reply.test.ts @@ -0,0 +1,97 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * ble-command-reply.test.ts: a command's reply is the device's next notification, as in pySwitchbot + */ + +import { Buffer } from 'node:buffer' +import { EventEmitter } from 'node:events' + +import { describe, expect, it, vi } from 'vitest' + +import { BLEConnection } from '../../src/ble.js' + +const MAC = 'aa:bb:cc:dd:ee:ff' + +function setup(options: { notifyEvents?: boolean, replyOnWrite?: number[] } = {}) { + const notifyChar: any = options.notifyEvents === false ? {} : new EventEmitter() + notifyChar.uuid = 'cba20003224d11e69fb80002a5d5c51b' + notifyChar.subscribe = (cb: (e?: Error) => void) => cb() + notifyChar.read = vi.fn((cb: (e: Error | null, d: Buffer) => void) => cb(null, Buffer.from([0x01, 0x42]))) + const writeChar = { + uuid: 'cba20002224d11e69fb80002a5d5c51b', + write: vi.fn((_data: Buffer, _withoutResponse: boolean, cb: (err?: Error) => void) => { + cb() + // A device that answers immediately, before the write promise has even resolved + if (options.replyOnWrite) { + notifyChar.emit('data', Buffer.from(options.replyOnWrite)) + } + }), + } + const peripheral = { + id: 'id', + address: MAC, + connect: (cb: (err?: Error) => void) => cb(), + disconnect: (cb: () => void) => cb(), + discoverSomeServicesAndCharacteristics: (_s: string[], _c: string[], cb: (e: null, s: any[], c: any[]) => void) => cb(null, [], [writeChar, notifyChar]), + } + const connection = new BLEConnection({ noble: { peripherals: [peripheral] } }) + return { connection, notifyChar, writeChar } +} + +describe('bLE command replies', () => { + // The reply used to be taken by reading the notify characteristic after the write. SwitchBot + // devices answer with a notification instead (pySwitchbot waits for it), so the read could + // return a stale value. + it('uses the notification that answers the command, not a read', async () => { + const { connection, notifyChar } = setup() + const reply = connection.sendCommand(MAC, Buffer.from([0x57, 0x01])) + await vi.waitFor(() => expect((connection as any).notificationFutures.has(MAC)).toBe(true)) + notifyChar.emit('data', Buffer.from([0x01, 0x64])) + await expect(reply).resolves.toEqual(Buffer.from([0x01, 0x64])) + expect(notifyChar.read).not.toHaveBeenCalled() + }) + + it('does not miss a reply that arrives during the write', async () => { + const { connection } = setup({ replyOnWrite: [0x01] }) + await expect(connection.sendCommand(MAC, Buffer.from([0x57, 0x01]))).resolves.toEqual(Buffer.from([0x01])) + }) + + it('validates the reply status', async () => { + const { connection } = setup({ replyOnWrite: [0x09] }) + await expect(connection.sendCommand(MAC, Buffer.from([0x57, 0x01]))).rejects.toThrow('password incorrect') + }) + + it('times out when no reply arrives', async () => { + const { connection } = setup() + await expect(connection.sendCommand(MAC, Buffer.from([0x57, 0x01]), { responseTimeoutMs: 20 })).rejects.toThrow('Notification timeout') + expect((connection as any).notificationFutures.size).toBe(0) + }) + + it('fails a pending reply when the device disconnects', async () => { + const { connection } = setup() + const reply = connection.sendCommand(MAC, Buffer.from([0x57, 0x01])) + await vi.waitFor(() => expect((connection as any).notificationFutures.has(MAC)).toBe(true)) + await connection.disconnect(MAC) + await expect(reply).rejects.toThrow('Disconnected') + }) + + it('reads the characteristic when the backend emits no notification events', async () => { + const { connection, notifyChar } = setup({ notifyEvents: false }) + await expect(connection.sendCommand(MAC, Buffer.from([0x57, 0x01]))).resolves.toEqual(Buffer.from([0x01, 0x42])) + expect(notifyChar.read).toHaveBeenCalledOnce() + }) + + it('passes notifications with no pending command to subscribers', async () => { + const { connection, notifyChar } = setup() + const handler = vi.fn() + await connection.subscribeNotifications(MAC, handler) + notifyChar.emit('data', Buffer.from([0x77])) + expect(handler).toHaveBeenCalledWith(Buffer.from([0x77])) + // ...and one that answers a command does not reach them + const reply = connection.sendCommand(MAC, Buffer.from([0x57, 0x01])) + await vi.waitFor(() => expect((connection as any).notificationFutures.has(MAC)).toBe(true)) + notifyChar.emit('data', Buffer.from([0x01])) + await reply + expect(handler).toHaveBeenCalledOnce() + }) +}) diff --git a/test/apis/ble-encryption.test.ts b/test/apis/ble-encryption.test.ts new file mode 100644 index 00000000..e05bf6e3 --- /dev/null +++ b/test/apis/ble-encryption.test.ts @@ -0,0 +1,158 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * ble-encryption.test.ts: encrypted devices use pySwitchbot's SwitchbotEncryptedDevice protocol + */ + +import { Buffer } from 'node:buffer' +import { createCipheriv, createDecipheriv } from 'node:crypto' +import { EventEmitter } from 'node:events' + +import { describe, expect, it, vi } from 'vitest' + +import { BLEConnection, decryptPayload, encryptPayload, parseEncryptionInit } from '../../src/ble.js' +import { WoAirPurifier } from '../../src/devices/wo-air-purifier.js' +import { WoRelaySwitch1 } from '../../src/devices/wo-relay-switch-1.js' +import { DEVICE_COMMANDS } from '../../src/settings.js' + +const MAC = 'aa:bb:cc:dd:ee:ff' +const KEY_HEX = '000102030405060708090a0b0c0d0e0f' +const KEY = Buffer.from(KEY_HEX, 'hex') +const KEY_ID = 'a1' + +/** + * A device that speaks pySwitchbot's encrypted protocol: answers the init command with its mode and + * IV, decrypts each command, and replies with an encrypted payload. + */ +function encryptedDevice(mode: 'ctr' | 'gcm', reply: (plain: Buffer) => Buffer = () => Buffer.from([0x80])) { + let iv = mode === 'gcm' ? Buffer.alloc(12, 0x10) : Buffer.alloc(16, 0x20) + const received: Buffer[] = [] + const notifyChar: any = new EventEmitter() + notifyChar.uuid = 'cba20003224d11e69fb80002a5d5c51b' + notifyChar.subscribe = (cb: (e?: Error) => void) => cb() + const crypt = (data: Buffer, decrypt: boolean) => { + if (mode === 'gcm') { + const c: any = decrypt ? createDecipheriv('aes-128-gcm', KEY, iv) : createCipheriv('aes-128-gcm', KEY, iv) + if (decrypt) { + c.setAuthTag(Buffer.alloc(16)) + } + return c.update(data) + } + const c = decrypt ? createDecipheriv('aes-128-ctr', KEY, iv) : createCipheriv('aes-128-ctr', KEY, iv) + return Buffer.concat([c.update(data), c.final()]) + } + const writeChar = { + uuid: 'cba20002224d11e69fb80002a5d5c51b', + write: vi.fn((data: Buffer, _w: boolean, cb: (err?: Error) => void) => { + cb() + if (data.equals(Buffer.from([0x57, 0x00, 0x00, 0x00, 0x0F, 0x21, 0x03, 0xA1]))) { + const tail = mode === 'gcm' ? Buffer.alloc(4) : Buffer.alloc(0) + notifyChar.emit('data', Buffer.concat([Buffer.from([0x01, 0x00, mode === 'gcm' ? 1 : 0, 0x00]), iv, tail])) + return + } + expect(data[1]).toBe(0xA1) // key id + const plain = Buffer.concat([data.subarray(0, 1), crypt(data.subarray(4), true)]) + received.push(plain) + notifyChar.emit('data', Buffer.concat([Buffer.from([0x01, 0x00, 0x00, 0x00]), crypt(reply(plain), false)])) + if (mode === 'gcm') { + iv = Buffer.from((BigInt(`0x${iv.toString('hex')}`) + 1n).toString(16).padStart(24, '0'), 'hex') + } + }), + } + const peripheral = { + id: 'id', + address: MAC, + connect: (cb: (err?: Error) => void) => cb(), + disconnect: (cb: () => void) => cb(), + discoverSomeServicesAndCharacteristics: (_s: string[], _c: string[], cb: (e: null, s: any[], c: any[]) => void) => cb(null, [], [writeChar, notifyChar]), + } + return { noble: { peripherals: [peripheral] }, received, writeChar } +} + +describe('encrypted BLE commands', () => { + it.each(['ctr', 'gcm'] as const)('sends commands the device can decrypt and decrypts its reply (%s)', async (mode) => { + const device = encryptedDevice(mode) + const connection = new BLEConnection({ noble: device.noble }) + const options = { keyId: KEY_ID, encryptionKey: KEY_HEX } + + await expect(connection.sendEncryptedCommand(MAC, Buffer.from([0x57, 0x0F, 0x4C, 0x09]), options)).resolves.toEqual(Buffer.from([0x01, 0x80])) + // A second command reuses the session (and in GCM mode the incremented IV) + await expect(connection.sendEncryptedCommand(MAC, Buffer.from([0x57, 0x01, 0x01]), options)).resolves.toEqual(Buffer.from([0x01, 0x80])) + expect(device.received).toEqual([Buffer.from([0x57, 0x0F, 0x4C, 0x09]), Buffer.from([0x57, 0x01, 0x01])]) + expect(device.writeChar.write).toHaveBeenCalledTimes(3) // init + 2 commands + }) + + it('starts a new session after a disconnect', async () => { + const device = encryptedDevice('ctr') + const connection = new BLEConnection({ noble: device.noble }) + const options = { keyId: KEY_ID, encryptionKey: KEY_HEX } + await connection.sendEncryptedCommand(MAC, Buffer.from([0x57, 0x01]), options) + await connection.disconnect(MAC) + await connection.sendEncryptedCommand(MAC, Buffer.from([0x57, 0x01]), options) + expect(device.writeChar.write).toHaveBeenCalledTimes(4) // init + command, twice + }) + + it('rejects invalid keys', async () => { + const connection = new BLEConnection({ noble: encryptedDevice('ctr').noble }) + await expect(connection.sendEncryptedCommand(MAC, Buffer.from([0x57]), { keyId: 'a', encryptionKey: KEY_HEX })).rejects.toThrow('key id') + await expect(connection.sendEncryptedCommand(MAC, Buffer.from([0x57]), { keyId: KEY_ID, encryptionKey: 'abcd' })).rejects.toThrow('encryption key') + }) +}) + +// The same cases as pySwitchbot's test_ensure_encryption_initialized_* and test_resolve_encryption_mode_* +describe('parseEncryptionInit', () => { + it('reads CTR and GCM sessions', () => { + expect(parseEncryptionInit(Buffer.concat([Buffer.from([1, 0, 0, 0]), Buffer.alloc(16, 2)]), MAC)).toEqual({ mode: 'ctr', iv: Buffer.alloc(16, 2) }) + expect(parseEncryptionInit(Buffer.concat([Buffer.from([1, 0, 1, 0]), Buffer.alloc(12, 1), Buffer.alloc(4)]), MAC)).toEqual({ mode: 'gcm', iv: Buffer.alloc(12, 1) }) + }) + + it('rejects bad replies', () => { + expect(() => parseEncryptionInit(Buffer.from([0x02]), MAC)).toThrow('rejected') + expect(() => parseEncryptionInit(undefined, MAC)).toThrow('rejected') + expect(() => parseEncryptionInit(Buffer.from([1, 0, 2, 0]), MAC)).toThrow('Unsupported encryption mode 2') + expect(() => parseEncryptionInit(Buffer.from([1, 0]), MAC)).toThrow('Unsupported encryption mode') + expect(() => parseEncryptionInit(Buffer.concat([Buffer.from([1, 0, 0, 0]), Buffer.alloc(12)]), MAC)).toThrow('Invalid IV length 12 for CTR') + expect(() => parseEncryptionInit(Buffer.concat([Buffer.from([1, 0, 1, 0]), Buffer.alloc(16), Buffer.alloc(4)]), MAC)).toThrow('Invalid IV length 16 for GCM') + }) + + it('round-trips a payload in both modes, as pySwitchbot test_gcm_encrypt_decrypt_without_finalize', () => { + for (const session of [{ mode: 'ctr' as const, iv: Buffer.alloc(16, 7) }, { mode: 'gcm' as const, iv: Buffer.alloc(12, 0x10) }]) { + const { ciphertext, header } = encryptPayload(KEY, session, Buffer.from('0f4c09', 'hex')) + expect(header).toHaveLength(2) + expect(decryptPayload(KEY, session, ciphertext)).toEqual(Buffer.from('0f4c09', 'hex')) + } + }) +}) + +describe('devices with a key id', () => { + // homebridge-switchbot passes its encryptionKey/keyId config through setKey(), which node-switchbot + // devices did not have, so encrypted devices never got their key. + it('setKey validates and stores the key', () => { + const relay = new WoRelaySwitch1({ id: 'R', name: 'Relay', deviceType: 'Relay Switch 1', mac: MAC, connectionTypes: ['ble'] }) + expect(() => relay.setKey({ encryptionKey: 'nothex', keyId: KEY_ID })).toThrow('32 hex') + expect(() => relay.setKey({ encryptionKey: KEY_HEX, keyId: 'zz' })).toThrow('2 hex') + relay.setKey({ encryptionKey: KEY_HEX, keyId: KEY_ID }) + expect(relay.getInfo()).toMatchObject({ encryptionKey: KEY_HEX, keyId: KEY_ID }) + }) + + it('a relay switch sends its plain command through the encrypted transport', async () => { + const device = encryptedDevice('ctr') + const relay = new WoRelaySwitch1( + { id: 'R', name: 'Relay', deviceType: 'Relay Switch 1', mac: MAC, connectionTypes: ['ble'] }, + { bleConnection: new BLEConnection({ noble: device.noble }), enableRetry: false }, + ) + relay.setKey({ encryptionKey: KEY_HEX, keyId: KEY_ID }) + await expect(relay.turnOn()).resolves.toBe(true) + expect(device.received).toEqual([Buffer.from(DEVICE_COMMANDS.COMMON.POWER_ON)]) + }) + + it('an encrypted air purifier verifies a filter reset from the decrypted reply', async () => { + const device = encryptedDevice('gcm', plain => plain[2] === 0x4D ? Buffer.from([0x00, 0x00]) : Buffer.alloc(0)) + const purifier = new WoAirPurifier( + { id: 'AP', name: 'Purifier', deviceType: 'Air Purifier', mac: MAC, connectionTypes: ['ble'] }, + { bleConnection: new BLEConnection({ noble: device.noble }), enableRetry: false }, + ) + purifier.setKey({ encryptionKey: KEY_HEX, keyId: KEY_ID }) + await expect(purifier.resetFilterUsage()).resolves.toBe(true) + expect(device.received).toEqual([Buffer.from(DEVICE_COMMANDS.AIR_PURIFIER.RESET_FILTER_USAGE), Buffer.from(DEVICE_COMMANDS.AIR_PURIFIER.READ_FILTER_USAGE)]) + }) +}) diff --git a/test/devices/airpurifier-filter-reset.test.ts b/test/devices/airpurifier-filter-reset.test.ts index df5f23ae..46c62594 100644 --- a/test/devices/airpurifier-filter-reset.test.ts +++ b/test/devices/airpurifier-filter-reset.test.ts @@ -70,9 +70,9 @@ describe('air purifier resetFilterUsage', () => { expect(sendCommand).toHaveBeenCalledTimes(3) }) - it('refuses on an encrypted connection without sending anything', async () => { + it('refuses an encryption key without a key id, without sending anything', async () => { const { device, sendCommand } = purifier([], { encryptionKey: '00112233445566778899aabbccddeeff' }) - await expect(device.resetFilterUsage()).rejects.toThrow('encrypted connection') + await expect(device.resetFilterUsage()).rejects.toThrow('no key id') expect(sendCommand).not.toHaveBeenCalled() }) diff --git a/test/types/model-identities.test.ts b/test/types/model-identities.test.ts index 66ff5170..fde29c2e 100644 --- a/test/types/model-identities.test.ts +++ b/test/types/model-identities.test.ts @@ -150,3 +150,29 @@ describe('bLE model codes match pySwitchbot', () => { expect(parse(0x0D, [0x03, 0x64, 0x00])?.state).toBeUndefined() }) }) + +// SwitchBotBLEModel used to hold codes that now belong to other products (MeterPro 'o' was the +// Lock's code, WaterLeakDetector 'y' the Keypad's, Remote 0x05 HubMini Matter's...). +describe('switchBotBLEModel values', () => { + it('every value is a known model code that resolves to a device class', () => { + for (const [name, code] of Object.entries(SwitchBotBLEModel)) { + const displayName = DEVICE_MODEL_MAP[code] + expect(displayName, name).toBeDefined() + expect(resolveDeviceClassName(displayName), `${name} -> ${displayName}`).toBeDefined() + } + }) + + it.each([ + ['MeterPro', 'Meter Pro'], + ['WaterLeakDetector', 'Water Leak Detector'], + ['Keypad', 'Keypad'], + ['Remote', 'Remote'], + ['RelaySwitch1PM', 'Relay Switch 1PM'], + ['AirPurifier', 'Air Purifier'], + ['BatteryCirculatorFan', 'Circulator Fan'], + ['Humidifier2', 'Evaporative Humidifier'], + ['RobotVacuumCleanerK10Plus', 'Robot Vacuum Cleaner K10+'], + ])('%s names the right product', (name, displayName) => { + expect(DEVICE_MODEL_MAP[(SwitchBotBLEModel as any)[name]]).toBe(displayName) + }) +})