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
8 changes: 7 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
316 changes: 252 additions & 64 deletions src/ble.ts

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions src/devices/air-purifier-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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 })
Expand Down
66 changes: 46 additions & 20 deletions src/devices/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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:<bleId>` when there is no
* MAC (macOS reports a peripheral id instead).
Expand Down
3 changes: 2 additions & 1 deletion src/devices/wo-air-purifier-pm25.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ export class WoAirPurifierPM25 extends SwitchBotDevice {
*/
async resetFilterUsage(): Promise<boolean> {
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),
})
Expand Down
7 changes: 6 additions & 1 deletion src/devices/wo-air-purifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -149,7 +153,8 @@ export class WoAirPurifier extends SequenceDevice implements AirPurifierCommands
*/
async resetFilterUsage(): Promise<boolean> {
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),
})
Expand Down
4 changes: 4 additions & 0 deletions src/devices/wo-bulb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions src/devices/wo-relay-switch-1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
52 changes: 31 additions & 21 deletions src/types/ble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export interface DeviceInfo {
bleServiceData?: Record<string, unknown>
/** 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 */
Expand Down
97 changes: 97 additions & 0 deletions test/apis/ble-command-reply.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading