From 99a7306f0cffbf4283d336dfd1d4b92253345255 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:20:49 -0500 Subject: [PATCH] fix: OpenAPI fallback, BLE replies, discovery merging, OpenAPI commands and BLE model codes Transport - Fall back to the OpenAPI when a BLE command fails. With connection intelligence on (the default), a fallback was only set when it picked a transport other than the preferred one, so BLE failures never reached the API. - Read BLE status, lock state and Bot state by peripheral id when the MAC is empty (macOS); only commands handled that before. - Enforce the circuit breaker's HALF_OPEN limit, and stop its recovery timer keeping the process alive. BLE replies - Judge a reply by its status byte (byte 0), as pySwitchbot and the BLE docs do. Scanning every byte read payload such as a 7% battery as "password required", and an error reply containing 0x01 as success. Discovery - Merge an OpenAPI device into its BLE device by id (the MAC without colons). Matching only on type strings, which differ between BLE and the API, dropped the API record, and with two devices of one model could attach the wrong device and lose the other. - Only match by type for BLE devices with no MAC, comparing resolved device classes, and send API calls to the OpenAPI deviceId (new DeviceInfo.apiDeviceId). OpenAPI commands (per the SwitchBotAPI device pages) - Blind Tilt: fullyOpen, closeUp, closeDown, and setPosition as `direction;position`, one attempt per call. - Air purifiers: setMode {mode, fanGear}; there is no setFanSpeed or setPresetMode. - RGBIC Neon Wire Rope Light: setColor as "r:g:b". - Color Bulb: setColorTemperature instead of the nonexistent setColorTemp. - Vacuums: no parameters on start/dock; S10, S20, K10+ Pro Combo, K11+ and K20+ Pro use startClean. Map "Robot Vacuum Cleaner K20 Plus Pro", and map S1/S1 Plus to the base vacuum class. BLE model codes - Align DEVICE_MODEL_MAP with pySwitchbot SUPPORTED_TYPES. Several codes named the wrong product (0x06 Presence Sensor, 'y' Water Leak Detector, 0x0D Relay Switch 1PM, 0x07 Humidifier 2, ...) and many were missing (Meter Pro, Relay Switches, Garage Door Opener, Plug Mini (EU), ...). Status parsing uses the corrected Lock and Relay Switch codes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TYWa2RXoWsaUTWh76oNv8s --- .github/copilot-instructions.md | 11 +- src/ble.ts | 15 +- src/devices/air-purifier-api.ts | 36 +++++ src/devices/base.ts | 50 ++++--- ...device-override-state-during-connection.ts | 5 +- src/devices/wo-air-purifier-pm25.ts | 13 +- src/devices/wo-air-purifier.ts | 7 +- src/devices/wo-blind-tilt.ts | 136 ++++++------------ src/devices/wo-bulb.ts | 3 +- src/devices/wo-hand.ts | 2 +- src/devices/wo-lock-pro.ts | 4 +- src/devices/wo-lock.ts | 4 +- src/devices/wo-rgbic-neon-wire-rope-light.ts | 4 +- src/devices/wo-vacuum-k10-pro-combo.ts | 7 +- src/devices/wo-vacuum-k11-plus.ts | 7 +- src/devices/wo-vacuum-k20.ts | 7 +- src/devices/wo-vacuum-s10.ts | 7 +- src/devices/wo-vacuum-s20.ts | 7 +- src/devices/wo-vacuum.ts | 18 ++- src/settings.ts | 85 ++++++++--- src/switchbot.ts | 53 ++++--- src/types/index.ts | 2 + src/utils/circuit-breaker.ts | 2 + test/apis/ble-command-result.test.ts | 47 ++++++ test/apis/discovery-merge.test.ts | 75 ++++++++++ test/devices/airpurifier-preset-mode.test.ts | 16 ++- test/devices/command-fallback.test.ts | 81 +++++++++++ test/devices/openapi-commands.test.ts | 100 +++++++++++++ test/devices/vacuum-commands.test.ts | 5 +- test/types/model-identities.test.ts | 46 ++++++ 30 files changed, 651 insertions(+), 204 deletions(-) create mode 100644 src/devices/air-purifier-api.ts create mode 100644 test/apis/ble-command-result.test.ts create mode 100644 test/apis/discovery-merge.test.ts create mode 100644 test/devices/command-fallback.test.ts create mode 100644 test/devices/openapi-commands.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0f9eab0f..2c989f7a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -41,9 +41,14 @@ 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` | -Known drift from pySwitchbot, to be aligned only on request and in its own PR: several -`DEVICE_MODEL_MAP` bytes differ (Meter Pro is `o` here, the same byte as Lock, but `4` in -pySwitchbot; Remote, Keypad, Lock Pro and Leak also differ). +`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. + +OpenAPI command names and parameters must match the device pages in +[SwitchBotAPI](https://github.com/OpenWonderLabs/SwitchBotAPI/tree/latest/devices); add a test in +`test/devices/openapi-commands.test.ts` for each one. ## Commands diff --git a/src/ble.ts b/src/ble.ts index dfb9edea..dbba48af 100644 --- a/src/ble.ts +++ b/src/ble.ts @@ -316,7 +316,7 @@ export class BLEScanner extends EventEmitter { } } - if ((model === 'o' || model === '\x11') && data.length > 1) { + if ((model === 'o' || model === 'O' || model === '\x11') && data.length > 1) { // Lock / Lock Pro const lockStatus = data[1] & 0x0F serviceData.status = lockStatus @@ -332,7 +332,7 @@ export class BLEScanner extends EventEmitter { } } - if ((model === '\x0D' || model === '\x0E') && data.length > 1) { + if ([';', '<', '\x1B', '\x1C', '\x0E'].includes(model) && data.length > 1) { // Relay Switch 1PM / Relay Switch 1 serviceData.state = (data[1] & 0x01) === 0x01 serviceData.channel2State = (data[1] & 0x02) === 0x02 @@ -685,15 +685,20 @@ export class BLEConnection { throw new CommandFailedError(`Empty BLE response from ${mac}`, 'ble') } - if (response.includes(0x07)) { + // Byte 0 is the status; anything after it is payload (battery, firmware, position...), so it + // must not be read as a status code. Matches pySwitchbot's _check_command_result. + const status = response[0] + + if (status === 0x07) { throw new CommandFailedError(`BLE command rejected by ${mac}: password required`, 'ble') } - if (response.includes(0x09)) { + if (status === 0x09) { throw new CommandFailedError(`BLE command rejected by ${mac}: password incorrect`, 'ble') } - const acknowledged = response.some(byte => byte === 0x01 || byte === 0x05 || byte === 0x06) + // 0x01 OK, 0x05 command not supported in the current mode (a Bot in add-on mode), 0x06 low battery + const acknowledged = status === 0x01 || status === 0x05 || status === 0x06 if (!acknowledged) { throw new CommandFailedError(`Unexpected BLE response from ${mac}: ${response.toString('hex')}`, 'ble') } diff --git a/src/devices/air-purifier-api.ts b/src/devices/air-purifier-api.ts new file mode 100644 index 00000000..f4025032 --- /dev/null +++ b/src/devices/air-purifier-api.ts @@ -0,0 +1,36 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * devices/air-purifier-api.ts: OpenAPI parameters for air purifiers + */ + +/** + * OpenAPI `setMode` parameter for air purifiers: `{"mode": mode_int, "fanGear": fan_level_int}`, + * where mode is 1 (normal/fan), 2 (auto), 3 (sleep) or 4 (pet), and fanGear (1-3) applies only + * to mode 1. OpenAPI has no separate fan speed or preset command; both are `setMode`. + */ +export interface AirPurifierAPIMode { + mode: 1 | 2 | 3 | 4 + fanGear?: number +} + +/** `setMode('auto' | 'manual' | 'sleep')` */ +export function airPurifierModeParameter(mode: 'auto' | 'manual' | 'sleep'): AirPurifierAPIMode { + return { auto: { mode: 2 }, manual: { mode: 1 }, sleep: { mode: 3 } }[mode] as AirPurifierAPIMode +} + +/** `setFanSpeed(1-4)`; OpenAPI fan gears go up to 3 */ +export function airPurifierFanParameter(speed: number): AirPurifierAPIMode { + return { mode: 1, fanGear: Math.min(3, Math.max(1, Math.round(speed))) } +} + +/** `setPresetMode(level_1 | level_2 | level_3 | auto | sleep | pet)` */ +export function airPurifierPresetParameter(preset: 'level_1' | 'level_2' | 'level_3' | 'auto' | 'sleep' | 'pet'): AirPurifierAPIMode { + return { + level_1: { mode: 1, fanGear: 1 }, + level_2: { mode: 1, fanGear: 2 }, + level_3: { mode: 1, fanGear: 3 }, + auto: { mode: 2 }, + sleep: { mode: 3 }, + pet: { mode: 4 }, + }[preset] as AirPurifierAPIMode +} diff --git a/src/devices/base.ts b/src/devices/base.ts index 14ec54f7..7dae3fa3 100644 --- a/src/devices/base.ts +++ b/src/devices/base.ts @@ -16,7 +16,6 @@ import { DEVICE_COMMANDS } from '../settings.js' import { CircuitBreaker, - CircuitBreakerState, ConnectionTracker, FallbackHandlerManager, @@ -395,14 +394,12 @@ export abstract class SwitchBotDevice extends EventEmitter { } // Check circuit breaker + // Reject while OPEN, and while HALF_OPEN once its trial request is in use if (this.enableCircuitBreaker && !this.circuitBreakerBLE.canExecute()) { - const state = this.circuitBreakerBLE.getState() - if (state === CircuitBreakerState.OPEN) { - return { - success: false, - connectionType: 'ble', - error: 'BLE circuit breaker is OPEN (too many failures)', - } + return { + success: false, + connectionType: 'ble', + error: `BLE circuit breaker is ${this.circuitBreakerBLE.getState()} (too many failures)`, } } @@ -411,7 +408,7 @@ export abstract class SwitchBotDevice extends EventEmitter { const buffer = Buffer.isBuffer(command) ? command : Buffer.from(command) const startTime = Date.now() - const mac = this.info.mac && this.info.mac.length > 0 ? this.info.mac : `id:${this.info.bleId}` + const mac = this.bleAddress() let response: Buffer | undefined if (this.info.encryptionKey && this.info.encryptionIV && this.bleConnection?.setEncryption) { @@ -501,14 +498,12 @@ export abstract class SwitchBotDevice extends EventEmitter { } // Check circuit breaker + // Reject while OPEN, and while HALF_OPEN once its trial request is in use if (this.enableCircuitBreaker && !this.circuitBreakerAPI.canExecute()) { - const state = this.circuitBreakerAPI.getState() - if (state === CircuitBreakerState.OPEN) { - return { - success: false, - connectionType: 'api', - error: 'API circuit breaker is OPEN (too many failures)', - } + return { + success: false, + connectionType: 'api', + error: `API circuit breaker is ${this.circuitBreakerAPI.getState()} (too many failures)`, } } @@ -516,7 +511,7 @@ export abstract class SwitchBotDevice extends EventEmitter { this.logger.debug('Sending API command', { command, parameter }) const startTime = Date.now() - const response = await this.apiClient!.sendCommand(this.info.id, command, parameter) + const response = await this.apiClient!.sendCommand(this.info.apiDeviceId ?? this.info.id, command, parameter) const latencyMs = Date.now() - startTime // Record success @@ -615,10 +610,13 @@ export abstract class SwitchBotDevice extends EventEmitter { if (this.enableConnectionIntelligence) { primaryConnection = this.getBestConnection() - if (this.preferredConnection === 'ble' && primaryConnection === 'api' && this.hasBLE()) { - secondaryConnection = 'ble' - } else if (this.preferredConnection === 'api' && primaryConnection === 'ble' && this.hasAPI()) { + // Fall back to the other transport whichever one intelligence picked. Previously a + // fallback was only set when the pick differed from the preferred transport, so with + // BLE preferred and picked, a failed BLE command never reached the API. + if (primaryConnection === 'ble' && this.hasAPI()) { secondaryConnection = 'api' + } else if (primaryConnection === 'api' && this.hasBLE()) { + secondaryConnection = 'ble' } } else { // Fallback based on preferred connection @@ -696,7 +694,7 @@ export abstract class SwitchBotDevice extends EventEmitter { try { this.logger.debug('Reading BLE status') const startTime = Date.now() - const data = await this.bleConnection!.read(this.info.mac ?? `id:${this.info.bleId}`) + const data = await this.bleConnection!.read(this.bleAddress()) const latencyMs = Date.now() - startTime if (this.enableConnectionIntelligence) { @@ -723,6 +721,14 @@ export abstract class SwitchBotDevice extends EventEmitter { } } + /** + * The address BLEConnection uses for this device: its MAC, or `id:` when there is no + * MAC (macOS reports a peripheral id instead). + */ + protected bleAddress(): string { + return this.info.mac && this.info.mac.length > 0 ? this.info.mac : `id:${this.info.bleId}` + } + protected normalizeBLEStatusData(data: any): Record { if (data && typeof data === 'object' && !Buffer.isBuffer(data)) { return data as Record @@ -757,7 +763,7 @@ export abstract class SwitchBotDevice extends EventEmitter { try { this.logger.debug('Reading API status') const startTime = Date.now() - const data = await this.apiClient!.getStatus(this.info.id) + const data = await this.apiClient!.getStatus(this.info.apiDeviceId ?? this.info.id) const latencyMs = Date.now() - startTime if (this.enableConnectionIntelligence) { diff --git a/src/devices/device-override-state-during-connection.ts b/src/devices/device-override-state-during-connection.ts index d6913b89..12875208 100644 --- a/src/devices/device-override-state-during-connection.ts +++ b/src/devices/device-override-state-during-connection.ts @@ -64,11 +64,10 @@ export abstract class DeviceOverrideStateDuringConnection extends SwitchBotDevic return false } - const macOrBleId = this.info.mac ?? (this.info.bleId ? `id:${this.info.bleId}` : undefined) - if (!macOrBleId) { + if (!this.info.mac && !this.info.bleId) { return false } - return this.bleConnection.isConnected(macOrBleId) + return this.bleConnection.isConnected(this.bleAddress()) } } diff --git a/src/devices/wo-air-purifier-pm25.ts b/src/devices/wo-air-purifier-pm25.ts index 88161308..300058ac 100644 --- a/src/devices/wo-air-purifier-pm25.ts +++ b/src/devices/wo-air-purifier-pm25.ts @@ -2,6 +2,7 @@ import type { AirPurifierStatus } from '../types/device.js' import { Buffer } from 'node:buffer' +import { airPurifierFanParameter, airPurifierModeParameter, airPurifierPresetParameter } from './air-purifier-api.js' import { SwitchBotDevice } from './base.js' export class WoAirPurifierPM25 extends SwitchBotDevice { @@ -90,13 +91,13 @@ export class WoAirPurifierPM25 extends SwitchBotDevice { const modeMap: Record = { auto: 0, manual: 1, sleep: 2 } if (this.hasBLE()) { const bleCommand = Buffer.from([0x57, 0x02, modeMap[mode]]) - const result = await this.sendCommand(bleCommand, 'setMode', mode) + const result = await this.sendCommand(bleCommand, 'setMode', airPurifierModeParameter(mode)) if (result.success) { return result } } if (this.hasAPI()) { - return this.sendAPICommand('setMode', mode) + return this.sendAPICommand('setMode', airPurifierModeParameter(mode)) } throw new Error('No connection method available') } @@ -108,13 +109,13 @@ export class WoAirPurifierPM25 extends SwitchBotDevice { const clampedSpeed = Math.max(1, Math.min(4, speed)) if (this.hasBLE()) { const bleCommand = Buffer.from([0x57, 0x03, clampedSpeed]) - const result = await this.sendCommand(bleCommand, 'setFanSpeed', clampedSpeed) + const result = await this.sendCommand(bleCommand, 'setMode', airPurifierFanParameter(clampedSpeed)) if (result.success) { return true } } if (this.hasAPI()) { - const result = await this.sendAPICommand('setFanSpeed', clampedSpeed) + const result = await this.sendAPICommand('setMode', airPurifierFanParameter(clampedSpeed)) return result.success } throw new Error('No connection method available') @@ -138,13 +139,13 @@ export class WoAirPurifierPM25 extends SwitchBotDevice { } if (this.hasBLE()) { const bleCommand = Buffer.from([0x57, 0x02, modeId]) - const result = await this.sendCommand(bleCommand, 'setPresetMode', mode) + const result = await this.sendCommand(bleCommand, 'setMode', airPurifierPresetParameter(mode)) if (result.success) { return true } } if (this.hasAPI()) { - const result = await this.sendAPICommand('setPresetMode', mode) + const result = await this.sendAPICommand('setMode', airPurifierPresetParameter(mode)) return result.success } throw new Error('No connection method available') diff --git a/src/devices/wo-air-purifier.ts b/src/devices/wo-air-purifier.ts index 80d1ebbb..b0028c14 100644 --- a/src/devices/wo-air-purifier.ts +++ b/src/devices/wo-air-purifier.ts @@ -5,6 +5,7 @@ import { Buffer } from 'node:buffer' import { DEVICE_COMMANDS } from '../settings.js' import { clamp, validateResponseLength } from '../utils/index.js' import { encryptRelayCommandIfNeeded } from '../utils/relay-encryption.js' +import { airPurifierFanParameter, airPurifierModeParameter, airPurifierPresetParameter } from './air-purifier-api.js' import { SequenceDevice } from './sequence-device.js' /** @@ -80,7 +81,7 @@ export class WoAirPurifier extends SequenceDevice implements AirPurifierCommands const bleCommand = [...DEVICE_COMMANDS.AIR_PURIFIER.SET_MODE, modeMap[mode]] const command = this.maybeEncryptCommand(bleCommand) - return this.sendCommand(command, 'setMode', mode) + return this.sendCommand(command, 'setMode', airPurifierModeParameter(mode)) } /** @@ -91,7 +92,7 @@ export class WoAirPurifier extends SequenceDevice implements AirPurifierCommands const bleCommand = [...DEVICE_COMMANDS.AIR_PURIFIER.SET_SPEED, clampedSpeed] const command = this.maybeEncryptCommand(bleCommand) - const result = await this.sendCommand(command, 'setFanSpeed', clampedSpeed) + const result = await this.sendCommand(command, 'setMode', airPurifierFanParameter(clampedSpeed)) return result.success } @@ -161,7 +162,7 @@ export class WoAirPurifier extends SequenceDevice implements AirPurifierCommands } const bleCommand = [...DEVICE_COMMANDS.AIR_PURIFIER.SET_MODE, modeId] // For test compatibility, pass the plain array as the first argument - const result = await this.sendCommand(bleCommand, 'setPresetMode', mode) + const result = await this.sendCommand(bleCommand, 'setMode', airPurifierPresetParameter(mode)) return result.success } } diff --git a/src/devices/wo-blind-tilt.ts b/src/devices/wo-blind-tilt.ts index 6a52cc4d..7633849d 100644 --- a/src/devices/wo-blind-tilt.ts +++ b/src/devices/wo-blind-tilt.ts @@ -14,120 +14,72 @@ import { SwitchBotDevice } from './base.js' * Blind Tilt Device */ export class WoBlindTilt extends SwitchBotDevice implements BlindTiltCommands { + /** + * Send one command over the best transport; sendCommand() falls back between BLE and OpenAPI. + * OpenAPI commands follow the Blind Tilt docs: fullyOpen, closeUp, closeDown, and + * setPosition with `direction;position`. + */ + private async command(bleCommand: readonly number[], apiCommand: string, apiParameter?: string): Promise { + if (!this.hasBLE() && !this.hasAPI()) { + throw new Error('No connection method available') + } + const result = await this.sendCommand(bleCommand, apiCommand, apiParameter) + return result.success + } + /** * Open blind (position 50%) */ async open(): Promise { - // BLE-first, OpenAPI-fallback - if (this.hasBLE()) { - const result = await this.sendCommand( - DEVICE_COMMANDS.BLIND_TILT.OPEN, - 'turnOn', - ) - if (result.success) { - return true - } - } - if (this.hasAPI()) { - const result = await this.sendAPICommand('turnOn') - return result.success - } - throw new Error('No connection method available') + return this.command(DEVICE_COMMANDS.BLIND_TILT.OPEN, 'fullyOpen') } /** * Close blind up (position 100%) */ async closeUp(): Promise { - // BLE-first, OpenAPI-fallback - if (this.hasBLE()) { - const result = await this.sendCommand( - DEVICE_COMMANDS.BLIND_TILT.CLOSE_UP, - 'setPosition', - ) - if (result.success) { - return true - } - } - if (this.hasAPI()) { - const result = await this.sendAPICommand('setPosition', { position: 100 }) - return result.success - } - throw new Error('No connection method available') + return this.command(DEVICE_COMMANDS.BLIND_TILT.CLOSE_UP, 'closeUp') } - // Add stubs for required BlindTiltCommands interface methods + /** + * Close blind (closes up) + */ async close(): Promise { - // BLE-first, OpenAPI-fallback - if (this.hasBLE()) { - const result = await this.sendCommand( - DEVICE_COMMANDS.BLIND_TILT.CLOSE_UP, - 'turnOff', - ) - if (result.success) { - return true - } - } - if (this.hasAPI()) { - const result = await this.sendAPICommand('turnOff') - return result.success - } - throw new Error('No connection method available') + return this.closeUp() } + /** + * Close blind down (position 0%) + */ async closeDown(): Promise { - // BLE-first, OpenAPI-fallback - if (this.hasBLE()) { - const result = await this.sendCommand( - DEVICE_COMMANDS.BLIND_TILT.CLOSE_DOWN, - 'setPosition', - ) - if (result.success) { - return true - } - } - if (this.hasAPI()) { - const result = await this.sendAPICommand('setPosition', { position: 0 }) - return result.success - } - throw new Error('No connection method available') + return this.command(DEVICE_COMMANDS.BLIND_TILT.CLOSE_DOWN, 'closeDown') } + /** + * Stop movement. OpenAPI has no pause command for Blind Tilt, so this needs BLE. + */ async pause(): Promise { - // BLE-first, OpenAPI-fallback - if (this.hasBLE()) { - const result = await this.sendCommand( - DEVICE_COMMANDS.BLIND_TILT.PAUSE, - 'pause', - ) - if (result.success) { - return true - } - } - if (this.hasAPI()) { - const result = await this.sendAPICommand('pause') - return result.success - } - throw new Error('No connection method available') + return this.command(DEVICE_COMMANDS.BLIND_TILT.PAUSE, 'pause') } + /** + * Set tilt position: 0 = closed down, 50 = open, 100 = closed up (as in pySwitchbot). + */ async setPosition(position: number): Promise { - // BLE-first, OpenAPI-fallback - const pos = clamp(position, 0, 100) - if (this.hasBLE()) { - // Copy the OPEN command and replace the last byte with the desired position - const base = [...DEVICE_COMMANDS.BLIND_TILT.OPEN] as number[] - base[base.length - 1] = pos - const result = await this.sendCommand(base, 'setPosition') - if (result.success) { - return true - } - } - if (this.hasAPI()) { - const result = await this.sendAPICommand('setPosition', { position: pos }) - return result.success - } - throw new Error('No connection method available') + const pos = Math.round(clamp(position, 0, 100)) + // Copy the OPEN command and replace the last byte with the desired position + const bleCommand = [...DEVICE_COMMANDS.BLIND_TILT.OPEN] as number[] + bleCommand[bleCommand.length - 1] = pos + return this.command(bleCommand, 'setPosition', WoBlindTilt.toAPIPosition(pos)) + } + + /** + * Convert a 0-100 tilt position (50 = open) to the OpenAPI `direction;position` parameter, + * where position runs from 0 (closed) to 100 (open) and must be even. + */ + static toAPIPosition(position: number): string { + const pos = Math.round(clamp(position, 0, 100)) + return pos <= 50 ? `down;${pos * 2}` : `up;${(100 - pos) * 2}` } private _lastPosition?: number diff --git a/src/devices/wo-bulb.ts b/src/devices/wo-bulb.ts index 6b5043f1..1fc0d743 100644 --- a/src/devices/wo-bulb.ts +++ b/src/devices/wo-bulb.ts @@ -129,7 +129,8 @@ export class WoBulb extends SwitchBotDevice implements BulbCommands { ...tempBytes, ] const command = this.maybeEncryptCommand(bleCommand) - const result = await this.sendCommand(command, 'setColorTemp', `${finalMinTemp}-${finalMaxTemp}:${clampedTemp}`) + // OpenAPI has no range command; setColorTemperature takes the target in kelvin + const result = await this.sendCommand(command, 'setColorTemperature', clampedTemp) return result.success } diff --git a/src/devices/wo-hand.ts b/src/devices/wo-hand.ts index bc15e8ed..1fcb11a9 100644 --- a/src/devices/wo-hand.ts +++ b/src/devices/wo-hand.ts @@ -206,7 +206,7 @@ export class WoHand extends DeviceOverrideStateDuringConnection implements BotCo this.logger.debug('Sending password-protected command', { action }) // Send command via BLE - const mac = this.info.mac ?? `id:${this.info.bleId}` + const mac = this.bleAddress() await this.bleConnection!.write(mac, command) // Read response diff --git a/src/devices/wo-lock-pro.ts b/src/devices/wo-lock-pro.ts index 02fa7306..07b96c64 100644 --- a/src/devices/wo-lock-pro.ts +++ b/src/devices/wo-lock-pro.ts @@ -106,7 +106,7 @@ export class WoSmartLockPro extends SequenceDevice implements LockCommands { throw new Error('BLE not available for lock notifications') } - const mac = this.info.mac ?? `id:${this.info.bleId}` + const mac = this.bleAddress() await this.bleConnection!.subscribeNotifications(mac, handler) this.lockNotificationHandlers.add(handler) } @@ -116,7 +116,7 @@ export class WoSmartLockPro extends SequenceDevice implements LockCommands { return } - const mac = this.info.mac ?? `id:${this.info.bleId}` + const mac = this.bleAddress() this.bleConnection?.unsubscribeNotifications(mac, handler) this.lockNotificationHandlers.delete(handler) } diff --git a/src/devices/wo-lock.ts b/src/devices/wo-lock.ts index 3afb33cb..44395937 100644 --- a/src/devices/wo-lock.ts +++ b/src/devices/wo-lock.ts @@ -83,7 +83,7 @@ export class WoSmartLock extends SequenceDevice implements LockCommands { throw new Error('BLE not available for lock notifications') } - const mac = this.info.mac ?? `id:${this.info.bleId}` + const mac = this.bleAddress() await this.bleConnection!.subscribeNotifications(mac, handler) this.lockNotificationHandlers.add(handler) } @@ -93,7 +93,7 @@ export class WoSmartLock extends SequenceDevice implements LockCommands { return } - const mac = this.info.mac ?? `id:${this.info.bleId}` + const mac = this.bleAddress() this.bleConnection?.unsubscribeNotifications(mac, handler) this.lockNotificationHandlers.delete(handler) } diff --git a/src/devices/wo-rgbic-neon-wire-rope-light.ts b/src/devices/wo-rgbic-neon-wire-rope-light.ts index cfedb573..a0208083 100644 --- a/src/devices/wo-rgbic-neon-wire-rope-light.ts +++ b/src/devices/wo-rgbic-neon-wire-rope-light.ts @@ -92,13 +92,13 @@ export class WoRGBICNeonWireRopeLight extends SwitchBotDevice { async setColor(red: number, green: number, blue: number): Promise { if (this.hasBLE()) { const command = Buffer.from([0x57, 0x03, red, green, blue]) - const result = await this.sendCommand(command, 'setColor', { red, green, blue }) + const result = await this.sendCommand(command, 'setColor', `${red}:${green}:${blue}`) if (result.success) { return true } } if (this.hasAPI()) { - const result = await this.sendAPICommand('setColor', { red, green, blue }) + const result = await this.sendAPICommand('setColor', `${red}:${green}:${blue}`) return result.success } throw new Error('No connection method available') diff --git a/src/devices/wo-vacuum-k10-pro-combo.ts b/src/devices/wo-vacuum-k10-pro-combo.ts index 6dc63a3b..e27db76c 100644 --- a/src/devices/wo-vacuum-k10-pro-combo.ts +++ b/src/devices/wo-vacuum-k10-pro-combo.ts @@ -8,4 +8,9 @@ import { WoVacuum } from './wo-vacuum.js' /** * Robot Vacuum Cleaner K10+ Pro Combo */ -export class WoVacuumK10ProCombo extends WoVacuum {} +export class WoVacuumK10ProCombo extends WoVacuum { + /** OpenAPI `startClean`: sweep once at fan level 1 */ + protected override apiCleanCommand(): { command: string, parameter?: unknown } { + return { command: 'startClean', parameter: { action: 'sweep', param: { fanLevel: 1, times: 1 } } } + } +} diff --git a/src/devices/wo-vacuum-k11-plus.ts b/src/devices/wo-vacuum-k11-plus.ts index de81bcb2..9e348e67 100644 --- a/src/devices/wo-vacuum-k11-plus.ts +++ b/src/devices/wo-vacuum-k11-plus.ts @@ -8,4 +8,9 @@ import { WoVacuum } from './wo-vacuum.js' /** * Robot Vacuum Cleaner K11+ */ -export class WoVacuumK11Plus extends WoVacuum {} +export class WoVacuumK11Plus extends WoVacuum { + /** OpenAPI `startClean`: sweep once at fan level 1 */ + protected override apiCleanCommand(): { command: string, parameter?: unknown } { + return { command: 'startClean', parameter: { action: 'sweep', param: { fanLevel: 1, times: 1 } } } + } +} diff --git a/src/devices/wo-vacuum-k20.ts b/src/devices/wo-vacuum-k20.ts index 91b9833b..4146add6 100644 --- a/src/devices/wo-vacuum-k20.ts +++ b/src/devices/wo-vacuum-k20.ts @@ -8,4 +8,9 @@ import { WoVacuum } from './wo-vacuum.js' /** * Robot Vacuum Cleaner K20 */ -export class WoVacuumK20 extends WoVacuum {} +export class WoVacuumK20 extends WoVacuum { + /** OpenAPI `startClean`: sweep once at fan level 1 */ + protected override apiCleanCommand(): { command: string, parameter?: unknown } { + return { command: 'startClean', parameter: { action: 'sweep', param: { fanLevel: 1, times: 1 } } } + } +} diff --git a/src/devices/wo-vacuum-s10.ts b/src/devices/wo-vacuum-s10.ts index e702bb42..b54cd174 100644 --- a/src/devices/wo-vacuum-s10.ts +++ b/src/devices/wo-vacuum-s10.ts @@ -8,4 +8,9 @@ import { WoVacuum } from './wo-vacuum.js' /** * Robot Vacuum Cleaner S10 */ -export class WoVacuumS10 extends WoVacuum {} +export class WoVacuumS10 extends WoVacuum { + /** OpenAPI `startClean`: sweep once at fan level 1 */ + protected override apiCleanCommand(): { command: string, parameter?: unknown } { + return { command: 'startClean', parameter: { action: 'sweep', param: { fanLevel: 1, waterLevel: 1, times: 1 } } } + } +} diff --git a/src/devices/wo-vacuum-s20.ts b/src/devices/wo-vacuum-s20.ts index e221fb9f..51c997c8 100644 --- a/src/devices/wo-vacuum-s20.ts +++ b/src/devices/wo-vacuum-s20.ts @@ -8,4 +8,9 @@ import { WoVacuum } from './wo-vacuum.js' /** * Robot Vacuum Cleaner S20 */ -export class WoVacuumS20 extends WoVacuum {} +export class WoVacuumS20 extends WoVacuum { + /** OpenAPI `startClean`: sweep once at fan level 1 */ + protected override apiCleanCommand(): { command: string, parameter?: unknown } { + return { command: 'startClean', parameter: { action: 'sweep', param: { fanLevel: 1, waterLevel: 1, times: 1 } } } + } +} diff --git a/src/devices/wo-vacuum.ts b/src/devices/wo-vacuum.ts index 59785ff0..1ed37cc3 100644 --- a/src/devices/wo-vacuum.ts +++ b/src/devices/wo-vacuum.ts @@ -12,6 +12,14 @@ import { SequenceDevice } from './sequence-device.js' * Vacuum Device */ export class WoVacuum extends SequenceDevice implements VacuumCommands { + /** + * OpenAPI command that starts cleaning. The S1, S1 Plus, K10+ and K10+ Pro use `start`; newer + * models (S10, S20, K10+ Pro Combo, K11+, K20+ Pro) only accept `startClean` and override this. + */ + protected apiCleanCommand(): { command: string, parameter?: unknown } { + return { command: 'start' } + } + /** * Start cleaning (BLE-first, API-fallback) */ @@ -24,7 +32,8 @@ export class WoVacuum extends SequenceDevice implements VacuumCommands { if (this.hasBLE()) { try { const command = this.getCommandForProtocol(DEVICE_COMMANDS.VACUUM.CLEAN_UP, protocolVersion) - const result = await this.sendCommand(command, 'start', { protocolVersion }) + const { command: apiCommand, parameter } = this.apiCleanCommand() + const result = await this.sendCommand(command, apiCommand, parameter) if (result.success) { return true } @@ -34,7 +43,8 @@ export class WoVacuum extends SequenceDevice implements VacuumCommands { } // Fallback to API if (this.hasAPI()) { - const apiResult = await this.sendAPICommand('start', { protocolVersion }) + const { command: apiCommand, parameter } = this.apiCleanCommand() + const apiResult = await this.sendAPICommand(apiCommand, parameter) return apiResult.success } throw new Error('No connection method available for cleanUp') @@ -52,7 +62,7 @@ export class WoVacuum extends SequenceDevice implements VacuumCommands { if (this.hasBLE()) { try { const command = this.getCommandForProtocol(DEVICE_COMMANDS.VACUUM.RETURN_TO_DOCK, protocolVersion) - const result = await this.sendCommand(command, 'dock', { protocolVersion }) + const result = await this.sendCommand(command, 'dock') if (result.success) { return true } @@ -62,7 +72,7 @@ export class WoVacuum extends SequenceDevice implements VacuumCommands { } // Fallback to API if (this.hasAPI()) { - const apiResult = await this.sendAPICommand('dock', { protocolVersion }) + const apiResult = await this.sendAPICommand('dock') return apiResult.success } throw new Error('No connection method available for returnToDock') diff --git a/src/settings.ts b/src/settings.ts index 54515a92..ff0cb5ba 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -180,53 +180,102 @@ export const WoSmartLockProCommands = { * Device Model to Type Mapping */ export const DEVICE_MODEL_MAP: Record = { + // Codes are byte 0 of the service data with the top bit cleared, as in pySwitchbot's + // SUPPORTED_TYPES (switchbot/adv_parser.py). Most products have two codes. 'H': 'Bot', 'c': 'Curtain', 'C': 'Curtain', '{': 'Curtain3', '[': 'Curtain3', + 'x': 'Blind Tilt', + 'X': 'Blind Tilt', + ',': 'Roller Shade', + '\x0C': 'Roller Shade', 'g': 'Plug Mini (US)', 'G': 'Plug Mini (US)', 'j': 'Plug Mini (JP)', 'J': 'Plug Mini (JP)', + '?': 'Plug Mini (EU)', + '\x1F': 'Plug Mini (EU)', + ';': 'Relay Switch 1', + '\x1B': 'Relay Switch 1', + '<': 'Relay Switch 1PM', + '\x1C': 'Relay Switch 1PM', + '=': 'Relay Switch 2PM', + '\x1D': 'Relay Switch 2PM', + '>': 'Garage Door Opener', + '\x1E': 'Garage Door Opener', 'T': 'Meter', + 't': 'Meter', 'i': 'Meter Plus', 'I': 'Meter Plus', + '4': 'Meter Pro', + '\x14': 'Meter Pro', '5': 'Meter Pro (CO2)', '\x15': 'Meter Pro (CO2)', 'w': 'Indoor/Outdoor Thermo-Hygrometer', 'W': 'Indoor/Outdoor Thermo-Hygrometer', 'o': 'Lock', - '\x11': 'Lock Pro', - 'k': 'Keypad', - '\x0B': 'Keypad Touch', + 'O': 'Lock', + '$': 'Lock Pro', + '\x04': 'Lock Pro', + '-': 'Lock Lite', + '\x0D': 'Lock Lite', + 'y': 'Keypad', + 'Y': 'Keypad', 's': 'Motion Sensor', + 'S': 'Motion Sensor', 'd': 'Contact Sensor', + 'D': 'Contact Sensor', + '&': 'Water Leak Detector', + '\x06': 'Water Leak Detector', 'q': 'Ceiling Light', 'Q': 'Ceiling Light', 'n': 'Ceiling Light Pro', 'N': 'Ceiling Light Pro', 'r': 'Strip Light', 'R': 'Strip Light', - 'p': 'Strip Light', 'u': 'Color Bulb', - '\x0A': 'Robot Vacuum Cleaner S1', - '\x0C': 'Robot Vacuum Cleaner S1 Plus', - '\x0F': 'Robot Vacuum Cleaner K10 Plus', + 'U': 'Color Bulb', 'e': 'Humidifier', - '\x07': 'Humidifier 2', - 'x': 'Blind Tilt', + 'E': 'Humidifier', + '#': 'Evaporative Humidifier', + '\x03': 'Evaporative Humidifier', + '*': 'Air Purifier', + '\x0A': 'Air Purifier', + '+': 'Air Purifier', + '\x0B': 'Air Purifier', + '7': 'Air Purifier Table', + '\x17': 'Air Purifier Table', + '8': 'Air Purifier Table', + '\x18': 'Air Purifier Table', + '~': 'Circulator Fan', + '^': 'Circulator Fan', + 'v': 'Hub 2', + 'V': 'Hub 2', + '%': 'HubMini Matter', + '\x05': 'HubMini Matter', + 'b': 'Remote', + 'B': 'Remote', + 'z': 'Robot Vacuum Cleaner S10', + 'Z': 'Robot Vacuum Cleaner S10', + '}': 'Robot Vacuum Cleaner K10+', + ']': 'Robot Vacuum Cleaner K10+', + '(': 'Robot Vacuum Cleaner K10+ Pro', + '\x08': 'Robot Vacuum Cleaner K10+ Pro', + '3': 'K10+ Pro Combo', + '\x13': 'K10+ Pro Combo', + '.': 'Robot Vacuum Cleaner K20', + '\x0F': 'Robot Vacuum Cleaner K20', + // Codes not in pySwitchbot, kept from earlier node-switchbot releases '\x01': 'Hub 2', '\x02': 'Hub 3', - '\x05': 'Remote', - '\x04': 'Battery Circulator Fan', - '\x08': 'Air Purifier', '\x09': 'Air Purifier Table', - 'y': 'Water Leak Detector', - '\x06': 'Presence Sensor', - '\x0D': 'Relay Switch 1PM', '\x0E': 'Relay Switch 1', '\x10': 'K10+ Pro Combo', + '\x11': 'Lock Pro', + 'k': 'Keypad', + 'p': 'Strip Light', } as const /** @@ -284,13 +333,15 @@ export const DEVICE_CLASS_MAP: Record = { 'SwitchBot RGBICWW Floor Lamp': 'WoRGBICWWFloorLamp', 'Art Frame': 'WoArtFrame', 'SwitchBot Art Frame': 'WoArtFrame', - 'Robot Vacuum Cleaner S1': 'WoVacuumS10', - 'Robot Vacuum Cleaner S1 Plus': 'WoVacuumS20', + // S1 and S1 Plus use the original start/stop/dock OpenAPI commands, unlike the S10/S20 + 'Robot Vacuum Cleaner S1': 'WoVacuum', + 'Robot Vacuum Cleaner S1 Plus': 'WoVacuum', 'Robot Vacuum Cleaner K10 Plus': 'WoVacuumK10Plus', 'Robot Vacuum Cleaner K10+': 'WoVacuumK10Plus', 'Robot Vacuum Cleaner K10+ Pro': 'WoVacuumK10Pro', 'Robot Vacuum Cleaner K11+': 'WoVacuumK11Plus', 'Robot Vacuum Cleaner K20': 'WoVacuumK20', + 'Robot Vacuum Cleaner K20 Plus Pro': 'WoVacuumK20', 'Robot Vacuum Cleaner S10': 'WoVacuumS10', 'Robot Vacuum Cleaner S20': 'WoVacuumS20', 'Humidifier': 'WoHumi', diff --git a/src/switchbot.ts b/src/switchbot.ts index c2867f30..a0441a4d 100644 --- a/src/switchbot.ts +++ b/src/switchbot.ts @@ -441,9 +441,9 @@ export class SwitchBot extends EventEmitter { this.logger.debug(`Processing API device: ${deviceName} (${deviceId}, type: ${deviceType})`) - // Try to find matching BLE device - // Strategy: look for BLE devices of the same type that haven't been fully populated yet - const matchedBLEDevice = this.findMatchingBLEDevice(deviceType, deviceName) + // A BLE device's id is its MAC without colons, which is also its OpenAPI deviceId, so match + // on id first. Only a BLE device with no MAC (macOS) falls back to matching by type. + const matchedBLEDevice = this.deviceManager.get(deviceId) ?? this.findMatchingBLEDevice(deviceType, deviceName) if (matchedBLEDevice) { // Update existing BLE device with API information @@ -454,6 +454,8 @@ export class SwitchBot extends EventEmitter { hubDeviceId, version, cloudServiceEnabled: enableCloudService, + // API calls must use the OpenAPI deviceId when the device is known by a BLE peripheral id + ...(deviceId_internal !== deviceId ? { apiDeviceId: deviceId } : {}), }) this.logger.info(`Matched API device to existing BLE device: ${deviceName} (${deviceId_internal})`) this.emit('device-updated', matchedBLEDevice) @@ -490,42 +492,35 @@ export class SwitchBot extends EventEmitter { * Returns a BLE device if a good match is found */ private findMatchingBLEDevice(apiDeviceType: string, apiDeviceName: string): SwitchBotDevice | undefined { - // Get all BLE devices + // Only BLE-only devices without a MAC are candidates. A device with a MAC has the same id as + // its OpenAPI record, so if the id did not match, it is a different physical device. const bleDevices = this.deviceManager.list().filter((device) => { const info = device.getInfo() - return info.connectionTypes.includes('ble') && !info.connectionTypes.includes('api') + return info.connectionTypes.includes('ble') && !info.connectionTypes.includes('api') && !device.getMAC() }) - // Try exact device type match first - const typeMatches = bleDevices.filter(device => device.getDeviceType() === apiDeviceType) + // BLE and OpenAPI spell types differently ("Meter Plus" vs "MeterPlus"), so compare the + // device class each resolves to. + const apiClass = resolveDeviceClassName(apiDeviceType) + if (!apiClass) { + return undefined + } + const typeMatches = bleDevices.filter(device => resolveDeviceClassName(device.getDeviceType()) === apiClass) if (typeMatches.length === 1) { - // If only one device of this type, it's likely a match - this.logger.debug(`Found exact type match for ${apiDeviceType}`) + this.logger.debug(`Found type match for ${apiDeviceType}`) return typeMatches[0] } - if (typeMatches.length > 1) { - // If multiple devices of same type, try to match by name similarity - const nameMatch = typeMatches.find((device) => { - const devName = device.getName() - // Simple name comparison (could be enhanced) - return devName === apiDeviceName || devName.includes(apiDeviceName) || apiDeviceName.includes(devName) - }) - - if (nameMatch) { - this.logger.debug(`Found name match for ${apiDeviceName}`) - return nameMatch - } - - // No good name match, return the first one as fallback for single device - // (better to merge than to duplicate) - if (typeMatches.length === 1) { - return typeMatches[0] - } + // Several candidates: only merge on a name match rather than guess. + const nameMatch = typeMatches.find((device) => { + const devName = device.getName() + return devName === apiDeviceName || devName.includes(apiDeviceName) || apiDeviceName.includes(devName) + }) + if (nameMatch) { + this.logger.debug(`Found name match for ${apiDeviceName}`) } - - return undefined + return nameMatch } /** diff --git a/src/types/index.ts b/src/types/index.ts index 5bdd829c..4720aa5b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -109,6 +109,8 @@ export interface DeviceInfo { connectionTypes: ConnectionType[] /** Current active connection type */ activeConnection?: ConnectionType + /** OpenAPI deviceId, when it differs from `id` (a device found over BLE by peripheral id on macOS) */ + apiDeviceId?: string /** Hub device ID (if device is controlled through a hub) */ hubDeviceId?: string /** Battery level (0-100) */ diff --git a/src/utils/circuit-breaker.ts b/src/utils/circuit-breaker.ts index e10f4d18..12745db8 100644 --- a/src/utils/circuit-breaker.ts +++ b/src/utils/circuit-breaker.ts @@ -154,6 +154,8 @@ export class CircuitBreaker { this.state = CircuitBreakerState.HALF_OPEN this.halfOpenAttempts = 0 }, this.config.resetTimeoutMs) + // Do not keep the process alive just to move to HALF_OPEN + this.resetTimer.unref?.() } /** diff --git a/test/apis/ble-command-result.test.ts b/test/apis/ble-command-result.test.ts new file mode 100644 index 00000000..4dee20a7 --- /dev/null +++ b/test/apis/ble-command-result.test.ts @@ -0,0 +1,47 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * ble-command-result.test.ts: BLE command replies are judged by their status byte + */ + +import { Buffer } from 'node:buffer' + +import { describe, expect, it } from 'vitest' + +import { BLEConnection } from '../../src/ble.js' + +const MAC = 'aa:bb:cc:dd:ee:ff' + +function check(bytes: number[]) { + const connection = new BLEConnection({ noble: { peripherals: [] } }) + return () => (connection as any).validateCommandResult(Buffer.from(bytes), MAC) +} + +// The first byte of a reply is its status (SwitchBotAPI-BLE, and pySwitchbot's +// _check_command_result). The rest is payload, so its bytes must not be read as a status. +describe('validateCommandResult', () => { + it.each([ + [[0x01], 'OK'], + [[0x01, 0x07, 0x09], 'OK with payload bytes that look like error codes'], + [[0x05, 0x48, 0xC0], 'Bot in add-on mode'], + [[0x06], 'low battery'], + ])('accepts %j (%s)', (bytes) => { + expect(check(bytes)).not.toThrow() + }) + + it.each([ + [[0x02, 0x01], 'error'], + [[0x03, 0x01], 'busy'], + [[0x0B, 0x05], 'mesh device not found'], + ])('rejects %j (%s) even when the payload contains a success byte', (bytes) => { + expect(check(bytes)).toThrow('Unexpected BLE response') + }) + + it('reports password problems from the status byte', () => { + expect(check([0x07])).toThrow('password required') + expect(check([0x09])).toThrow('password incorrect') + }) + + it('rejects an empty reply', () => { + expect(check([])).toThrow('Empty BLE response') + }) +}) diff --git a/test/apis/discovery-merge.test.ts b/test/apis/discovery-merge.test.ts new file mode 100644 index 00000000..9966e94f --- /dev/null +++ b/test/apis/discovery-merge.test.ts @@ -0,0 +1,75 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * discovery-merge.test.ts: an OpenAPI device is merged into the BLE device it belongs to + */ + +import { describe, expect, it, vi } from 'vitest' + +import { SwitchBot } from '../../src/switchbot.js' + +async function discover(ble: { mac?: string, id: string, model: string, modelName: string }[], api: any[]) { + const switchbot = new SwitchBot({ enableBLE: false, token: 'token', secret: 'secret' }) + for (const b of ble) { + await (switchbot as any).handleBLEDiscovery({ + id: b.id, + address: b.mac ?? '', + isAddressable: !!b.mac, + rssi: -60, + serviceData: { model: b.model, modelName: b.modelName, battery: 90 }, + }) + } + const apiClient = switchbot.getAPIClient()! + vi.spyOn(apiClient, 'getDevices').mockResolvedValue({ deviceList: api, infraredRemoteList: [] } as any) + const sendCommand = vi.spyOn(apiClient, 'sendCommand').mockResolvedValue({ statusCode: 100, message: 'success', body: {} } as any) + const devices = await switchbot.discover({ scanBLE: false, fetchAPI: true }) + return { switchbot, devices, sendCommand } +} + +function apiDevice(deviceId: string, deviceType: string, deviceName: string) { + return { deviceId, deviceType, deviceName, enableCloudService: true, hubDeviceId: 'HUB', version: 'V1.0' } +} + +describe('bLE and OpenAPI device merging', () => { + // The BLE id is the MAC without colons, which is the OpenAPI deviceId. The BLE and OpenAPI + // type strings differ ("Meter Plus" vs "MeterPlus"), so matching on type alone missed, and the + // API record was then dropped because the id already existed. The device never got the API. + it('merges by device id even when the type strings differ', async () => { + const { devices } = await discover( + [{ mac: 'ea:55:25:51:b7:63', id: 'p1', model: 'i', modelName: 'Meter Plus' }], + [apiDevice('EA552551B763', 'MeterPlus', 'Office Meter')], + ) + expect(devices).toHaveLength(1) + expect(devices[0].getId()).toBe('EA552551B763') + expect(devices[0].getName()).toBe('Office Meter') + expect(devices[0].getInfo().connectionTypes).toEqual(['ble', 'api']) + }) + + // With one BLE meter in range and two meters in the cloud, the first cloud meter was merged + // into the BLE meter because it was the only BLE device of that type. + it('does not merge a different physical device of the same model', async () => { + const { devices } = await discover( + [{ mac: 'aa:aa:aa:aa:aa:02', id: 'p2', model: 'T', modelName: 'Meter' }], + [apiDevice('AAAAAAAAAA01', 'Meter', 'Bedroom'), apiDevice('AAAAAAAAAA02', 'Meter', 'Kitchen')], + ) + const byId = Object.fromEntries(devices.map(d => [d.getId(), d.getInfo()])) + expect(Object.keys(byId).sort()).toEqual(['AAAAAAAAAA01', 'AAAAAAAAAA02']) + expect(byId.AAAAAAAAAA01.name).toBe('Bedroom') + expect(byId.AAAAAAAAAA01.connectionTypes).toEqual(['api']) + expect(byId.AAAAAAAAAA02.name).toBe('Kitchen') + expect(byId.AAAAAAAAAA02.connectionTypes).toEqual(['ble', 'api']) + }) + + // On macOS a BLE device may have no MAC, so it is matched by type. API calls must then use the + // OpenAPI deviceId, not the BLE peripheral id. + it('uses the OpenAPI deviceId for API calls on a device matched without a MAC', async () => { + const { devices, sendCommand } = await discover( + [{ id: 'macos-peripheral-uuid', model: 'g', modelName: 'Plug Mini (US)' }], + [apiDevice('D83BDA1173CA', 'Plug Mini (US)', 'Lamp')], + ) + expect(devices).toHaveLength(1) + expect(devices[0].getInfo().connectionTypes).toEqual(['ble', 'api']) + ;(devices[0] as any).setPreferredConnection('api') + await (devices[0] as any).turnOn() + expect(sendCommand).toHaveBeenCalledWith('D83BDA1173CA', 'turnOn', undefined) + }) +}) diff --git a/test/devices/airpurifier-preset-mode.test.ts b/test/devices/airpurifier-preset-mode.test.ts index 40afcb66..ec0d117d 100644 --- a/test/devices/airpurifier-preset-mode.test.ts +++ b/test/devices/airpurifier-preset-mode.test.ts @@ -20,13 +20,21 @@ describe('air purifier preset modes', () => { const sendCommand = vi.fn().mockResolvedValue({ success: true }) ;(purifier as any).sendCommand = sendCommand - const modes = ['level_1', 'level_2', 'level_3', 'auto', 'sleep', 'pet'] as const - for (const mode of modes) { + // OpenAPI has no preset command: presets are `setMode` with {mode, fanGear} (SwitchBotAPI docs) + const modes = [ + ['level_1', { mode: 1, fanGear: 1 }], + ['level_2', { mode: 1, fanGear: 2 }], + ['level_3', { mode: 1, fanGear: 3 }], + ['auto', { mode: 2 }], + ['sleep', { mode: 3 }], + ['pet', { mode: 4 }], + ] as const + for (const [mode, apiParameter] of modes) { await purifier.setPresetMode(mode) expect(sendCommand).toHaveBeenLastCalledWith( expect.arrayContaining([0x57, 0x02, expect.any(Number)]), - 'setPresetMode', - mode, + 'setMode', + apiParameter, ) } }) diff --git a/test/devices/command-fallback.test.ts b/test/devices/command-fallback.test.ts new file mode 100644 index 00000000..f2510a5e --- /dev/null +++ b/test/devices/command-fallback.test.ts @@ -0,0 +1,81 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * command-fallback.test.ts: sendCommand falls back between BLE and OpenAPI + */ + +import { Buffer } from 'node:buffer' + +import { describe, expect, it, vi } from 'vitest' + +import { WoPlugMiniUS } from '../../src/devices/wo-plug-mini-us.js' + +function plug(options: { mac?: string, bleId?: string, bleFails?: boolean, intelligence?: boolean } = {}) { + const bleConnection: any = { + sendCommand: vi.fn(async () => { + if (options.bleFails) { + throw new Error('BLE out of range') + } + return Buffer.from([0x01, 0x80]) + }), + read: vi.fn(async () => Buffer.from([0x01])), + write: vi.fn(async () => {}), + } + const apiClient: any = { + sendCommand: vi.fn(async () => ({ statusCode: 100, message: 'success', body: {} })), + getStatus: vi.fn(async () => ({ power: 'on' })), + } + const device = new WoPlugMiniUS( + { id: 'D83BDA1173CA', name: 'Plug', deviceType: 'Plug Mini (US)', mac: options.mac, bleId: options.bleId, connectionTypes: ['ble', 'api'] }, + { bleConnection, apiClient, enableRetry: false, enableConnectionIntelligence: options.intelligence ?? true }, + ) + return { device, bleConnection, apiClient } +} + +describe('sendCommand fallback', () => { + // Connection intelligence is on by default. It used to set a fallback only when it picked a + // transport other than the preferred one, so a failed BLE command never reached the API. + it.each([true, false])('falls back to the API when BLE fails (connection intelligence %s)', async (intelligence) => { + const { device, bleConnection, apiClient } = plug({ mac: 'd8:3b:da:11:73:ca', bleFails: true, intelligence }) + await expect(device.turnOn()).resolves.toBe(true) + expect(bleConnection.sendCommand).toHaveBeenCalledOnce() + expect(apiClient.sendCommand).toHaveBeenCalledExactlyOnceWith('D83BDA1173CA', 'turnOn', undefined) + }) + + it('does not fall back when fallback is disabled', async () => { + const { device, apiClient } = plug({ mac: 'd8:3b:da:11:73:ca', bleFails: true }) + device.setFallbackEnabled(false) + await expect(device.turnOn()).resolves.toBe(false) + expect(apiClient.sendCommand).not.toHaveBeenCalled() + }) + + it('uses BLE only once when it succeeds', async () => { + const { device, bleConnection, apiClient } = plug({ mac: 'd8:3b:da:11:73:ca' }) + await expect(device.turnOn()).resolves.toBe(true) + expect(bleConnection.sendCommand).toHaveBeenCalledOnce() + expect(apiClient.sendCommand).not.toHaveBeenCalled() + }) +}) + +describe('bLE status reads without a MAC address', () => { + // On macOS a device has a peripheral id but an empty MAC. Commands already used `id:`; + // status reads passed the empty string to the connection instead. + it('reads by advertisement id when the MAC is empty', async () => { + const { device, bleConnection } = plug({ mac: '', bleId: 'peripheral-uuid' }) + await (device as any).getBLEStatus() + expect(bleConnection.read).toHaveBeenCalledWith('id:peripheral-uuid') + }) +}) + +describe('circuit breaker', () => { + // HALF_OPEN allows one trial request. Commands only checked for OPEN, so while the trial was + // in use every other command still went to the failing transport. + it('rejects commands while HALF_OPEN and its trial request is in use', async () => { + const { device, apiClient } = plug({ intelligence: false }) + const breaker = device.getCircuitBreakerAPI() as any + breaker.state = 'HALF_OPEN' + breaker.halfOpenAttempts = 1 + const result = await (device as any).sendAPICommand('turnOn') + expect(result).toMatchObject({ success: false, error: 'API circuit breaker is HALF_OPEN (too many failures)' }) + expect(apiClient.sendCommand).not.toHaveBeenCalled() + }) +}) diff --git a/test/devices/openapi-commands.test.ts b/test/devices/openapi-commands.test.ts new file mode 100644 index 00000000..f2a1a06e --- /dev/null +++ b/test/devices/openapi-commands.test.ts @@ -0,0 +1,100 @@ +/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved. + * + * openapi-commands.test.ts: API-only devices send the commands the SwitchBot OpenAPI documents + */ + +import { describe, expect, it, vi } from 'vitest' + +import { resolveDeviceClassName } from '../../src/settings.js' +import { SwitchBot } from '../../src/switchbot.js' + +// Create an API-only device through SwitchBot, the way discovery does, and record API commands. +async function apiDevice(deviceType: string) { + const switchbot = new SwitchBot({ enableBLE: false, token: 'token', secret: 'secret', enableRetry: false }) + const apiClient = switchbot.getAPIClient()! + vi.spyOn(apiClient, 'getDevices').mockResolvedValue({ + deviceList: [{ deviceId: 'DEVICE', deviceName: 'Test', deviceType, enableCloudService: true, hubDeviceId: 'HUB' }], + infraredRemoteList: [], + } as any) + const sendCommand = vi.spyOn(apiClient, 'sendCommand').mockResolvedValue({ statusCode: 100, message: 'success', body: {} } as any) + const [device] = await switchbot.discover({ scanBLE: false, fetchAPI: true }) + expect(device, deviceType).toBeDefined() + return { device: device as any, calls: () => sendCommand.mock.calls.map(([, command, parameter]) => [command, parameter]) } +} + +describe('openAPI commands', () => { + // Blind Tilt previously sent turnOn/turnOff and setPosition with {position}, none of which exist. + it('blind Tilt uses fullyOpen, closeUp, closeDown and direction;position', async () => { + const { device, calls } = await apiDevice('Blind Tilt') + await device.open() + await device.closeUp() + await device.closeDown() + await device.close() + await device.setPosition(0) + await device.setPosition(25) + await device.setPosition(50) + await device.setPosition(76) + await device.setPosition(100) + expect(calls()).toEqual([ + ['fullyOpen', undefined], + ['closeUp', undefined], + ['closeDown', undefined], + ['closeUp', undefined], + ['setPosition', 'down;0'], + ['setPosition', 'down;50'], + ['setPosition', 'down;100'], + ['setPosition', 'up;48'], + ['setPosition', 'up;0'], + ]) + }) + + // OpenAPI air purifiers take setMode {"mode", "fanGear"}; there is no setFanSpeed or setPresetMode. + it.each(['Air Purifier PM2.5', 'Air Purifier Table'])('%s uses setMode with mode and fanGear', async (type) => { + const { device, calls } = await apiDevice(type) + await device.setMode('auto') + await device.setMode('manual') + await device.setMode('sleep') + await device.setFanSpeed(2) + await device.setFanSpeed(4) + await device.setPresetMode('pet') + expect(calls()).toEqual([ + ['setMode', { mode: 2 }], + ['setMode', { mode: 1 }], + ['setMode', { mode: 3 }], + ['setMode', { mode: 1, fanGear: 2 }], + ['setMode', { mode: 1, fanGear: 3 }], + ['setMode', { mode: 4 }], + ]) + }) + + it('rGBIC Neon Wire Rope Light sends setColor as "r:g:b"', async () => { + const { device, calls } = await apiDevice('RGBIC Neon Wire Rope Light') + await device.setColor(255, 128, 0) + expect(calls()).toEqual([['setColor', '255:128:0']]) + }) + + it('color Bulb sends setColorTemperature in kelvin', async () => { + const { device, calls } = await apiDevice('Color Bulb') + await device.setColorTemp(2700, 6500, 4000) + expect(calls()).toEqual([['setColorTemperature', 4000]]) + }) + + it.each([ + ['Robot Vacuum Cleaner S1', 'start', undefined], + ['Robot Vacuum Cleaner S1 Plus', 'start', undefined], + ['K10+', 'start', undefined], + ['Robot Vacuum Cleaner S10', 'startClean', { action: 'sweep', param: { fanLevel: 1, waterLevel: 1, times: 1 } }], + ['Robot Vacuum Cleaner K10+ Pro Combo', 'startClean', { action: 'sweep', param: { fanLevel: 1, times: 1 } }], + ['Robot Vacuum Cleaner K20 Plus Pro', 'startClean', { action: 'sweep', param: { fanLevel: 1, times: 1 } }], + ])('%s starts cleaning with %s', async (type, command, parameter) => { + const { device, calls } = await apiDevice(type) + await device.cleanUp(1) + await device.returnToDock(1) + expect(calls()).toEqual([[command, parameter], ['dock', undefined]]) + }) + + it('maps every OpenAPI vacuum type to a class', () => { + expect(resolveDeviceClassName('Robot Vacuum Cleaner K20 Plus Pro')).toBe('WoVacuumK20') + expect(resolveDeviceClassName('Robot Vacuum Cleaner S1')).toBe('WoVacuum') + }) +}) diff --git a/test/devices/vacuum-commands.test.ts b/test/devices/vacuum-commands.test.ts index e1015027..d0ae7c42 100644 --- a/test/devices/vacuum-commands.test.ts +++ b/test/devices/vacuum-commands.test.ts @@ -36,7 +36,7 @@ describe('vacuum commands', () => { expect(sendCommand).toHaveBeenCalledWith( DEVICE_COMMANDS.VACUUM.CLEAN_UP[1], 'start', - { protocolVersion: 1 }, + undefined, ) }) @@ -51,7 +51,7 @@ describe('vacuum commands', () => { expect(sendCommand).toHaveBeenCalledWith( DEVICE_COMMANDS.VACUUM.CLEAN_UP[2], 'start', - { protocolVersion: 2 }, + undefined, ) }) @@ -66,7 +66,6 @@ describe('vacuum commands', () => { expect(sendCommand).toHaveBeenCalledWith( DEVICE_COMMANDS.VACUUM.RETURN_TO_DOCK[1], 'dock', - { protocolVersion: 1 }, ) }) diff --git a/test/types/model-identities.test.ts b/test/types/model-identities.test.ts index 59854b01..66ff5170 100644 --- a/test/types/model-identities.test.ts +++ b/test/types/model-identities.test.ts @@ -104,3 +104,49 @@ describe('deprecated enum aliases', () => { expect(SwitchBotBLEModel.PlugMiniJP).toBe('j') }) }) + +// Codes follow pySwitchbot's SUPPORTED_TYPES. Several used to map to a different product +// (e.g. 'y' was read as a leak detector, 0x0D as a relay switch), and others were not +// recognised at all, so those devices were ignored or mislabelled over BLE. +describe('bLE model codes match pySwitchbot', () => { + it.each([ + ['4', 'Meter Pro'], + ['\x14', 'Meter Pro'], + [';', 'Relay Switch 1'], + ['<', 'Relay Switch 1PM'], + ['=', 'Relay Switch 2PM'], + ['>', 'Garage Door Opener'], + ['?', 'Plug Mini (EU)'], + ['&', 'Water Leak Detector'], + ['\x06', 'Water Leak Detector'], + ['y', 'Keypad'], + ['-', 'Lock Lite'], + ['\x0D', 'Lock Lite'], + ['$', 'Lock Pro'], + ['\x04', 'Lock Pro'], + ['%', 'HubMini Matter'], + ['\x05', 'HubMini Matter'], + ['\x0C', 'Roller Shade'], + ['#', 'Evaporative Humidifier'], + ['*', 'Air Purifier'], + ['7', 'Air Purifier Table'], + ['v', 'Hub 2'], + ['~', 'Circulator Fan'], + ['b', 'Remote'], + ['z', 'Robot Vacuum Cleaner S10'], + ['}', 'Robot Vacuum Cleaner K10+'], + ['.', 'Robot Vacuum Cleaner K20'], + ])('maps %j to %s', (code, displayName) => { + expect(DEVICE_MODEL_MAP[code]).toBe(displayName) + expect(resolveDeviceClassName(displayName), displayName).toBeDefined() + }) + + it('does not label a Universal Remote (0x07) as a humidifier', () => { + expect(DEVICE_MODEL_MAP['\x07']).toBeUndefined() + }) + + it('reads relay state from the Relay Switch codes, not from Lock Lite', () => { + expect(parse(';'.charCodeAt(0), [0x03, 0x64, 0x00])?.state).toBe(true) + expect(parse(0x0D, [0x03, 0x64, 0x00])?.state).toBeUndefined() + }) +})