From 0e1e52ba64277599cbc241fbf4587e9161ce685f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:02:32 -0500 Subject: [PATCH 1/3] fix(ble): take a command's reply from the device's notification SwitchBot devices answer a command with a notification on the notify characteristic, which pySwitchbot waits for. node-switchbot read the characteristic after writing, which can return a stale value. - Register the wait before writing, so a fast reply is not missed. - Attach one notify listener per characteristic: a notification answers the pending command, otherwise it goes to subscribeNotifications() handlers. - Fail a pending reply on disconnect, and use pySwitchbot's 5 s reply timeout. - Backends without notification events still read the characteristic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYWa2RXoWsaUTWh76oNv8s --- src/ble.ts | 316 ++++++++++++++++++++++------ src/devices/base.ts | 66 ++++-- test/apis/ble-command-reply.test.ts | 97 +++++++++ 3 files changed, 395 insertions(+), 84 deletions(-) create mode 100644 test/apis/ble-command-reply.test.ts diff --git a/src/ble.ts b/src/ble.ts index dbba48af..14062698 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() @@ -719,56 +788,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) - if (validateResponse) { - this.validateCommandResult(response, normalizedMac) + const previous = this.notificationFutures.get(mac) + if (previous) { + clearTimeout(previous.timer) + previous.reject(new Error(`Superseded by a newer command for ${mac}`)) + } + + 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) + } }) } @@ -786,34 +992,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. @@ -994,7 +1175,14 @@ 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) { + clearTimeout(pending.timer) + this.notificationFutures.delete(normalizedMac) + pending.reject(new Error(`Disconnected from ${mac} while waiting for a reply`)) + } const wasExpected = this.expectedDisconnects.has(normalizedMac) if (wasExpected) { diff --git a/src/devices/base.ts b/src/devices/base.ts index 3395685b..66a68477 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 @@ -417,6 +420,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, @@ -432,30 +445,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 { @@ -727,6 +723,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/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() + }) +}) From 177474be9d8072d801f3dae471158446a9af0a44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:02:32 -0500 Subject: [PATCH 2/3] feat(ble)!: encrypt with pySwitchbot's protocol and decrypt replies Implement pySwitchbot's SwitchbotEncryptedDevice protocol in BLEConnection.sendEncryptedCommand: - Session setup per connection: 57 000000 0F2103 returns the AES mode (CTR or GCM) and the device's IV. - Commands are sent as 57 <2-byte header> , and replies are decrypted (status byte plus payload). GCM increments the IV per command. The session resets on disconnect. Devices use it when they have an encryption key and key id. Add setKey({ encryptionKey, keyId }), which homebridge-switchbot already calls but which did not exist, so encrypted devices never received their key. Bulbs, relay switches and air purifiers no longer pre-encrypt commands when a key id is set. Air purifier filter reset now works on encrypted devices, verified from the decrypted reply. BREAKING CHANGE: an encrypted device needs `keyId` (2 hex characters) alongside `encryptionKey`, both from the SwitchBot account. The previous scheme (encryptionKey plus a configured encryptionIV) is kept only when no keyId is set; it does not match the device protocol. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYWa2RXoWsaUTWh76oNv8s --- src/devices/air-purifier-api.ts | 8 +- src/devices/wo-air-purifier-pm25.ts | 3 +- src/devices/wo-air-purifier.ts | 7 +- src/devices/wo-bulb.ts | 4 + src/devices/wo-relay-switch-1.ts | 4 + src/types/index.ts | 2 + test/apis/ble-encryption.test.ts | 158 ++++++++++++++++++ test/devices/airpurifier-filter-reset.test.ts | 4 +- 8 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 test/apis/ble-encryption.test.ts 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/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/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-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() }) From 3def1fb86d36a8dfc99ea90e51ce209a56c9c434 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:02:32 -0500 Subject: [PATCH 3/3] feat!: align SwitchBotBLEModel with pySwitchbot's model codes Several values were codes that now belong to other products: MeterPro 'o' (Lock), WaterLeakDetector 'y' (Keypad), Remote 0x05 (HubMini Matter), RelaySwitch1PM 0x0D (Lock Lite), BatteryCirculatorFan 0x04 (Lock Pro), AirPurifier 0x08 (K10+ Pro), and others. Every value is now the product's primary pySwitchbot code. Add RollerShade, PlugMiniEU, RelaySwitch2PM, GarageDoorOpener, LockLite, HubMiniMatter and robot vacuum members. BREAKING CHANGE: SwitchBotBLEModel values changed to pySwitchbot's primary codes. KeypadTouch, RobotVacuumCleanerS1, RobotVacuumCleanerS1Plus and PresenceSensor are removed: they have no single-byte code and their values named other products. Compare against DEVICE_MODEL_MAP (all codes) rather than enum values where possible. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYWa2RXoWsaUTWh76oNv8s --- .github/copilot-instructions.md | 8 ++++- src/types/ble.ts | 52 +++++++++++++++++------------ test/types/model-identities.test.ts | 26 +++++++++++++++ 3 files changed, 64 insertions(+), 22 deletions(-) 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/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/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) + }) +})