Skip to content

Commit cbf3c96

Browse files
committed
Phase 2
1 parent 265c20a commit cbf3c96

12 files changed

Lines changed: 452 additions & 47 deletions

File tree

src/ble.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ export class BLEConnection {
457457
private disconnectTimers: Map<string, NodeJS.Timeout> = new Map()
458458
private operationLocks: Map<string, Promise<void>> = new Map()
459459
private encryptionConfig: Map<string, { key: Buffer, iv: Buffer, mode: 'ctr' | 'gcm' }> = new Map()
460+
private notificationHandlers: Map<string, Set<(payload: Buffer) => void>> = new Map()
460461
private persistentConnectionMs = 8500
461462
private noblePromise: Promise<any> | null = null
462463

@@ -676,6 +677,62 @@ export class BLEConnection {
676677
})
677678
}
678679

680+
async subscribeNotifications(mac: string, handler: (payload: Buffer) => void): Promise<void> {
681+
const normalizedMac = normalizeMAC(mac)
682+
683+
if (!this.connections.has(normalizedMac)) {
684+
await this.connect(normalizedMac)
685+
}
686+
687+
const chars = this.characteristics.get(normalizedMac)
688+
if (!chars?.notify) {
689+
throw new CommandFailedError(`Notify characteristic not available for ${normalizedMac}`, 'ble')
690+
}
691+
692+
if (!this.notificationHandlers.has(normalizedMac)) {
693+
this.notificationHandlers.set(normalizedMac, new Set())
694+
695+
if (typeof chars.notify.on === 'function') {
696+
chars.notify.on('data', (payload: Buffer) => {
697+
const handlers = this.notificationHandlers.get(normalizedMac)
698+
if (!handlers) {
699+
return
700+
}
701+
for (const listener of handlers) {
702+
listener(payload)
703+
}
704+
})
705+
}
706+
}
707+
708+
this.notificationHandlers.get(normalizedMac)!.add(handler)
709+
710+
if (typeof chars.notify.subscribe === 'function') {
711+
await new Promise<void>((resolve, reject) => {
712+
chars.notify.subscribe((error: Error) => {
713+
if (error) {
714+
reject(error)
715+
} else {
716+
resolve()
717+
}
718+
})
719+
})
720+
}
721+
}
722+
723+
unsubscribeNotifications(mac: string, handler: (payload: Buffer) => void): void {
724+
const normalizedMac = normalizeMAC(mac)
725+
const handlers = this.notificationHandlers.get(normalizedMac)
726+
if (!handlers) {
727+
return
728+
}
729+
730+
handlers.delete(handler)
731+
if (handlers.size === 0) {
732+
this.notificationHandlers.delete(normalizedMac)
733+
}
734+
}
735+
679736
/**
680737
* Connect to a device
681738
*/
@@ -788,6 +845,7 @@ export class BLEConnection {
788845
const peripheral = this.connections.get(normalizedMac)
789846
this.clearDisconnectTimer(normalizedMac)
790847
this.clearEncryption(normalizedMac)
848+
this.notificationHandlers.delete(normalizedMac)
791849

792850
if (!peripheral) {
793851
return

src/devices/base.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
240240
const startTime = Date.now()
241241
const mac = this.info.mac ?? `id:${this.info.bleId}`
242242

243+
let response: Buffer | undefined
243244
if (this.info.encryptionKey && this.info.encryptionIV && this.bleConnection?.setEncryption) {
244245
this.bleConnection.setEncryption(
245246
mac,
@@ -250,7 +251,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
250251
}
251252

252253
if (this.bleConnection?.sendCommand) {
253-
await this.bleConnection.sendCommand(mac, buffer, {
254+
response = await this.bleConnection.sendCommand(mac, buffer, {
254255
expectResponse: true,
255256
validateResponse: true,
256257
responseTimeoutMs: 1200,
@@ -275,6 +276,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
275276
return {
276277
success: true,
277278
connectionType: 'ble' as const,
279+
data: response,
278280
}
279281
}
280282

src/devices/wo-bulb.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@ import { SwitchBotDevice } from './base.js'
1313
* Color Bulb Device
1414
*/
1515
export class WoBulb extends SwitchBotDevice implements BulbCommands {
16+
private static readonly EFFECTS: Record<string, number> = {
17+
christmas: 0x00,
18+
candle: 0x01,
19+
halloween: 0x02,
20+
sunset: 0x03,
21+
party: 0x04,
22+
reading: 0x05,
23+
rainbow: 0x06,
24+
forest: 0x07,
25+
ocean: 0x08,
26+
spring: 0x09,
27+
summer: 0x0A,
28+
autumn: 0x0B,
29+
winter: 0x0C,
30+
aurora: 0x0D,
31+
meteor: 0x0E,
32+
firework: 0x0F,
33+
breathe: 0x10,
34+
pulse: 0x11,
35+
flicker: 0x12,
36+
pet: 0x13,
37+
}
38+
1639
/**
1740
* Turn on
1841
*/
@@ -87,6 +110,26 @@ export class WoBulb extends SwitchBotDevice implements BulbCommands {
87110
return result.success
88111
}
89112

113+
/**
114+
* Set preset light effect
115+
*/
116+
async setEffect(effectName: string, speed = 100): Promise<boolean> {
117+
const effectId = WoBulb.EFFECTS[effectName.toLowerCase()]
118+
if (effectId === undefined) {
119+
throw new Error(`Unsupported effect: ${effectName}`)
120+
}
121+
122+
const effectSpeed = clamp(speed, 1, 100)
123+
const bleCommand = [...DEVICE_COMMANDS.BULB.BASE, effectId, 0xFF, effectSpeed, 0x00, 0x00, 0x00]
124+
125+
const result = await this.sendCommand(
126+
bleCommand,
127+
'setEffect',
128+
`${effectName}:${effectSpeed}`,
129+
)
130+
return result.success
131+
}
132+
90133
/**
91134
* Get device status
92135
*/

src/devices/wo-hand.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,54 @@ export class WoHand extends SwitchBotDevice implements BotCommands {
9999
return result.success
100100
}
101101

102+
/**
103+
* Set Bot mode
104+
*/
105+
async setMode(mode: 'press' | 'switch'): Promise<boolean> {
106+
const modeByte = mode === 'switch' ? 0x01 : 0x00
107+
const result = await this.sendCommand(
108+
[...DEVICE_COMMANDS.BOT.SET_MODE, modeByte],
109+
'setMode',
110+
mode,
111+
)
112+
return result.success
113+
}
114+
115+
/**
116+
* Set Bot long-press duration (1-255 deciseconds)
117+
*/
118+
async setLongPress(duration: number): Promise<boolean> {
119+
const clampedDuration = Math.min(255, Math.max(1, Math.trunc(duration)))
120+
const result = await this.sendCommand(
121+
[...DEVICE_COMMANDS.BOT.SET_LONG_PRESS, clampedDuration],
122+
'setLongPress',
123+
clampedDuration,
124+
)
125+
return result.success
126+
}
127+
128+
/**
129+
* Raise Bot arm
130+
*/
131+
async handUp(): Promise<boolean> {
132+
const result = await this.sendCommand(
133+
DEVICE_COMMANDS.BOT.UP,
134+
'turnOff',
135+
)
136+
return result.success
137+
}
138+
139+
/**
140+
* Lower Bot arm
141+
*/
142+
async handDown(): Promise<boolean> {
143+
const result = await this.sendCommand(
144+
DEVICE_COMMANDS.BOT.DOWN,
145+
'turnOn',
146+
)
147+
return result.success
148+
}
149+
102150
/**
103151
* Execute password-protected Bot command
104152
* @param action - Bot action to perform

src/devices/wo-lock-pro.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* devices/wo-lock-pro.ts: SwitchBot v4.0.0 - Smart Lock Pro Device
44
*/
55

6+
import type { Buffer } from 'node:buffer'
7+
68
import type { LockCommands, LockStatus } from '../types/device.js'
79

810
import { WoSmartLockProCommands } from '../settings.js'
@@ -12,10 +14,21 @@ import { SwitchBotDevice } from './base.js'
1214
* Smart Lock Pro Device (with unlatch support)
1315
*/
1416
export class WoSmartLockPro extends SwitchBotDevice implements LockCommands {
17+
private lockNotificationHandlers = new Set<(payload: Buffer) => void>()
18+
1519
/**
1620
* Lock the lock
1721
*/
1822
async lock(): Promise<boolean> {
23+
try {
24+
const status = await this.getStatus()
25+
if (status.lockState === 'locked') {
26+
return true
27+
}
28+
} catch {
29+
// Best effort status check before command
30+
}
31+
1932
const result = await this.sendCommand(
2033
WoSmartLockProCommands.LOCK,
2134
'lock',
@@ -27,13 +40,34 @@ export class WoSmartLockPro extends SwitchBotDevice implements LockCommands {
2740
* Unlock the lock
2841
*/
2942
async unlock(): Promise<boolean> {
43+
try {
44+
const status = await this.getStatus()
45+
if (status.lockState === 'unlocked') {
46+
return true
47+
}
48+
} catch {
49+
// Best effort status check before command
50+
}
51+
3052
const result = await this.sendCommand(
3153
WoSmartLockProCommands.UNLOCK,
3254
'unlock',
3355
)
3456
return result.success
3557
}
3658

59+
/**
60+
* Unlock without unlatching the door
61+
*/
62+
async unlockWithoutUnlatch(): Promise<boolean> {
63+
const result = await this.sendCommand(
64+
WoSmartLockProCommands.UNLOCK,
65+
'unlock',
66+
'withoutUnlatch',
67+
)
68+
return result.success
69+
}
70+
3771
/**
3872
* Unlatch the lock (Lock Pro only)
3973
*/
@@ -45,6 +79,48 @@ export class WoSmartLockPro extends SwitchBotDevice implements LockCommands {
4579
return result.success
4680
}
4781

82+
async getLockInfo(): Promise<Record<string, unknown>> {
83+
if (this.hasAPI()) {
84+
const status = await this.getAPIStatus()
85+
return {
86+
lockState: status.lockState,
87+
doorState: status.doorState,
88+
calibrate: status.calibrate,
89+
battery: status.battery,
90+
version: status.version,
91+
}
92+
}
93+
94+
const ble = await this.getBLEStatus().catch(() => this.normalizeBLEStatusData(undefined))
95+
return {
96+
lockState: ble.lockState,
97+
doorOpen: ble.doorOpen,
98+
calibration: ble.calibration,
99+
battery: ble.battery,
100+
sequenceNumber: ble.sequenceNumber,
101+
}
102+
}
103+
104+
async onLockNotification(handler: (payload: Buffer) => void): Promise<void> {
105+
if (!this.hasBLE()) {
106+
throw new Error('BLE not available for lock notifications')
107+
}
108+
109+
const mac = this.info.mac ?? `id:${this.info.bleId}`
110+
await this.bleConnection!.subscribeNotifications(mac, handler)
111+
this.lockNotificationHandlers.add(handler)
112+
}
113+
114+
offLockNotification(handler: (payload: Buffer) => void): void {
115+
if (!this.hasBLE()) {
116+
return
117+
}
118+
119+
const mac = this.info.mac ?? `id:${this.info.bleId}`
120+
this.bleConnection?.unsubscribeNotifications(mac, handler)
121+
this.lockNotificationHandlers.delete(handler)
122+
}
123+
48124
/**
49125
* Get device status
50126
*/

0 commit comments

Comments
 (0)