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() + }) +})