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
10 changes: 10 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ This library tracks pySwitchbot. To port a release:
| `switchbot/adv_parsers/*.py` (passive status) | `src/ble.ts` `BLEScanner.parseServiceData` |
| `switchbot/devices/*.py` (commands, active status) | `src/devices/wo-*.ts` |

Newer products have no model byte: pySwitchbot identifies them by the 4-byte product ID at the end
of the service data (its multi-byte `SUPPORTED_TYPES` keys). node-switchbot does the same with
`PRODUCT_ID_MODEL_MAP` and `findProductId` in `src/settings.ts`. List a product there only once
its BLE status and commands are implemented: recognising it over BLE makes its status BLE-first,
so an unfinished one would replace good cloud data.

Locks use pySwitchbot's per-model command tables (`LOCK_COMMANDS`, chosen by the lock model from
the device type) and read their state from the manufacturer data in advertisements
(`src/utils/lock-advertisement.ts`).

`DEVICE_MODEL_MAP` mirrors pySwitchbot's single-byte `SUPPORTED_TYPES` codes. A few extra codes
(Hub 2 `0x01`, Hub 3 `0x02`, `0x09`, `0x0E`, `0x10`, `0x11`, `k`, `p`) come from earlier
node-switchbot releases and have no pySwitchbot equivalent. `SwitchBotBLEModel` in
Expand Down
42 changes: 24 additions & 18 deletions src/ble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { createCipheriv, createDecipheriv } from 'node:crypto'
import { EventEmitter } from 'node:events'

import { BLENotAvailableError, CommandFailedError, DeviceNotFoundError } from './errors.js'
import { BLE_COMMAND_TIMEOUT, BLE_CONNECT_TIMEOUT, BLE_NOTIFY_CHARACTERISTIC_UUID, BLE_SCAN_TIMEOUT, BLE_SERVICE_UUID, BLE_WRITE_CHARACTERISTIC_UUID, DEVICE_MODEL_MAP } from './settings.js'
import { extractMacFromManufacturerData, isPassiveSensorModel, isValidMAC, Logger, macToDeviceId, mergeAdvertisement, normalizeMAC, parseSensorAdvertisement, switchBotManufacturerPayload, withTimeout } from './utils/index.js'
import { BLE_COMMAND_TIMEOUT, BLE_CONNECT_TIMEOUT, BLE_NOTIFY_CHARACTERISTIC_UUID, BLE_SCAN_TIMEOUT, BLE_SERVICE_UUID, BLE_WRITE_CHARACTERISTIC_UUID, DEVICE_MODEL_MAP, findProductId, PRODUCT_ID_MODEL_MAP } from './settings.js'
import { extractMacFromManufacturerData, isPassiveSensorModel, isValidMAC, LOCK_FAMILY_BY_DEVICE_TYPE, Logger, macToDeviceId, mergeAdvertisement, normalizeMAC, parseLockAdvertisement, parseSensorAdvertisement, switchBotManufacturerPayload, withTimeout } from './utils/index.js'
// Move RegExp to module scope to avoid re-compilation
const CHARACTERISTIC_REGEX = /characteristic/i
const UUID_DASH_REGEX = /-/g
Expand Down Expand Up @@ -282,11 +282,20 @@ export class BLEScanner extends EventEmitter {
}

try {
const model = String.fromCharCode(data[0] & 0x7F)
const modelName = DEVICE_MODEL_MAP[model]
// The model byte first; then the 4-byte product ID at the end of the service data, which
// newer products use instead (pySwitchbot _find_model_from_service_data_suffix)
let model = String.fromCharCode(data[0] & 0x7F)
let modelName = DEVICE_MODEL_MAP[model]
if (!modelName) {
const productId = findProductId(data)
if (productId) {
model = productId
modelName = PRODUCT_ID_MODEL_MAP[productId]
}
}

if (!modelName) {
this.logger.debug(`Unknown device model: ${model} (0x${data[0].toString(16)})`)
this.logger.debug(`Unknown device model: 0x${data[0].toString(16)}`)
return null
}

Expand Down Expand Up @@ -320,19 +329,16 @@ export class BLEScanner extends EventEmitter {
}
}

if ((model === 'o' || model === 'O' || model === '\x11') && data.length > 1) {
// Lock / Lock Pro
const lockStatus = data[1] & 0x0F
serviceData.status = lockStatus
serviceData.doorOpen = (data[1] & 0x10) !== 0
serviceData.calibration = (data[1] & 0x80) !== 0
serviceData.lockState = lockStatus === 1
? 'unlocked'
: lockStatus === 2
? 'jammed'
: 'locked'
if (data.length > 3) {
serviceData.sequenceNumber = data[3]
// Locks: state from the manufacturer data, in the layout of the lock family (pySwitchbot)
const lockFamily = LOCK_FAMILY_BY_DEVICE_TYPE[modelName]
if (lockFamily) {
const lock = parseLockAdvertisement(lockFamily, data, manufacturerPayload)
if (lock) {
for (const [key, value] of Object.entries(lock)) {
if (value !== undefined) {
serviceData[key] = value
}
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/devices/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export { WoGarageDoorOpener } from './wo-garage-door-opener.js'
export { WoHand } from './wo-hand.js'
export { WoHub2 } from './wo-hub2.js'
export { WoHub3 } from './wo-hub3.js'
export { WoHub4 } from './wo-hub4.js'
export { WoHubMiniMatter } from './wo-hubmini-matter.js'
export { WoHumi2 } from './wo-humi2.js'
export { WoHumi } from './wo-humi.js'
Expand All @@ -33,8 +34,11 @@ export { WoKeypadVision } from './wo-keypad-vision.js'
export { WoKeypad } from './wo-keypad.js'
export { WoLeak } from './wo-leak.js'
export { WoSmartLockLite } from './wo-lock-lite.js'
export { WoSmartLockNano } from './wo-lock-nano.js'
export { WoSmartLockProWiFi } from './wo-lock-pro-wifi.js'
export { WoSmartLockPro } from './wo-lock-pro.js'
export { buildQuickKeyCommand, parseQuickKeyConfig, QuickKeyFunction, WoSmartLockUltra } from './wo-lock-ultra.js'
export type { QuickKeySettings, QuickKeyUpdate } from './wo-lock-ultra.js'
export { WoSmartLockVisionPro } from './wo-lock-vision-pro.js'
export { WoSmartLockVision } from './wo-lock-vision.js'
export { WoSmartLock } from './wo-lock.js'
Expand Down
22 changes: 22 additions & 0 deletions src/devices/lock-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved.
*
* devices/lock-model.ts: Which lock model a lock device is, for its BLE commands
*/

import type { LockCommandSet, LockModel } from '../settings.js'

import { LOCK_COMMANDS, LOCK_MODEL_BY_DEVICE_TYPE } from '../settings.js'

/**
* The lock model for a device type (a node-switchbot or OpenAPI device type name), or `fallback`
* when the device type does not name a lock model.
*/
export function resolveLockModel(deviceType: string | undefined, fallback: LockModel): LockModel {
const key = (deviceType ?? '').trim().toLowerCase().replace(/\s+/g, ' ')
return LOCK_MODEL_BY_DEVICE_TYPE[key] ?? fallback
}

/** The BLE commands for a lock model */
export function lockCommandsFor(model: LockModel): LockCommandSet {
return LOCK_COMMANDS[model]
}
33 changes: 33 additions & 0 deletions src/devices/wo-hand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* devices/wo-hand.ts: SwitchBot v4.0.0 - Bot (WoHand) Device
*/

import type { Buffer } from 'node:buffer'

import type { OpenAPIClient } from '../api.js'
import type { BLEConnection } from '../ble.js'
import type { BotCommands, BotStatus } from '../types/device.js'
Expand Down Expand Up @@ -147,6 +149,37 @@ export class WoHand extends DeviceOverrideStateDuringConnection implements BotCo
)
}

/**
* Read the Bot's mode settings from its basic info (pySwitchbot `get_basic_info`). BLE only.
*/
async getModeSettings(): Promise<{ strength: number, switchMode: boolean, inverseDirection: boolean }> {
const result = await this.sendBLECommand([0x57, 0x02], false, { validateResponse: false })
const reply: Buffer | undefined = result.data
if (!result.success || !reply || reply[0] !== 0x01 || reply.length < 10) {
throw new Error(`Unable to get the Bot's mode settings${result.error ? `: ${result.error}` : ''}`)
}
return {
strength: reply[3],
switchMode: (reply[9] & 0x10) !== 0,
inverseDirection: (reply[9] & 0x01) !== 0,
}
}

/**
* Set the Bot's inverse direction, keeping its other mode settings (pySwitchbot
* `set_inverse_direction`). BLE only.
*
* Command: `57 03 <strength> <mode>`, where the mode byte's high nibble is the switch mode and
* its low nibble the inverse direction.
*/
async setInverseDirection(inverse: boolean): Promise<boolean> {
const { strength, switchMode } = await this.getModeSettings()
const modeByte = (switchMode ? 0x10 : 0x00) | (inverse ? 0x01 : 0x00)
const result = await this.sendBLECommand([0x57, 0x03, strength, modeByte], false, { validateResponse: false })
const reply: Buffer | undefined = result.data
return result.success && !!reply && reply[0] === 0x01
}

/**
* Set Bot long-press duration (1-255 deciseconds)
*/
Expand Down
11 changes: 11 additions & 0 deletions src/devices/wo-hub4.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved.
*
* devices/wo-hub4.ts: SwitchBot Hub 4
*/

import { WoHub3 } from './wo-hub3.js'

/**
* Hub 4. pySwitchbot parses it like the Hub 3, with a 20-step light level instead of lux.
*/
export class WoHub4 extends WoHub3 {}
6 changes: 5 additions & 1 deletion src/devices/wo-lock-lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
* devices/wo-lock-lite.ts: SwitchBot v4.0.0 - Smart Lock Lite Device
*/

import type { LockModel } from '../settings.js'

import { WoSmartLock } from './wo-lock.js'

/**
* Smart Lock Lite Device
*/
export class WoSmartLockLite extends WoSmartLock {}
export class WoSmartLockLite extends WoSmartLock {
protected override readonly defaultLockModel: LockModel = 'Lock Lite'
}
15 changes: 15 additions & 0 deletions src/devices/wo-lock-nano.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/* Copyright(C) 2024-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved.
*
* devices/wo-lock-nano.ts: SwitchBot Lock Nano
*/

import type { LockModel } from '../settings.js'

import { WoSmartLockPro } from './wo-lock-pro.js'

/**
* Lock Nano: Lock Pro commands with its own lock model byte (pySwitchbot)
*/
export class WoSmartLockNano extends WoSmartLockPro {
protected override readonly defaultLockModel: LockModel = 'Lock Nano'
}
6 changes: 5 additions & 1 deletion src/devices/wo-lock-pro-wifi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
* devices/wo-lock-pro-wifi.ts: SwitchBot v4.0.0 - Smart Lock Pro WiFi Device
*/

import type { LockModel } from '../settings.js'

import { WoSmartLockPro } from './wo-lock-pro.js'

/**
* Smart Lock Pro WiFi Device (with WiFi connectivity and unlatch support)
*/
export class WoSmartLockProWiFi extends WoSmartLockPro {}
export class WoSmartLockProWiFi extends WoSmartLockPro {
protected override readonly defaultLockModel: LockModel = 'Lock Pro WiFi'
}
31 changes: 25 additions & 6 deletions src/devices/wo-lock-pro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@

import type { Buffer } from 'node:buffer'

import type { LockCommandSet, LockModel } from '../settings.js'
import type { LockCommands, LockStatus } from '../types/device.js'

import { WoSmartLockProCommands } from '../settings.js'
import { lockCommandsFor, resolveLockModel } from './lock-model.js'
import { SequenceDevice } from './sequence-device.js'

/**
Expand All @@ -16,6 +17,23 @@ import { SequenceDevice } from './sequence-device.js'
export class WoSmartLockPro extends SequenceDevice implements LockCommands {
private lockNotificationHandlers = new Set<(payload: Buffer) => void>()

/** The lock model used when the device type does not name one */
protected readonly defaultLockModel: LockModel = 'Lock Pro'

/** This lock's model, which selects its BLE commands (pySwitchbot's per-model tables) */
get lockModel(): LockModel {
return resolveLockModel(this.info.deviceType, this.defaultLockModel)
}

protected get lockCommands(): LockCommandSet {
return lockCommandsFor(this.lockModel)
}

/** Lock state comes from the manufacturer data in advertisements, as in pySwitchbot */
protected override get readsFromAdvertisement(): boolean {
return true
}

/**
* Lock the lock
*/
Expand All @@ -30,7 +48,7 @@ export class WoSmartLockPro extends SequenceDevice implements LockCommands {
}

const result = await this.sendCommand(
WoSmartLockProCommands.LOCK,
this.lockCommands.lock,
'lock',
)
return result.success
Expand All @@ -50,7 +68,7 @@ export class WoSmartLockPro extends SequenceDevice implements LockCommands {
}

const result = await this.sendCommand(
WoSmartLockProCommands.UNLOCK,
this.lockCommands.unlock,
'unlock',
)
return result.success
Expand All @@ -61,19 +79,20 @@ export class WoSmartLockPro extends SequenceDevice implements LockCommands {
*/
async unlockWithoutUnlatch(): Promise<boolean> {
const result = await this.sendCommand(
WoSmartLockProCommands.UNLOCK,
this.lockCommands.unlockWithoutUnlatch,
'unlock',
'withoutUnlatch',
)
return result.success
}

/**
* Unlatch the lock (Lock Pro only)
* Unlatch the lock. An unlock also unlatches when the lock's unlatch feature is on, so this
* sends the unlock command (pySwitchbot has no separate unlatch command).
*/
async unlatch(): Promise<boolean> {
const result = await this.sendCommand(
WoSmartLockProCommands.UNLATCH,
this.lockCommands.unlock,
'unlock',
)
return result.success
Expand Down
Loading
Loading