From 0595fb67f231c45f1cf4d3141952b2ebd0e51b87 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 06:55:58 -0600 Subject: [PATCH] feat: port pySwitchbot 3.2.0-3.8.1 New in pySwitchbot 3.2.0-3.8.1: - UWB Lock, Lock Nano and Lock Ultra Max: lock models with pySwitchbot's commands, advertisement parsing and product IDs. - Lock Ultra Quick Key: getQuickKey / setQuickKey (WoSmartLockUltra), and half lock. - Bot inverse direction: setInverseDirection keeps the mode and strength; getModeSettings reads them. - Hub 4, Plug Mini (IT) and Outdoor Plug (EU) OpenAPI device types. Fixes found while porting: - Product IDs: newer products have no model byte and are identified by the 4-byte product ID at the end of the service data. node-switchbot only knew model bytes, so no such product was recognised over BLE. The lookup is ported; the lock family is listed. - Lock commands never matched the lock protocol (the Lock Pro "lock" sent the lock info command, 57 0f 4f). Locks now use pySwitchbot's per-model tables, picked from the device type. - Lock state was read from service data byte 1 and by a raw GATT read of an encrypted lock. It now comes from the manufacturer data in advertisements, with pySwitchbot's layout for each lock family. Not applicable: the Circulator Fan Pro firmware query and the RGBICWW ceiling light power state (no such classes here), the Lock Ultra basic-info battery fix (node-switchbot does not read lock basic info), and the encryption-key verification disconnect (no such flow here). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYWa2RXoWsaUTWh76oNv8s --- .github/copilot-instructions.md | 10 + src/ble.ts | 42 ++-- src/devices/index.ts | 4 + src/devices/lock-model.ts | 22 ++ src/devices/wo-hand.ts | 33 +++ src/devices/wo-hub4.ts | 11 + src/devices/wo-lock-lite.ts | 6 +- src/devices/wo-lock-nano.ts | 15 ++ src/devices/wo-lock-pro-wifi.ts | 6 +- src/devices/wo-lock-pro.ts | 31 ++- src/devices/wo-lock-ultra.ts | 178 ++++++++++++++ src/devices/wo-lock-vision-pro.ts | 6 +- src/devices/wo-lock-vision.ts | 6 +- src/devices/wo-lock.ts | 24 +- src/index.ts | 7 + src/settings.ts | 164 ++++++++++++- src/switchbot.ts | 6 + src/utils/index.ts | 1 + src/utils/lock-advertisement.ts | 198 ++++++++++++++++ test/devices/pyswitchbot-3.8.test.ts | 270 ++++++++++++++++++++++ test/types/device-type-resolution.test.ts | 4 +- test/utils/lock-advertisement.test.ts | 150 ++++++++++++ 22 files changed, 1157 insertions(+), 37 deletions(-) create mode 100644 src/devices/lock-model.ts create mode 100644 src/devices/wo-hub4.ts create mode 100644 src/devices/wo-lock-nano.ts create mode 100644 src/devices/wo-lock-ultra.ts create mode 100644 src/utils/lock-advertisement.ts create mode 100644 test/devices/pyswitchbot-3.8.test.ts create mode 100644 test/utils/lock-advertisement.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6505d8e3..bf889010 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -41,6 +41,16 @@ This library tracks pySwitchbot. To port a release: | `switchbot/adv_parsers/*.py` (passive status) | `src/ble.ts` `BLEScanner.parseServiceData` | | `switchbot/devices/*.py` (commands, active status) | `src/devices/wo-*.ts` | +Newer products have no model byte: pySwitchbot identifies them by the 4-byte product ID at the end +of the service data (its multi-byte `SUPPORTED_TYPES` keys). node-switchbot does the same with +`PRODUCT_ID_MODEL_MAP` and `findProductId` in `src/settings.ts`. List a product there only once +its BLE status and commands are implemented: recognising it over BLE makes its status BLE-first, +so an unfinished one would replace good cloud data. + +Locks use pySwitchbot's per-model command tables (`LOCK_COMMANDS`, chosen by the lock model from +the device type) and read their state from the manufacturer data in advertisements +(`src/utils/lock-advertisement.ts`). + `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 diff --git a/src/ble.ts b/src/ble.ts index 245e38f8..53fc5701 100644 --- a/src/ble.ts +++ b/src/ble.ts @@ -10,8 +10,8 @@ import { createCipheriv, createDecipheriv } from 'node:crypto' import { EventEmitter } from 'node:events' import { BLENotAvailableError, CommandFailedError, DeviceNotFoundError } from './errors.js' -import { BLE_COMMAND_TIMEOUT, BLE_CONNECT_TIMEOUT, BLE_NOTIFY_CHARACTERISTIC_UUID, BLE_SCAN_TIMEOUT, BLE_SERVICE_UUID, BLE_WRITE_CHARACTERISTIC_UUID, DEVICE_MODEL_MAP } from './settings.js' -import { extractMacFromManufacturerData, isPassiveSensorModel, isValidMAC, Logger, macToDeviceId, mergeAdvertisement, normalizeMAC, parseSensorAdvertisement, switchBotManufacturerPayload, withTimeout } from './utils/index.js' +import { BLE_COMMAND_TIMEOUT, BLE_CONNECT_TIMEOUT, BLE_NOTIFY_CHARACTERISTIC_UUID, BLE_SCAN_TIMEOUT, BLE_SERVICE_UUID, BLE_WRITE_CHARACTERISTIC_UUID, DEVICE_MODEL_MAP, findProductId, PRODUCT_ID_MODEL_MAP } from './settings.js' +import { extractMacFromManufacturerData, isPassiveSensorModel, isValidMAC, LOCK_FAMILY_BY_DEVICE_TYPE, Logger, macToDeviceId, mergeAdvertisement, normalizeMAC, parseLockAdvertisement, parseSensorAdvertisement, switchBotManufacturerPayload, withTimeout } from './utils/index.js' // Move RegExp to module scope to avoid re-compilation const CHARACTERISTIC_REGEX = /characteristic/i const UUID_DASH_REGEX = /-/g @@ -282,11 +282,20 @@ export class BLEScanner extends EventEmitter { } try { - const model = String.fromCharCode(data[0] & 0x7F) - const modelName = DEVICE_MODEL_MAP[model] + // The model byte first; then the 4-byte product ID at the end of the service data, which + // newer products use instead (pySwitchbot _find_model_from_service_data_suffix) + let model = String.fromCharCode(data[0] & 0x7F) + let modelName = DEVICE_MODEL_MAP[model] + if (!modelName) { + const productId = findProductId(data) + if (productId) { + model = productId + modelName = PRODUCT_ID_MODEL_MAP[productId] + } + } if (!modelName) { - this.logger.debug(`Unknown device model: ${model} (0x${data[0].toString(16)})`) + this.logger.debug(`Unknown device model: 0x${data[0].toString(16)}`) return null } @@ -320,19 +329,16 @@ export class BLEScanner extends EventEmitter { } } - if ((model === 'o' || model === 'O' || model === '\x11') && data.length > 1) { - // Lock / Lock Pro - const lockStatus = data[1] & 0x0F - serviceData.status = lockStatus - serviceData.doorOpen = (data[1] & 0x10) !== 0 - serviceData.calibration = (data[1] & 0x80) !== 0 - serviceData.lockState = lockStatus === 1 - ? 'unlocked' - : lockStatus === 2 - ? 'jammed' - : 'locked' - if (data.length > 3) { - serviceData.sequenceNumber = data[3] + // Locks: state from the manufacturer data, in the layout of the lock family (pySwitchbot) + const lockFamily = LOCK_FAMILY_BY_DEVICE_TYPE[modelName] + if (lockFamily) { + const lock = parseLockAdvertisement(lockFamily, data, manufacturerPayload) + if (lock) { + for (const [key, value] of Object.entries(lock)) { + if (value !== undefined) { + serviceData[key] = value + } + } } } diff --git a/src/devices/index.ts b/src/devices/index.ts index 07de17c4..f37ba7e8 100644 --- a/src/devices/index.ts +++ b/src/devices/index.ts @@ -24,6 +24,7 @@ export { WoGarageDoorOpener } from './wo-garage-door-opener.js' export { WoHand } from './wo-hand.js' export { WoHub2 } from './wo-hub2.js' export { WoHub3 } from './wo-hub3.js' +export { WoHub4 } from './wo-hub4.js' export { WoHubMiniMatter } from './wo-hubmini-matter.js' export { WoHumi2 } from './wo-humi2.js' export { WoHumi } from './wo-humi.js' @@ -33,8 +34,11 @@ export { WoKeypadVision } from './wo-keypad-vision.js' export { WoKeypad } from './wo-keypad.js' export { WoLeak } from './wo-leak.js' export { WoSmartLockLite } from './wo-lock-lite.js' +export { WoSmartLockNano } from './wo-lock-nano.js' export { WoSmartLockProWiFi } from './wo-lock-pro-wifi.js' export { WoSmartLockPro } from './wo-lock-pro.js' +export { buildQuickKeyCommand, parseQuickKeyConfig, QuickKeyFunction, WoSmartLockUltra } from './wo-lock-ultra.js' +export type { QuickKeySettings, QuickKeyUpdate } from './wo-lock-ultra.js' export { WoSmartLockVisionPro } from './wo-lock-vision-pro.js' export { WoSmartLockVision } from './wo-lock-vision.js' export { WoSmartLock } from './wo-lock.js' diff --git a/src/devices/lock-model.ts b/src/devices/lock-model.ts new file mode 100644 index 00000000..1282779e --- /dev/null +++ b/src/devices/lock-model.ts @@ -0,0 +1,22 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * devices/lock-model.ts: Which lock model a lock device is, for its BLE commands + */ + +import type { LockCommandSet, LockModel } from '../settings.js' + +import { LOCK_COMMANDS, LOCK_MODEL_BY_DEVICE_TYPE } from '../settings.js' + +/** + * The lock model for a device type (a node-switchbot or OpenAPI device type name), or `fallback` + * when the device type does not name a lock model. + */ +export function resolveLockModel(deviceType: string | undefined, fallback: LockModel): LockModel { + const key = (deviceType ?? '').trim().toLowerCase().replace(/\s+/g, ' ') + return LOCK_MODEL_BY_DEVICE_TYPE[key] ?? fallback +} + +/** The BLE commands for a lock model */ +export function lockCommandsFor(model: LockModel): LockCommandSet { + return LOCK_COMMANDS[model] +} diff --git a/src/devices/wo-hand.ts b/src/devices/wo-hand.ts index 1fcb11a9..8c1c6f91 100644 --- a/src/devices/wo-hand.ts +++ b/src/devices/wo-hand.ts @@ -3,6 +3,8 @@ * devices/wo-hand.ts: SwitchBot v4.0.0 - Bot (WoHand) Device */ +import type { Buffer } from 'node:buffer' + import type { OpenAPIClient } from '../api.js' import type { BLEConnection } from '../ble.js' import type { BotCommands, BotStatus } from '../types/device.js' @@ -147,6 +149,37 @@ export class WoHand extends DeviceOverrideStateDuringConnection implements BotCo ) } + /** + * Read the Bot's mode settings from its basic info (pySwitchbot `get_basic_info`). BLE only. + */ + async getModeSettings(): Promise<{ strength: number, switchMode: boolean, inverseDirection: boolean }> { + const result = await this.sendBLECommand([0x57, 0x02], false, { validateResponse: false }) + const reply: Buffer | undefined = result.data + if (!result.success || !reply || reply[0] !== 0x01 || reply.length < 10) { + throw new Error(`Unable to get the Bot's mode settings${result.error ? `: ${result.error}` : ''}`) + } + return { + strength: reply[3], + switchMode: (reply[9] & 0x10) !== 0, + inverseDirection: (reply[9] & 0x01) !== 0, + } + } + + /** + * Set the Bot's inverse direction, keeping its other mode settings (pySwitchbot + * `set_inverse_direction`). BLE only. + * + * Command: `57 03 `, where the mode byte's high nibble is the switch mode and + * its low nibble the inverse direction. + */ + async setInverseDirection(inverse: boolean): Promise { + const { strength, switchMode } = await this.getModeSettings() + const modeByte = (switchMode ? 0x10 : 0x00) | (inverse ? 0x01 : 0x00) + const result = await this.sendBLECommand([0x57, 0x03, strength, modeByte], false, { validateResponse: false }) + const reply: Buffer | undefined = result.data + return result.success && !!reply && reply[0] === 0x01 + } + /** * Set Bot long-press duration (1-255 deciseconds) */ diff --git a/src/devices/wo-hub4.ts b/src/devices/wo-hub4.ts new file mode 100644 index 00000000..a60e17fe --- /dev/null +++ b/src/devices/wo-hub4.ts @@ -0,0 +1,11 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * devices/wo-hub4.ts: SwitchBot Hub 4 + */ + +import { WoHub3 } from './wo-hub3.js' + +/** + * Hub 4. pySwitchbot parses it like the Hub 3, with a 20-step light level instead of lux. + */ +export class WoHub4 extends WoHub3 {} diff --git a/src/devices/wo-lock-lite.ts b/src/devices/wo-lock-lite.ts index 12c2cf05..1e66cb02 100644 --- a/src/devices/wo-lock-lite.ts +++ b/src/devices/wo-lock-lite.ts @@ -3,9 +3,13 @@ * devices/wo-lock-lite.ts: SwitchBot v4.0.0 - Smart Lock Lite Device */ +import type { LockModel } from '../settings.js' + import { WoSmartLock } from './wo-lock.js' /** * Smart Lock Lite Device */ -export class WoSmartLockLite extends WoSmartLock {} +export class WoSmartLockLite extends WoSmartLock { + protected override readonly defaultLockModel: LockModel = 'Lock Lite' +} diff --git a/src/devices/wo-lock-nano.ts b/src/devices/wo-lock-nano.ts new file mode 100644 index 00000000..3213c082 --- /dev/null +++ b/src/devices/wo-lock-nano.ts @@ -0,0 +1,15 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * devices/wo-lock-nano.ts: SwitchBot Lock Nano + */ + +import type { LockModel } from '../settings.js' + +import { WoSmartLockPro } from './wo-lock-pro.js' + +/** + * Lock Nano: Lock Pro commands with its own lock model byte (pySwitchbot) + */ +export class WoSmartLockNano extends WoSmartLockPro { + protected override readonly defaultLockModel: LockModel = 'Lock Nano' +} diff --git a/src/devices/wo-lock-pro-wifi.ts b/src/devices/wo-lock-pro-wifi.ts index 619c0ef7..4fac6677 100644 --- a/src/devices/wo-lock-pro-wifi.ts +++ b/src/devices/wo-lock-pro-wifi.ts @@ -3,9 +3,13 @@ * devices/wo-lock-pro-wifi.ts: SwitchBot v4.0.0 - Smart Lock Pro WiFi Device */ +import type { LockModel } from '../settings.js' + import { WoSmartLockPro } from './wo-lock-pro.js' /** * Smart Lock Pro WiFi Device (with WiFi connectivity and unlatch support) */ -export class WoSmartLockProWiFi extends WoSmartLockPro {} +export class WoSmartLockProWiFi extends WoSmartLockPro { + protected override readonly defaultLockModel: LockModel = 'Lock Pro WiFi' +} diff --git a/src/devices/wo-lock-pro.ts b/src/devices/wo-lock-pro.ts index 07b96c64..35804c8f 100644 --- a/src/devices/wo-lock-pro.ts +++ b/src/devices/wo-lock-pro.ts @@ -5,9 +5,10 @@ import type { Buffer } from 'node:buffer' +import type { LockCommandSet, LockModel } from '../settings.js' import type { LockCommands, LockStatus } from '../types/device.js' -import { WoSmartLockProCommands } from '../settings.js' +import { lockCommandsFor, resolveLockModel } from './lock-model.js' import { SequenceDevice } from './sequence-device.js' /** @@ -16,6 +17,23 @@ import { SequenceDevice } from './sequence-device.js' export class WoSmartLockPro extends SequenceDevice implements LockCommands { private lockNotificationHandlers = new Set<(payload: Buffer) => void>() + /** The lock model used when the device type does not name one */ + protected readonly defaultLockModel: LockModel = 'Lock Pro' + + /** This lock's model, which selects its BLE commands (pySwitchbot's per-model tables) */ + get lockModel(): LockModel { + return resolveLockModel(this.info.deviceType, this.defaultLockModel) + } + + protected get lockCommands(): LockCommandSet { + return lockCommandsFor(this.lockModel) + } + + /** Lock state comes from the manufacturer data in advertisements, as in pySwitchbot */ + protected override get readsFromAdvertisement(): boolean { + return true + } + /** * Lock the lock */ @@ -30,7 +48,7 @@ export class WoSmartLockPro extends SequenceDevice implements LockCommands { } const result = await this.sendCommand( - WoSmartLockProCommands.LOCK, + this.lockCommands.lock, 'lock', ) return result.success @@ -50,7 +68,7 @@ export class WoSmartLockPro extends SequenceDevice implements LockCommands { } const result = await this.sendCommand( - WoSmartLockProCommands.UNLOCK, + this.lockCommands.unlock, 'unlock', ) return result.success @@ -61,7 +79,7 @@ export class WoSmartLockPro extends SequenceDevice implements LockCommands { */ async unlockWithoutUnlatch(): Promise { const result = await this.sendCommand( - WoSmartLockProCommands.UNLOCK, + this.lockCommands.unlockWithoutUnlatch, 'unlock', 'withoutUnlatch', ) @@ -69,11 +87,12 @@ export class WoSmartLockPro extends SequenceDevice implements LockCommands { } /** - * Unlatch the lock (Lock Pro only) + * Unlatch the lock. An unlock also unlatches when the lock's unlatch feature is on, so this + * sends the unlock command (pySwitchbot has no separate unlatch command). */ async unlatch(): Promise { const result = await this.sendCommand( - WoSmartLockProCommands.UNLATCH, + this.lockCommands.unlock, 'unlock', ) return result.success diff --git a/src/devices/wo-lock-ultra.ts b/src/devices/wo-lock-ultra.ts new file mode 100644 index 00000000..afbfe84b --- /dev/null +++ b/src/devices/wo-lock-ultra.ts @@ -0,0 +1,178 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * devices/wo-lock-ultra.ts: SwitchBot Lock Ultra, Lock Ultra Max and UWB Lock + */ + +import type { LockModel } from '../settings.js' + +import { Buffer } from 'node:buffer' + +import { LOCK_QUICK_KEY_GET, LOCK_QUICK_KEY_SET_PREFIX } from '../settings.js' +import { WoSmartLockPro } from './wo-lock-pro.js' + +/** + * Action of the Lock Ultra Quick Key: the 2-bit function field of the Quick Key config byte + * (pySwitchbot QuickKeyFunction) + */ +export enum QuickKeyFunction { + LockOnly = 0b00, + UnlockOnly = 0b01, + LockAndUnlock = 0b10, +} + +/** Lock Ultra Quick Key and basic property settings */ +export interface QuickKeySettings { + /** The config byte as received */ + raw: number + light: boolean + sound: boolean + manualUnlockLinkage: boolean + /** Quick Key enabled */ + enabled: boolean + /** Quick Key triggers on a double press instead of a single press */ + doublePress: boolean + /** undefined for an unknown function value */ + function?: QuickKeyFunction + /** 0 high, 1 normal, 2 quiet; undefined when the reply does not include it */ + motorSpeed?: number +} + +/** Quick Key settings to change; settings left undefined are not changed */ +export interface QuickKeyUpdate { + light?: boolean + sound?: boolean + manualUnlockLinkage?: boolean + enabled?: boolean + doublePress?: boolean + function?: QuickKeyFunction + /** 0 high, 1 normal, 2 quiet, or 0xFF (default) to keep the current speed */ + motorSpeed?: 0 | 1 | 2 | 0xFF +} + +/** Command status bytes that mean success: 0x01, or 0x06 for success with a low battery */ +const QUICK_KEY_OK = new Set([0x01, 0x06]) + +const BOOLEAN_BITS: Array<[keyof QuickKeyUpdate & keyof QuickKeySettings, number]> = [ + ['light', 7], + ['sound', 6], + ['manualUnlockLinkage', 4], + ['enabled', 3], + ['doublePress', 2], +] + +/** Decode the Quick Key config byte (pySwitchbot `_parse_quick_key`) */ +export function parseQuickKeyConfig(config: number): QuickKeySettings { + const functionBits = config & 0b11 + return { + raw: config, + light: (config & (1 << 7)) !== 0, + sound: (config & (1 << 6)) !== 0, + manualUnlockLinkage: (config & (1 << 4)) !== 0, + enabled: (config & (1 << 3)) !== 0, + doublePress: (config & (1 << 2)) !== 0, + function: functionBits in QuickKeyFunction ? functionBits as QuickKeyFunction : undefined, + } +} + +/** + * Build the Quick Key write command (pySwitchbot `set_quick_key`): + * `57 0f 4e 04 01 00 `. Each set bit of the update byte selects that + * property for writing. + */ +export function buildQuickKeyCommand(update: QuickKeyUpdate): Buffer { + const motorSpeed = update.motorSpeed ?? 0xFF + if (![0, 1, 2, 0xFF].includes(motorSpeed)) { + throw new RangeError(`motorSpeed must be 0 (high), 1 (normal), 2 (quiet) or 0xFF (keep), got ${motorSpeed}`) + } + let updateFields = 0 + let value = 0 + for (const [setting, bit] of BOOLEAN_BITS) { + const requested = update[setting] + if (requested !== undefined) { + updateFields |= 1 << bit + value |= Number(requested) << bit + } + } + if (update.function !== undefined) { + updateFields |= 1 << 1 + value |= update.function + } + if (updateFields === 0 && motorSpeed === 0xFF) { + throw new RangeError('setQuickKey needs at least one setting to change') + } + return Buffer.from([...LOCK_QUICK_KEY_SET_PREFIX, updateFields, value, motorSpeed]) +} + +/** + * Lock Ultra, Lock Ultra Max and UWB Lock. They share Lock Pro's commands with their own lock + * model byte; the Lock Ultra adds half lock and the Quick Key. + */ +export class WoSmartLockUltra extends WoSmartLockPro { + protected override readonly defaultLockModel: LockModel = 'Lock Ultra' + + /** + * Half lock (Lock Ultra only) + */ + async halfLock(): Promise { + const command = this.lockCommands.halfLock + if (!command) { + throw new Error(`Half lock is not supported on the ${this.lockModel}`) + } + const result = await this.sendCommand(command, 'halfLock') + return result.success + } + + private assertQuickKeySupported(): void { + if (this.lockModel !== 'Lock Ultra') { + throw new Error(`Quick Key is not supported on the ${this.lockModel}`) + } + } + + /** + * Read the Quick Key and basic property settings (Lock Ultra only, BLE only). + * Returns undefined when the lock rejects the request or the reply has no config byte. + */ + async getQuickKey(): Promise { + this.assertQuickKeySupported() + const result = await this.sendBLECommand(LOCK_QUICK_KEY_GET, false, { validateResponse: false }) + if (!result.success) { + throw new Error(`Failed to read Quick Key settings: ${result.error}`) + } + const reply: Buffer | undefined = result.data + if (!reply || !QUICK_KEY_OK.has(reply[0]) || reply.length < 2) { + this.logger.error('Invalid Quick Key response', reply?.toString('hex')) + return undefined + } + return { ...parseQuickKeyConfig(reply[1]), motorSpeed: reply.length > 3 ? reply[3] : undefined } + } + + /** + * Change any combination of Quick Key and basic property settings (Lock Ultra only, BLE only). + * Returns true when the lock accepts the command and its reply confirms every requested + * setting. The motor speed is confirmed by the status byte only. + */ + async setQuickKey(update: QuickKeyUpdate): Promise { + this.assertQuickKeySupported() + const command = buildQuickKeyCommand(update) + const result = await this.sendBLECommand(command, false, { validateResponse: false }) + if (!result.success) { + throw new Error(`Failed to write Quick Key settings: ${result.error}`) + } + const reply: Buffer | undefined = result.data + if (!reply || !QUICK_KEY_OK.has(reply[0])) { + this.logger.error('Quick Key command failed', reply?.toString('hex')) + return false + } + if (reply.length < 2) { + this.logger.error('Quick Key reply is missing the config byte', reply.toString('hex')) + return false + } + const confirmed = parseQuickKeyConfig(reply[1]) + const mismatches = [...BOOLEAN_BITS.map(([setting]) => setting), 'function' as const] + .filter(setting => update[setting] !== undefined && confirmed[setting] !== update[setting]) + for (const setting of mismatches) { + this.logger.error(`Quick Key ${setting} was not applied`, { requested: update[setting], reported: confirmed[setting] }) + } + return mismatches.length === 0 + } +} diff --git a/src/devices/wo-lock-vision-pro.ts b/src/devices/wo-lock-vision-pro.ts index 8f9dbfc9..5711e401 100644 --- a/src/devices/wo-lock-vision-pro.ts +++ b/src/devices/wo-lock-vision-pro.ts @@ -3,9 +3,13 @@ * devices/wo-lock-vision-pro.ts: SwitchBot v4.0.0 - Smart Lock Vision Pro Device */ +import type { LockModel } from '../settings.js' + import { WoSmartLockPro } from './wo-lock-pro.js' /** * Smart Lock Vision Pro Device (with camera and unlatch support) */ -export class WoSmartLockVisionPro extends WoSmartLockPro {} +export class WoSmartLockVisionPro extends WoSmartLockPro { + protected override readonly defaultLockModel: LockModel = 'Lock Vision Pro' +} diff --git a/src/devices/wo-lock-vision.ts b/src/devices/wo-lock-vision.ts index d4032539..335cfba5 100644 --- a/src/devices/wo-lock-vision.ts +++ b/src/devices/wo-lock-vision.ts @@ -3,9 +3,13 @@ * devices/wo-lock-vision.ts: SwitchBot v4.0.0 - Smart Lock Vision Device */ +import type { LockModel } from '../settings.js' + import { WoSmartLock } from './wo-lock.js' /** * Smart Lock Vision Device (with camera) */ -export class WoSmartLockVision extends WoSmartLock {} +export class WoSmartLockVision extends WoSmartLock { + protected override readonly defaultLockModel: LockModel = 'Lock Vision' +} diff --git a/src/devices/wo-lock.ts b/src/devices/wo-lock.ts index 44395937..fc480333 100644 --- a/src/devices/wo-lock.ts +++ b/src/devices/wo-lock.ts @@ -5,9 +5,10 @@ import type { Buffer } from 'node:buffer' +import type { LockCommandSet, LockModel } from '../settings.js' import type { LockCommands, LockStatus } from '../types/device.js' -import { WoSmartLockCommands } from '../settings.js' +import { lockCommandsFor, resolveLockModel } from './lock-model.js' import { SequenceDevice } from './sequence-device.js' /** @@ -16,6 +17,23 @@ import { SequenceDevice } from './sequence-device.js' export class WoSmartLock extends SequenceDevice implements LockCommands { private lockNotificationHandlers = new Set<(payload: Buffer) => void>() + /** The lock model used when the device type does not name one */ + protected readonly defaultLockModel: LockModel = 'Lock' + + /** This lock's model, which selects its BLE commands (pySwitchbot's per-model tables) */ + get lockModel(): LockModel { + return resolveLockModel(this.info.deviceType, this.defaultLockModel) + } + + protected get lockCommands(): LockCommandSet { + return lockCommandsFor(this.lockModel) + } + + /** Lock state comes from the manufacturer data in advertisements, as in pySwitchbot */ + protected override get readsFromAdvertisement(): boolean { + return true + } + /** * Lock the lock */ @@ -30,7 +48,7 @@ export class WoSmartLock extends SequenceDevice implements LockCommands { } const result = await this.sendCommand( - WoSmartLockCommands.LOCK, + this.lockCommands.lock, 'lock', ) return result.success @@ -50,7 +68,7 @@ export class WoSmartLock extends SequenceDevice implements LockCommands { } const result = await this.sendCommand( - WoSmartLockCommands.UNLOCK, + this.lockCommands.unlock, 'unlock', ) return result.success diff --git a/src/index.ts b/src/index.ts index 155cd93f..c8ccb76a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ export { BLEConnection, BLEScanner } from './ble.js' export { DeviceManager, SwitchBotDevice } from './devices/base.js' export { DeviceOverrideStateDuringConnection } from './devices/device-override-state-during-connection.js' export { + buildQuickKeyCommand, + parseQuickKeyConfig, + QuickKeyFunction, WoAIHub, WoAirPurifier, WoAirPurifierPM25, @@ -27,6 +30,7 @@ export { WoHand, WoHub2, WoHub3, + WoHub4, WoHubMiniMatter, WoHumi, WoHumi2, @@ -55,8 +59,10 @@ export { WoSensorTHProCO2, WoSmartLock, WoSmartLockLite, + WoSmartLockNano, WoSmartLockPro, WoSmartLockProWiFi, + WoSmartLockUltra, WoSmartLockVision, WoSmartLockVisionPro, WoSmartThermostatRadiator, @@ -88,6 +94,7 @@ export { updateBaseURL, urls } from './settings.js' export { SwitchBot } from './switchbot.js' // Type exports (alphabetically by module path, then by export name) +export type { QuickKeySettings, QuickKeyUpdate } from './devices/wo-lock-ultra.js' export type { APICommandRequest, APICommandResponse, diff --git a/src/settings.ts b/src/settings.ts index 901dbf9a..6cddf8ba 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,6 +7,8 @@ * settings.ts: SwitchBot v4.0.0 - Configuration and Constants */ +import type { Buffer } from 'node:buffer' + import process from 'node:process' export const PASSIVE_POLL_INTERVAL = 60 * 60 * 24 * 1000 @@ -163,7 +165,7 @@ export const DEVICE_COMMANDS = { } as const /** - * Lock Command Constants (WoSmartLock) + * @deprecated These bytes never matched the lock protocol. Locks use {@link LOCK_COMMANDS}. */ export const WoSmartLockCommands = { LOCK: [0x57, 0x0F, 0x4E, 0x01, 0x00] as const, @@ -171,7 +173,7 @@ export const WoSmartLockCommands = { } as const /** - * Lock Pro Command Constants (WoSmartLockPro) + * @deprecated These bytes never matched the lock protocol. Locks use {@link LOCK_COMMANDS}. */ export const WoSmartLockProCommands = { LOCK: [0x57, 0x0F, 0x4F, 0x01, 0x00] as const, @@ -179,6 +181,146 @@ export const WoSmartLockProCommands = { UNLATCH: [0x57, 0x0F, 0x4F, 0x01, 0x02] as const, } as const +/** Lock models, with pySwitchbot's names */ +export type LockModel + = | 'Lock' + | 'Lock Lite' + | 'Lock Pro' + | 'Lock Pro WiFi' + | 'Lock Ultra' + | 'Lock Ultra Max' + | 'UWB Lock' + | 'Lock Nano' + | 'Lock Vision' + | 'Lock Vision Pro' + +/** BLE commands for one lock model, before encryption */ +export interface LockCommandSet { + lock: readonly number[] + unlock: readonly number[] + unlockWithoutUnlatch: readonly number[] + /** Read the lock info */ + info: readonly number[] + enableNotifications: readonly number[] + /** Lock Ultra only */ + halfLock?: readonly number[] +} + +function hex(value: string): readonly number[] { + return Object.freeze(value.match(/../g)!.map(byte => Number.parseInt(byte, 16))) +} + +/** Lock, Lock Lite: lock and unlock commands end with a mode byte (pySwitchbot) */ +function lock1Commands(modelByte: string): LockCommandSet { + return { + lock: hex('570f4e01011000'), + unlock: hex('570f4e01011080'), + unlockWithoutUnlatch: hex('570f4e010110a0'), + info: hex(`570f4f81${modelByte}`), + enableNotifications: hex(`570e01001e000081${modelByte}`), + } +} + +/** Every other lock model */ +function lock2Commands(modelByte: string): LockCommandSet { + return { + lock: hex('570f4e0101000000'), + unlock: hex('570f4e0101000080'), + unlockWithoutUnlatch: hex('570f4e01010000a0'), + info: hex(`570f4f81${modelByte}`), + enableNotifications: hex(`570e01001e000081${modelByte}`), + } +} + +/** + * BLE lock commands for each lock model, as pySwitchbot's tables in switchbot/devices/lock.py. + * Locks are encrypted devices: set the key with `device.setKey({ keyId, encryptionKey })`. + */ +export const LOCK_COMMANDS: Record = { + 'Lock': lock1Commands('01'), + 'Lock Lite': lock1Commands('01'), + 'Lock Pro': lock2Commands('04'), + 'Lock Ultra': { ...lock2Commands('07'), halfLock: hex('570f4e0101000008') }, + 'Lock Ultra Max': lock2Commands('07'), + 'UWB Lock': lock2Commands('07'), + 'Lock Nano': lock2Commands('0c'), + 'Lock Vision Pro': lock2Commands('02'), + 'Lock Vision': lock2Commands('02'), + 'Lock Pro WiFi': lock2Commands('0a'), +} + +/** Lock Ultra Quick Key: read the settings */ +export const LOCK_QUICK_KEY_GET = hex('570f4f0401') +/** Lock Ultra Quick Key: write prefix, followed by the update, value and motor speed bytes */ +export const LOCK_QUICK_KEY_SET_PREFIX = hex('570f4e040100') + +/** + * Lock models for the device type names node-switchbot and the OpenAPI use. Device types that + * are not listed fall back to the lock class's own model. + */ +export const LOCK_MODEL_BY_DEVICE_TYPE: Record = { + 'lock': 'Lock', + 'smart lock': 'Lock', + 'lock lite': 'Lock Lite', + 'smart lock lite': 'Lock Lite', + 'lock pro': 'Lock Pro', + 'smart lock pro': 'Lock Pro', + 'lock pro wifi': 'Lock Pro WiFi', + 'smart lock pro wifi': 'Lock Pro WiFi', + 'lock ultra': 'Lock Ultra', + 'smart lock ultra': 'Lock Ultra', + 'lock ultra max': 'Lock Ultra Max', + 'smart lock ultra max': 'Lock Ultra Max', + 'uwb lock': 'UWB Lock', + 'smart lock uwb': 'UWB Lock', + 'lock nano': 'Lock Nano', + 'smart lock nano': 'Lock Nano', + 'lock vision': 'Lock Vision', + 'lock vision pro': 'Lock Vision Pro', +} + +/** + * Devices identified by the 4-byte product ID at the end of their service data, as pySwitchbot's + * multi-byte SUPPORTED_TYPES keys (found by `_find_model_from_service_data_suffix`). Keys are hex; + * each product has a `00` and a `01` form. + * + * Only products whose BLE status and commands node-switchbot implements are listed: recognising a + * product over BLE makes its status BLE-first. + */ +export const PRODUCT_ID_MODEL_MAP: Record = { + '0010a5b8': 'Lock Ultra', + '0110a5b8': 'Lock Ultra', + '00119fb8': 'Lock Ultra Max', + '01119fb8': 'Lock Ultra Max', + '00121100': 'UWB Lock', + '01121100': 'UWB Lock', + '00122488': 'Lock Nano', + '01122488': 'Lock Nano', + '00116908': 'Lock Vision', + '01116908': 'Lock Vision', + '00116909': 'Lock Vision Pro', + '01116909': 'Lock Vision Pro', + '0010ff90': 'Lock Pro WiFi', + '0110ff90': 'Lock Pro WiFi', +} + +/** + * The product ID from the end of a service data payload, as pySwitchbot checks it: the last 4 + * bytes, then the 4 bytes before the last. + */ +export function findProductId(data: Buffer): string | undefined { + if (data.length <= 5) { + return undefined + } + for (const candidate of [data.subarray(-4), data.subarray(-5, -1)]) { + const key = candidate.toString('hex') + if (Object.hasOwn(PRODUCT_ID_MODEL_MAP, key)) { + return key + } + } + return undefined +} + /** * Device Model to Type Mapping */ @@ -297,6 +439,11 @@ export const DEVICE_CLASS_MAP: Record = { 'Plug Mini (JP)': 'WoPlugMiniJP', 'WoPlugJP': 'WoPlugMiniJP', 'Plug Mini (EU)': 'WoPlugMiniEU', + // Same relay-type plug as the Plug Mini (EU) in pySwitchbot + 'Plug Mini (IT)': 'WoPlugMiniEU', + // Plug Mini (EU) relay with production metering in pySwitchbot (SwitchbotOutdoorPlug) + 'Outdoor Plug (EU)': 'WoPlugMiniEU', + 'Outdoor Plug': 'WoPlugMiniEU', 'Meter': 'WoSensorTH', 'Meter Plus': 'WoSensorTHPlus', 'WoMeterPlus': 'WoSensorTHPlus', @@ -315,8 +462,14 @@ export const DEVICE_CLASS_MAP: Record = { 'Smart Lock': 'WoSmartLock', 'Smart Lock Pro': 'WoSmartLockPro', 'Smart Lock Pro Wifi': 'WoSmartLockProWiFi', - 'Smart Lock Ultra': 'WoSmartLockPro', - 'Lock Ultra': 'WoSmartLockPro', // deviceType in the Lock Ultra status response + 'Smart Lock Ultra': 'WoSmartLockUltra', + 'Lock Ultra': 'WoSmartLockUltra', // deviceType in the Lock Ultra status response + 'Lock Ultra Max': 'WoSmartLockUltra', + 'Smart Lock Ultra Max': 'WoSmartLockUltra', + 'UWB Lock': 'WoSmartLockUltra', + 'Smart Lock UWB': 'WoSmartLockUltra', + 'Lock Nano': 'WoSmartLockNano', + 'Smart Lock Nano': 'WoSmartLockNano', 'Lock Vision': 'WoSmartLockVision', 'Lock Vision Pro': 'WoSmartLockVisionPro', 'Keypad': 'WoKeypad', @@ -365,6 +518,9 @@ export const DEVICE_CLASS_MAP: Record = { 'Hub 2': 'WoHub2', 'Hub 3': 'WoHub3', 'SwitchBot Hub 3': 'WoHub3', + 'Hub 4': 'WoHub4', + 'Hub4': 'WoHub4', + 'SwitchBot Hub 4': 'WoHub4', 'Hub Mini': 'WoHub2', 'WoLinkMini': 'WoHub2', 'HubMini Matter': 'WoHubMiniMatter', diff --git a/src/switchbot.ts b/src/switchbot.ts index af7adde3..a2473e3c 100644 --- a/src/switchbot.ts +++ b/src/switchbot.ts @@ -30,6 +30,7 @@ import { WoGarageDoorOpener } from './devices/wo-garage-door-opener.js' import { WoHand } from './devices/wo-hand.js' import { WoHub2 } from './devices/wo-hub2.js' import { WoHub3 } from './devices/wo-hub3.js' +import { WoHub4 } from './devices/wo-hub4.js' import { WoHubMiniMatter } from './devices/wo-hubmini-matter.js' import { WoHumi2 } from './devices/wo-humi2.js' import { WoHumi } from './devices/wo-humi.js' @@ -39,8 +40,10 @@ import { WoKeypadVision } from './devices/wo-keypad-vision.js' import { WoKeypad } from './devices/wo-keypad.js' import { WoLeak } from './devices/wo-leak.js' import { WoSmartLockLite } from './devices/wo-lock-lite.js' +import { WoSmartLockNano } from './devices/wo-lock-nano.js' import { WoSmartLockProWiFi } from './devices/wo-lock-pro-wifi.js' import { WoSmartLockPro } from './devices/wo-lock-pro.js' +import { WoSmartLockUltra } from './devices/wo-lock-ultra.js' import { WoSmartLockVisionPro } from './devices/wo-lock-vision-pro.js' import { WoSmartLockVision } from './devices/wo-lock-vision.js' import { WoSmartLock } from './devices/wo-lock.js' @@ -91,8 +94,10 @@ const DEVICE_CLASSES: Record = { WoRollerShade, WoSmartLock, WoSmartLockLite, + WoSmartLockNano, WoSmartLockPro, WoSmartLockProWiFi, + WoSmartLockUltra, WoSmartThermostatRadiator, WoSmartLockVision, WoSmartLockVisionPro, @@ -122,6 +127,7 @@ const DEVICE_CLASSES: Record = { WoAirPurifierTable, WoHub2, WoHub3, + WoHub4, WoHubMiniMatter, WoLeak, WoGarageDoorOpener, diff --git a/src/utils/index.ts b/src/utils/index.ts index aa15984a..0e77c562 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -392,6 +392,7 @@ export { FallbackHandlerManager, type FallbackHandlerOptions, } from './fallback-handler.js' +export { LOCK_FAMILY_BY_DEVICE_TYPE, type LockAdvertisement, type LockFamily, lockStateFromStatus, LockStatusCode, parseLockAdvertisement } from './lock-advertisement.js' // Export advanced utilities export { type RetryConfig, RetryExecutor, type RetryResult } from './retry.js' export { isPassiveSensorModel, parseSensorAdvertisement, type SensorAdvertisement, switchBotManufacturerPayload } from './sensor-advertisement.js' diff --git a/src/utils/lock-advertisement.ts b/src/utils/lock-advertisement.ts new file mode 100644 index 00000000..59caff29 --- /dev/null +++ b/src/utils/lock-advertisement.ts @@ -0,0 +1,198 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * utils/lock-advertisement.ts: Lock state from BLE advertisements + * + * Ported from pySwitchbot (switchbot/adv_parsers/lock.py). Locks report their state in the + * manufacturer data, with a layout that depends on the lock family. `mfr` is the SwitchBot + * manufacturer data payload after the 2-byte company ID (0x0969), as pySwitchbot's `mfr_data`. + */ + +import type { Buffer } from 'node:buffer' + +/** + * Lock status values, as pySwitchbot's LockStatus (switchbot/const/lock.py) + */ +export enum LockStatusCode { + Locked = 0, + Unlocked = 1, + Locking = 2, + Unlocking = 3, + /** Locking blocked (jammed) */ + LockingStop = 4, + /** Unlocking blocked (jammed) */ + UnlockingStop = 5, + /** Latch locked; EU lock type only */ + NotFullyLocked = 6, + /** Half locked; Lock Ultra EU lock type only */ + HalfLocked = 7, +} + +/** + * Lock families with different advertisement layouts, named after pySwitchbot's parsers: + * - `lock`: Lock (process_wolock) + * - `lite`: Lock Lite, Lock Vision (process_locklite) + * - `pro`: Lock Pro, Lock Pro WiFi (process_wolock_pro) + * - `lock2`: Lock Ultra, Lock Ultra Max, UWB Lock, Lock Vision Pro (process_lock2) + * - `nano`: Lock Nano (process_lock_nano) + */ +export type LockFamily = 'lock' | 'lite' | 'pro' | 'lock2' | 'nano' + +/** Lock state parsed from an advertisement */ +export interface LockAdvertisement { + sequenceNumber: number + /** LockStatusCode */ + status: number + lockState: 'locked' | 'unlocked' | 'jammed' + calibration: boolean + battery?: number + doorOpen?: boolean + autoLockPaused?: boolean + nightLatch?: boolean + doubleLockMode?: boolean + unlockedAlarm?: boolean + unclosedAlarm?: boolean + updateFromSecondaryLock?: boolean + isSecondaryLock?: boolean + manualUnlockLinkage?: boolean + lowTemperatureAlarm?: boolean + powerAlarm?: boolean + batteryStatus?: number + /** Lock Nano */ + chargingStatus?: number + batteryCharging?: boolean + chargingFault?: boolean +} + +/** + * The lock state reported to consumers. Moving states report where the lock is heading, and a + * blocked motor reports `jammed`; latch locked and half locked are locked. + */ +export function lockStateFromStatus(status: number): 'locked' | 'unlocked' | 'jammed' { + switch (status) { + case LockStatusCode.Unlocked: + case LockStatusCode.Unlocking: + return 'unlocked' + case LockStatusCode.LockingStop: + case LockStatusCode.UnlockingStop: + return 'jammed' + default: + return 'locked' + } +} + +function withState(status: number, fields: Omit): LockAdvertisement { + return { ...fields, status, lockState: lockStateFromStatus(status) } +} + +/** process_locklite */ +function parseLite(data: Buffer | undefined, mfr: Buffer): LockAdvertisement | undefined { + if (mfr.length < 9) { + return undefined + } + return withState((mfr[7] & 0b01110000) >> 4, { + sequenceNumber: mfr[6], + battery: data && data.length >= 3 ? data[2] & 0x7F : undefined, + calibration: (mfr[7] & 0b10000000) !== 0, + updateFromSecondaryLock: (mfr[7] & 0b00001000) !== 0, + doubleLockMode: (mfr[8] & 0b10000000) !== 0, + unlockedAlarm: (mfr[8] & 0b00010000) !== 0, + nightLatch: mfr.length > 9 ? (mfr[9] & 0b00000001) !== 0 : false, + }) +} + +/** process_wolock: Lock Lite fields plus door and alarm bits */ +function parseLock(data: Buffer | undefined, mfr: Buffer): LockAdvertisement | undefined { + const common = parseLite(data, mfr) + if (!common) { + return undefined + } + return { + ...common, + doorOpen: (mfr[7] & 0b00000100) !== 0, + unclosedAlarm: (mfr[8] & 0b00100000) !== 0, + autoLockPaused: (mfr[8] & 0b00000010) !== 0, + } +} + +/** parse_common_data: shared by Lock Pro and Lock 2 */ +function parseCommon(mfr: Buffer): LockAdvertisement | undefined { + if (mfr.length < 12) { + return undefined + } + return withState((mfr[7] & 0b01111000) >> 3, { + sequenceNumber: mfr[6], + calibration: (mfr[7] & 0b10000000) !== 0, + updateFromSecondaryLock: (mfr[8] & 0b11000000) !== 0, + doorOpen: (mfr[8] & 0b00010000) !== 0, + autoLockPaused: (mfr[8] & 0b00001000) !== 0, + battery: mfr[9] & 0x7F, + doubleLockMode: (mfr[10] & 0b10000000) !== 0, + isSecondaryLock: (mfr[10] & 0b01000000) !== 0, + manualUnlockLinkage: (mfr[10] & 0b00100000) !== 0, + unclosedAlarm: (mfr[11] & 0b10000000) !== 0, + unlockedAlarm: (mfr[11] & 0b01000000) !== 0, + nightLatch: false, + }) +} + +/** process_lock_nano */ +function parseNano(mfr: Buffer): LockAdvertisement | undefined { + if (mfr.length < 11) { + return undefined + } + const chargingStatus = ((mfr[8] & 1) << 1) | (mfr[9] >> 7) + return withState((mfr[7] & 0x78) >> 3, { + sequenceNumber: mfr[6], + calibration: (mfr[7] & 0x80) !== 0, + autoLockPaused: (mfr[8] & 0x08) !== 0, + nightLatch: (mfr[8] & 0x02) !== 0, + battery: mfr[9] & 0x7F, + chargingStatus, + batteryCharging: chargingStatus === 1, + unlockedAlarm: (mfr[10] & 0x40) !== 0, + lowTemperatureAlarm: (mfr[10] & 0x20) !== 0, + powerAlarm: (mfr[10] & 0x10) !== 0, + batteryStatus: (mfr[10] >> 1) & 0x03, + chargingFault: (mfr[10] & 0x01) !== 0, + }) +} + +/** + * Parse a lock's advertisement. Returns undefined when the manufacturer data is missing or too + * short for the family's layout. + */ +export function parseLockAdvertisement(family: LockFamily, data: Buffer | undefined, mfr: Buffer | undefined): LockAdvertisement | undefined { + if (!mfr) { + return undefined + } + switch (family) { + case 'lite': + return parseLite(data, mfr) + case 'lock': + return parseLock(data, mfr) + case 'pro': { + const common = parseCommon(mfr) + return common && { ...common, lowTemperatureAlarm: (mfr[11] & 0b00100000) !== 0 } + } + case 'lock2': { + const common = parseCommon(mfr) + return common && { ...common, powerAlarm: (mfr[11] & 0b00010000) !== 0, batteryStatus: mfr[11] & 0b00000111 } + } + case 'nano': + return parseNano(mfr) + } +} + +/** The advertisement layout for each lock device type (DEVICE_MODEL_MAP / product ID names) */ +export const LOCK_FAMILY_BY_DEVICE_TYPE: Record = { + 'Lock': 'lock', + 'Lock Lite': 'lite', + 'Lock Vision': 'lite', + 'Lock Pro': 'pro', + 'Lock Pro WiFi': 'pro', + 'Lock Ultra': 'lock2', + 'Lock Ultra Max': 'lock2', + 'UWB Lock': 'lock2', + 'Lock Vision Pro': 'lock2', + 'Lock Nano': 'nano', +} diff --git a/test/devices/pyswitchbot-3.8.test.ts b/test/devices/pyswitchbot-3.8.test.ts new file mode 100644 index 00000000..14a43bf0 --- /dev/null +++ b/test/devices/pyswitchbot-3.8.test.ts @@ -0,0 +1,270 @@ +import type { DeviceInfo } from '../../src/types/index.js' + +import { Buffer } from 'node:buffer' +import { EventEmitter } from 'node:events' + +import { describe, expect, it, vi } from 'vitest' + +import { BLEScanner } from '../../src/ble.js' +import { WoHand } from '../../src/devices/wo-hand.js' +import { WoSmartLockLite } from '../../src/devices/wo-lock-lite.js' +import { WoSmartLockNano } from '../../src/devices/wo-lock-nano.js' +import { WoSmartLockProWiFi } from '../../src/devices/wo-lock-pro-wifi.js' +import { WoSmartLockPro } from '../../src/devices/wo-lock-pro.js' +import { buildQuickKeyCommand, parseQuickKeyConfig, QuickKeyFunction, WoSmartLockUltra } from '../../src/devices/wo-lock-ultra.js' +import { WoSmartLockVisionPro } from '../../src/devices/wo-lock-vision-pro.js' +import { WoSmartLock } from '../../src/devices/wo-lock.js' +import { LOCK_COMMANDS } from '../../src/settings.js' +import { SwitchBot } from '../../src/switchbot.js' + +// Ports of pySwitchbot 3.2.0-3.8.1. Byte strings and expected values come from pySwitchbot's +// switchbot/devices/lock.py tables and its tests (test_lock.py, test_bot.py, test_adv_parser.py). +const hex = (value: string) => Buffer.from(value, 'hex') +const bytes = (value: readonly number[]) => Buffer.from(value).toString('hex') + +function info(deviceType: string): DeviceInfo { + return { id: 'lock-1', name: 'Lock', deviceType, connectionTypes: ['ble'], mac: 'AA:BB:CC:DD:EE:FF' } +} + +function withSentCommands(device: T) { + const sent: string[] = [] + ;(device as any).sendCommand = vi.fn(async (command: readonly number[]) => { + sent.push(bytes(command)) + return { success: true } + }) + ;(device as any).getStatus = vi.fn().mockRejectedValue(new Error('no status')) + return sent +} + +describe('lock commands match pySwitchbot', () => { + it('has pySwitchbot\'s command bytes for every model', () => { + const table = Object.fromEntries(Object.entries(LOCK_COMMANDS).map(([model, set]) => [model, { + lock: bytes(set.lock), + unlock: bytes(set.unlock), + unlockWithoutUnlatch: bytes(set.unlockWithoutUnlatch), + info: bytes(set.info), + enableNotifications: bytes(set.enableNotifications), + ...(set.halfLock ? { halfLock: bytes(set.halfLock) } : {}), + }])) + const lock2 = (byte: string) => ({ lock: '570f4e0101000000', unlock: '570f4e0101000080', unlockWithoutUnlatch: '570f4e01010000a0', info: `570f4f81${byte}`, enableNotifications: `570e01001e000081${byte}` }) + const lock1 = { lock: '570f4e01011000', unlock: '570f4e01011080', unlockWithoutUnlatch: '570f4e010110a0', info: '570f4f8101', enableNotifications: '570e01001e00008101' } + expect(table).toEqual({ + 'Lock': lock1, + 'Lock Lite': lock1, + 'Lock Pro': lock2('04'), + 'Lock Ultra': { ...lock2('07'), halfLock: '570f4e0101000008' }, + 'Lock Ultra Max': lock2('07'), + 'UWB Lock': lock2('07'), + 'Lock Nano': lock2('0c'), + 'Lock Vision Pro': lock2('02'), + 'Lock Vision': lock2('02'), + 'Lock Pro WiFi': lock2('0a'), + }) + }) + + it('sends each class\'s own model commands', async () => { + const cases: Array<[object, string, string]> = [ + [new WoSmartLock(info('Lock')), '570f4e01011000', '570f4e01011080'], + [new WoSmartLockLite(info('Lock Lite')), '570f4e01011000', '570f4e01011080'], + [new WoSmartLockPro(info('Lock Pro')), '570f4e0101000000', '570f4e0101000080'], + [new WoSmartLockProWiFi(info('Lock Pro WiFi')), '570f4e0101000000', '570f4e0101000080'], + [new WoSmartLockVisionPro(info('Lock Vision Pro')), '570f4e0101000000', '570f4e0101000080'], + ] + for (const [device, lockBytes, unlockBytes] of cases) { + const sent = withSentCommands(device) + await (device as WoSmartLock).lock() + await (device as WoSmartLock).unlock() + expect(sent).toEqual([lockBytes, unlockBytes]) + } + }) + + it('selects the lock model from the device type, including OpenAPI names', () => { + expect(new WoSmartLockUltra(info('Smart Lock Ultra')).lockModel).toBe('Lock Ultra') + expect(new WoSmartLockUltra(info('Lock Ultra Max')).lockModel).toBe('Lock Ultra Max') + expect(new WoSmartLockUltra(info('UWB Lock')).lockModel).toBe('UWB Lock') + expect(new WoSmartLockNano(info('Lock Nano')).lockModel).toBe('Lock Nano') + expect(new WoSmartLockPro(info('Smart Lock Pro')).lockModel).toBe('Lock Pro') + // An unknown device type falls back to the class's model + expect(new WoSmartLockNano(info('WoSmartLockNano')).lockModel).toBe('Lock Nano') + }) + + it('unlocks the Lock Pro without unlatching', async () => { + const lock = new WoSmartLockPro(info('Lock Pro')) + const sent = withSentCommands(lock) + await lock.unlockWithoutUnlatch() + expect(sent).toEqual(['570f4e01010000a0']) + }) + + it('half locks only the Lock Ultra', async () => { + const ultra = new WoSmartLockUltra(info('Lock Ultra')) + const sent = withSentCommands(ultra) + await expect(ultra.halfLock()).resolves.toBe(true) + expect(sent).toEqual(['570f4e0101000008']) + await expect(new WoSmartLockUltra(info('UWB Lock')).halfLock()).rejects.toThrow(/not supported/) + }) +}) + +describe('lock Ultra Quick Key (pySwitchbot test_lock.py)', () => { + function ultraReplying(reply: Buffer, deviceType = 'Lock Ultra') { + const lock = new WoSmartLockUltra(info(deviceType)) + const sendBLECommand = vi.fn().mockResolvedValue({ success: true, data: reply }) + ;(lock as any).sendBLECommand = sendBLECommand + return { lock, sendBLECommand } + } + + it('reads the settings', async () => { + const { lock, sendBLECommand } = ultraReplying(hex('01ca00000080')) + await expect(lock.getQuickKey()).resolves.toEqual({ + raw: 0xCA, + light: true, + sound: true, + manualUnlockLinkage: false, + enabled: true, + doublePress: false, + function: QuickKeyFunction.LockAndUnlock, + motorSpeed: 0, + }) + expect(bytes(sendBLECommand.mock.calls[0][0])).toBe('570f4f0401') + }) + + it('reads all-zero settings, and no motor speed from a short reply', async () => { + await expect(ultraReplying(hex('0100')).lock.getQuickKey()).resolves.toEqual({ + raw: 0, + light: false, + sound: false, + manualUnlockLinkage: false, + enabled: false, + doublePress: false, + function: QuickKeyFunction.LockOnly, + motorSpeed: undefined, + }) + }) + + it('returns undefined for a failed or short reply, and an unknown function as undefined', async () => { + await expect(ultraReplying(hex('00')).lock.getQuickKey()).resolves.toBeUndefined() + await expect(ultraReplying(hex('01')).lock.getQuickKey()).resolves.toBeUndefined() + expect(parseQuickKeyConfig(0xCB).function).toBeUndefined() + }) + + it('writes one setting', async () => { + const { lock, sendBLECommand } = ultraReplying(hex('01c9')) + await expect(lock.setQuickKey({ function: QuickKeyFunction.UnlockOnly })).resolves.toBe(true) + expect(bytes(sendBLECommand.mock.calls[0][0])).toBe('570f4e0401000201ff') + }) + + it('writes several settings', async () => { + const { lock, sendBLECommand } = ultraReplying(hex('01ce')) + await expect(lock.setQuickKey({ enabled: true, doublePress: true, function: QuickKeyFunction.LockAndUnlock })).resolves.toBe(true) + expect(bytes(sendBLECommand.mock.calls[0][0])).toBe('570f4e0401000e0eff') + }) + + it('writes false and zero values', () => { + expect(buildQuickKeyCommand({ enabled: false }).toString('hex')).toBe('570f4e04010008' + '00ff') + expect(buildQuickKeyCommand({ doublePress: false }).toString('hex')).toBe('570f4e04010004' + '00ff') + expect(buildQuickKeyCommand({ function: QuickKeyFunction.LockOnly }).toString('hex')).toBe('570f4e04010002' + '00ff') + expect(buildQuickKeyCommand({ enabled: false, doublePress: false, function: QuickKeyFunction.LockOnly }).toString('hex')).toBe('570f4e0401000e00ff') + }) + + it('reports a setting the lock did not apply', async () => { + const { lock } = ultraReplying(hex('01c0')) + await expect(lock.setQuickKey({ enabled: true })).resolves.toBe(false) + }) + + it('needs a setting, a valid speed and a Lock Ultra', async () => { + expect(() => buildQuickKeyCommand({})).toThrow(/at least one/) + expect(() => buildQuickKeyCommand({ motorSpeed: 3 as any })).toThrow(/motorSpeed/) + await expect(ultraReplying(hex('01c0'), 'UWB Lock').lock.getQuickKey()).rejects.toThrow(/not supported/) + }) +}) + +describe('product IDs and lock advertisements in the scanner', () => { + function parse(data: string, mfr?: string) { + const scanner = new BLEScanner({ noble: new EventEmitter() }) + return (scanner as any).parseServiceData(hex(data), mfr ? hex(mfr) : undefined) + } + + it('recognises the lock family by the product ID at the end of the service data', () => { + expect(parse('0080340010a5b8')).toMatchObject({ model: '0010a5b8', modelName: 'Lock Ultra' }) + expect(parse('00806400119fb8')).toMatchObject({ modelName: 'Lock Ultra Max' }) + expect(parse('00806400121100')).toMatchObject({ modelName: 'UWB Lock' }) + expect(parse('00806400122488')).toMatchObject({ modelName: 'Lock Nano' }) + expect(parse('00802e00116908')).toMatchObject({ modelName: 'Lock Vision' }) + expect(parse('00803300116909')).toMatchObject({ modelName: 'Lock Vision Pro' }) + expect(parse('00803d0010ff90')).toMatchObject({ modelName: 'Lock Pro WiFi' }) + }) + + it('still ignores unknown product IDs', () => { + expect(parse('00806400999999')).toBeNull() + }) + + it('parses the lock state from the manufacturer data', () => { + expect(parse('00806400122488', 'b0e9fe1122332a88086470')).toMatchObject({ + modelName: 'Lock Nano', + lockState: 'unlocked', + battery: 100, + sequenceNumber: 42, + unlockedAlarm: true, + }) + expect(parse('248064', 'f7610748e6e83a8a00640000')).toMatchObject({ + modelName: 'Lock Pro', + lockState: 'unlocked', + doorOpen: false, + sequenceNumber: 58, + }) + }) +}) + +describe('new device types', () => { + const apiDevice = { deviceId: 'api-1', deviceName: 'Device', enableCloudService: true, hubDeviceId: 'hub-1' } + + for (const [deviceType, className] of [ + ['Lock Ultra', 'WoSmartLockUltra'], + ['Smart Lock Ultra', 'WoSmartLockUltra'], + ['Lock Ultra Max', 'WoSmartLockUltra'], + ['UWB Lock', 'WoSmartLockUltra'], + ['Lock Nano', 'WoSmartLockNano'], + ['Hub 4', 'WoHub4'], + ['Plug Mini (IT)', 'WoPlugMiniUS'], + ['Outdoor Plug (EU)', 'WoPlugMiniUS'], + ] as const) { + it(`maps ${deviceType} to ${className}`, async () => { + const switchbot = new SwitchBot({ enableBLE: false, token: '', secret: '' }) + await (switchbot as any).handleAPIDiscovery({ ...apiDevice, deviceType }) + expect(switchbot.devices.list()[0]?.constructor.name).toBe(className) + }) + } +}) + +describe('bot inverse direction (pySwitchbot test_bot.py)', () => { + function botReplying(...replies: string[]) { + const bot = new WoHand({ id: 'bot-1', name: 'Bot', deviceType: 'Bot', connectionTypes: ['ble'], mac: 'AA:BB:CC:DD:EE:FF' }) + const sendBLECommand = vi.fn() + for (const reply of replies) { + sendBLECommand.mockResolvedValueOnce({ success: true, data: hex(reply) }) + } + ;(bot as any).sendBLECommand = sendBLECommand + return { bot, sendBLECommand } + } + + // Basic info: strength 75 (0x4b), switch mode on, inverse off (byte 9 = 0x10) + const basicInfo = Buffer.from([1, 89, 31, 75, 0, 0, 0, 0, 2, 0x10, 0]).toString('hex') + + it('reads the mode settings', async () => { + const { bot } = botReplying(Buffer.from([1, 89, 31, 75, 0, 0, 0, 0, 2, 17, 0]).toString('hex')) + await expect(bot.getModeSettings()).resolves.toEqual({ strength: 75, switchMode: true, inverseDirection: true }) + }) + + for (const [inverse, expected] of [[false, '57034b10'], [true, '57034b11']] as const) { + it(`sets inverse direction ${inverse}, keeping mode and strength`, async () => { + const { bot, sendBLECommand } = botReplying(basicInfo, '01') + await expect(bot.setInverseDirection(inverse)).resolves.toBe(true) + expect(bytes(sendBLECommand.mock.calls[0][0])).toBe('5702') + expect(bytes(sendBLECommand.mock.calls[1][0])).toBe(expected) + }) + } + + it('fails when the mode settings cannot be read', async () => { + const { bot } = botReplying('00') + await expect(bot.setInverseDirection(true)).rejects.toThrow(/mode settings/) + }) +}) diff --git a/test/types/device-type-resolution.test.ts b/test/types/device-type-resolution.test.ts index 78cd9cad..c63a66cf 100644 --- a/test/types/device-type-resolution.test.ts +++ b/test/types/device-type-resolution.test.ts @@ -61,8 +61,8 @@ describe('openAPI deviceType names', () => { ['Smart Lock', 'WoSmartLock'], ['Smart Lock Pro', 'WoSmartLockPro'], ['Smart Lock Pro Wifi', 'WoSmartLockProWiFi'], - ['Smart Lock Ultra', 'WoSmartLockPro'], - ['Lock Ultra', 'WoSmartLockPro'], + ['Smart Lock Ultra', 'WoSmartLockUltra'], + ['Lock Ultra', 'WoSmartLockUltra'], ['Permanent Outdoor Lights', 'WoRGBICWWStripLight'], ['Air Purifier VOC', 'WoAirPurifier'], ['Air Purifier Table PM2.5', 'WoAirPurifierTable'], diff --git a/test/utils/lock-advertisement.test.ts b/test/utils/lock-advertisement.test.ts new file mode 100644 index 00000000..4e382740 --- /dev/null +++ b/test/utils/lock-advertisement.test.ts @@ -0,0 +1,150 @@ +import { Buffer } from 'node:buffer' + +import { describe, expect, it } from 'vitest' + +import { lockStateFromStatus, LockStatusCode, parseLockAdvertisement } from '../../src/utils/lock-advertisement.js' + +// Fixtures and expected values are pySwitchbot's (tests/test_adv_parser.py, the lock cases). +// `mfr` is the manufacturer data after the company ID; `data` is the service data. +const hex = (value: string) => Buffer.from(value, 'hex') + +describe('parseLockAdvertisement (pySwitchbot fixtures)', () => { + it('lock Lite', () => { + expect(parseLockAdvertisement('lite', hex('2d8064'), hex('e9d511b26b5317930820'))).toMatchObject({ + sequenceNumber: 23, + battery: 100, + calibration: true, + status: LockStatusCode.Unlocked, + lockState: 'unlocked', + unlockedAlarm: false, + nightLatch: false, + }) + }) + + it('lock', () => { + expect(parseLockAdvertisement('lock', hex('6f8064'), hex('eef5e6098fe811970820'))).toMatchObject({ + sequenceNumber: 17, + battery: 100, + calibration: true, + status: LockStatusCode.Unlocked, + doorOpen: true, + unlockedAlarm: false, + autoLockPaused: false, + nightLatch: false, + }) + }) + + it('lock Pro', () => { + expect(parseLockAdvertisement('pro', hex('248064'), hex('f7610748e6e83a8a00640000'))).toMatchObject({ + sequenceNumber: 58, + battery: 100, + calibration: true, + status: LockStatusCode.Unlocked, + doorOpen: false, + lowTemperatureAlarm: false, + unlockedAlarm: false, + autoLockPaused: false, + nightLatch: false, + }) + }) + + it('lock Ultra (Lock 2 layout)', () => { + expect(parseLockAdvertisement('lock2', hex('0080340010a5b8'), hex('b0e9feb66a3d258230340004'))).toMatchObject({ + sequenceNumber: 37, + battery: 52, + calibration: true, + status: LockStatusCode.Locked, + lockState: 'locked', + doorOpen: true, + unlockedAlarm: false, + autoLockPaused: false, + powerAlarm: false, + batteryStatus: 4, + }) + }) + + it('lock Vision (Lock Lite layout)', () => { + expect(parseLockAdvertisement('lite', hex('00802e00116908'), hex('b0e9fe6fc9a60a00002e000c00000000'))).toMatchObject({ + sequenceNumber: 10, + calibration: false, + status: LockStatusCode.Locked, + unlockedAlarm: false, + nightLatch: false, + }) + }) + + it('lock Vision Pro (Lock 2 layout)', () => { + expect(parseLockAdvertisement('lock2', hex('00803300116909'), hex('b0e9fee61a7103000033000c00000000'))).toMatchObject({ + sequenceNumber: 3, + battery: 51, + status: LockStatusCode.Locked, + doorOpen: false, + powerAlarm: false, + batteryStatus: 4, + }) + }) + + it('lock Pro WiFi (Lock Pro layout)', () => { + expect(parseLockAdvertisement('pro', hex('00803d0010ff90'), hex('b0e9feb05efe0e02003d0008'))).toMatchObject({ + sequenceNumber: 14, + battery: 61, + status: LockStatusCode.Locked, + lowTemperatureAlarm: false, + }) + }) + + it('lock Ultra Max and UWB Lock (Lock 2 layout)', () => { + for (const data of ['00806400119fb8', '00806400121100']) { + expect(parseLockAdvertisement('lock2', hex(data), hex('b0e9fe1122332a8818640091'))).toMatchObject({ + sequenceNumber: 42, + battery: 100, + calibration: true, + status: LockStatusCode.Unlocked, + doorOpen: true, + unlockedAlarm: false, + autoLockPaused: true, + powerAlarm: true, + batteryStatus: 1, + }) + } + }) + + it('lock Nano', () => { + expect(parseLockAdvertisement('nano', hex('00806400122488'), hex('b0e9fe1122332a88086470'))).toEqual({ + sequenceNumber: 42, + battery: 100, + calibration: true, + status: LockStatusCode.Unlocked, + lockState: 'unlocked', + autoLockPaused: true, + nightLatch: false, + chargingStatus: 0, + batteryCharging: false, + unlockedAlarm: true, + lowTemperatureAlarm: true, + powerAlarm: true, + batteryStatus: 0, + chargingFault: false, + }) + }) + + it('returns undefined for short or missing manufacturer data', () => { + expect(parseLockAdvertisement('lock2', hex('00'), hex('b0e9fe1122'))).toBeUndefined() + expect(parseLockAdvertisement('nano', hex('00'), undefined)).toBeUndefined() + }) +}) + +describe('lockStateFromStatus', () => { + it('maps every pySwitchbot LockStatus', () => { + expect([0, 1, 2, 3, 4, 5, 6, 7].map(lockStateFromStatus)).toEqual([ + 'locked', + 'unlocked', + 'locked', + 'unlocked', + 'jammed', + 'jammed', + 'locked', + 'locked', + ]) + }) +})