Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 10 additions & 5 deletions src/ble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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')
}
Expand Down
36 changes: 36 additions & 0 deletions src/devices/air-purifier-api.ts
Original file line number Diff line number Diff line change
@@ -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
}
50 changes: 28 additions & 22 deletions src/devices/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { DEVICE_COMMANDS } from '../settings.js'
import {
CircuitBreaker,

CircuitBreakerState,
ConnectionTracker,

FallbackHandlerManager,
Expand Down Expand Up @@ -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)`,
}
}

Expand All @@ -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) {
Expand Down Expand Up @@ -501,22 +498,20 @@ 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)`,
}
}

const executeCommand = async () => {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -723,6 +721,14 @@ export abstract class SwitchBotDevice extends EventEmitter {
}
}

/**
* The address BLEConnection uses for this device: its MAC, or `id:<bleId>` 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<string, unknown> {
if (data && typeof data === 'object' && !Buffer.isBuffer(data)) {
return data as Record<string, unknown>
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 2 additions & 3 deletions src/devices/device-override-state-during-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
13 changes: 7 additions & 6 deletions src/devices/wo-air-purifier-pm25.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -90,13 +91,13 @@ export class WoAirPurifierPM25 extends SwitchBotDevice {
const modeMap: Record<string, number> = { 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')
}
Expand All @@ -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')
Expand All @@ -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')
Expand Down
7 changes: 4 additions & 3 deletions src/devices/wo-air-purifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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))
}

/**
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
}
Loading
Loading