Skip to content

Commit 0d2e215

Browse files
authored
Fix macOS BLE Bot control (#334)
## Summary - Allows macOS BLE-only SwitchBot Bot devices to be controlled when noble exposes a peripheral ID but no usable MAC address. - Passes live BLE/API handles into discovered device instances so BLE-capable devices are actually commandable after discovery. - Lets device implementations explicitly mark BLE commands as write-only, and applies that to standard Bot action commands. - Avoids fatal unhandled EventEmitter `error` events when device command failures are already returned to the caller. ## Root Cause On macOS, noble may discover a SwitchBot Bot by peripheral ID rather than MAC address and stores discovered peripherals in its private `_peripherals` map. The existing BLE availability and connect paths assumed a MAC/public peripheral list. Standard Bot action commands also do not provide the response expected by the response-validating command path, so they need to be sent as write-only BLE commands. Separately, command failures logged and returned failure objects but also emitted EventEmitter `error` events. In consumers without an `error` listener, Node treats that as fatal and can crash the process instead of letting the integration handle the returned command failure. ## Changes - Treat `bleId` as sufficient for BLE availability when a MAC address is unavailable. - Pass the initialized `BLEConnection` and `OpenAPIClient` into created device instances. - Read known peripherals from noble `peripherals` or `_peripherals`, including array, map, and object forms. - Add an internal `writeOnly` boolean for BLE commands. - Mark standard Bot action commands as write-only while leaving other BLE commands on the normal response-validating path. - Emit device `error` events only when a listener is registered. ## Validation - `npm run lint` - `npm run build` - `npm test` ## Notes - This is the lower-level BLE fix.
1 parent d804942 commit 0d2e215

4 files changed

Lines changed: 51 additions & 9 deletions

File tree

src/ble.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,24 @@ export class BLEConnection {
619619
this.encryptionConfig.delete(normalizeMAC(mac))
620620
}
621621

622+
private getKnownPeripherals(): any[] {
623+
const candidates = [this.noble?.peripherals, this.noble?._peripherals]
624+
625+
for (const peripherals of candidates) {
626+
if (Array.isArray(peripherals)) {
627+
return peripherals
628+
}
629+
if (peripherals instanceof Map) {
630+
return [...peripherals.values()]
631+
}
632+
if (peripherals && typeof peripherals === 'object') {
633+
return Object.values(peripherals)
634+
}
635+
}
636+
637+
return []
638+
}
639+
622640
private incrementIv(iv: Buffer): Buffer {
623641
const nextIv = Buffer.from(iv)
624642
for (let i = nextIv.length - 1; i >= 0; i--) {
@@ -831,7 +849,7 @@ export class BLEConnection {
831849
this.logger.info(`Connecting to ${mac}`)
832850

833851
// Find peripheral (by address or ID)
834-
const peripherals = await this.noble.peripherals || []
852+
const peripherals = this.getKnownPeripherals()
835853
let peripheral: any
836854

837855
// Try to find by normalized MAC first

src/devices/base.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
310310
* Check if BLE is available for this device
311311
*/
312312
hasBLE(): boolean {
313-
return this.info.connectionTypes.includes('ble') && !!this.bleConnection && !!this.info.mac
313+
return this.info.connectionTypes.includes('ble') && !!this.bleConnection && (!!this.info.mac || !!this.info.bleId)
314314
}
315315

316316
/**
@@ -320,6 +320,12 @@ export abstract class SwitchBotDevice extends EventEmitter {
320320
return this.info.connectionTypes.includes('api') && !!this.apiClient && this.info.cloudServiceEnabled !== false
321321
}
322322

323+
protected emitError(payload: Record<string, unknown>): void {
324+
if (this.listenerCount('error') > 0) {
325+
this.emit('error', payload)
326+
}
327+
}
328+
323329
/**
324330
* Get device status (abstract - implemented by subclasses)
325331
*/
@@ -368,6 +374,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
368374
*/
369375
protected async sendBLECommand(
370376
command: readonly number[] | number[] | Buffer,
377+
writeOnly = false,
371378
): Promise<CommandResult> {
372379
if (!this.hasBLE()) {
373380
return {
@@ -394,7 +401,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
394401
const buffer = Buffer.isBuffer(command) ? command : Buffer.from(command)
395402

396403
const startTime = Date.now()
397-
const mac = this.info.mac ?? `id:${this.info.bleId}`
404+
const mac = this.info.mac && this.info.mac.length > 0 ? this.info.mac : `id:${this.info.bleId}`
398405

399406
let response: Buffer | undefined
400407
if (this.info.encryptionKey && this.info.encryptionIV && this.bleConnection?.setEncryption) {
@@ -406,7 +413,9 @@ export abstract class SwitchBotDevice extends EventEmitter {
406413
)
407414
}
408415

409-
if (this.bleConnection?.sendCommand) {
416+
if (writeOnly) {
417+
await this.bleConnection!.write(mac, buffer)
418+
} else if (this.bleConnection?.sendCommand) {
410419
response = await this.bleConnection.sendCommand(mac, buffer, {
411420
expectResponse: true,
412421
validateResponse: true,
@@ -459,7 +468,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
459468
}
460469

461470
this.logger.error('BLE command failed', error)
462-
this.emit('error', { type: 'ble', error })
471+
this.emitError({ type: 'ble', error })
463472

464473
return {
465474
success: false,
@@ -540,7 +549,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
540549
}
541550

542551
this.logger.error('API command failed', error)
543-
this.emit('error', { type: 'api', error })
552+
this.emitError({ type: 'api', error })
544553

545554
return {
546555
success: false,
@@ -587,6 +596,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
587596
bleCommand: readonly number[] | number[] | Buffer,
588597
apiCommand: string,
589598
apiParameter?: any,
599+
writeOnly = false,
590600
): Promise<CommandResult> {
591601
// Determine connection strategy
592602
let primaryConnection = this.preferredConnection
@@ -628,7 +638,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
628638
let fallbackUsed = false
629639

630640
if (primaryConnection === 'ble') {
631-
result = await this.sendBLECommand(bleCommand)
641+
result = await this.sendBLECommand(bleCommand, writeOnly)
632642
} else {
633643
result = await this.sendAPICommand(apiCommand, apiParameter)
634644
}
@@ -652,7 +662,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
652662
await this.fallbackHandlerManager.emit(fallbackEvent)
653663

654664
if (secondaryConnection === 'ble') {
655-
result = await this.sendBLECommand(bleCommand)
665+
result = await this.sendBLECommand(bleCommand, writeOnly)
656666
} else {
657667
result = await this.sendAPICommand(apiCommand, apiParameter)
658668
}

src/devices/wo-hand.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { DEVICE_COMMANDS } from '../settings.js'
1212
import { BOT_BLE_ACTIONS, buildBotBleCommand, parseBotBleResponse, validateBotPassword } from '../utils/index.js'
1313
import { DeviceOverrideStateDuringConnection } from './device-override-state-during-connection.js'
1414

15+
const BOT_BLE_COMMAND_WRITE_ONLY = true
16+
1517
/**
1618
* Bot (WoHand) Device - Press or switch button device
1719
* Supports optional BLE password protection
@@ -89,6 +91,8 @@ export class WoHand extends DeviceOverrideStateDuringConnection implements BotCo
8991
const result = await this.sendCommand(
9092
DEVICE_COMMANDS.BOT.TURN_ON,
9193
'turnOn',
94+
undefined,
95+
BOT_BLE_COMMAND_WRITE_ONLY,
9296
)
9397
return result.success
9498
}
@@ -106,6 +110,8 @@ export class WoHand extends DeviceOverrideStateDuringConnection implements BotCo
106110
const result = await this.sendCommand(
107111
DEVICE_COMMANDS.BOT.TURN_OFF,
108112
'turnOff',
113+
undefined,
114+
BOT_BLE_COMMAND_WRITE_ONLY,
109115
)
110116
return result.success
111117
}
@@ -123,6 +129,8 @@ export class WoHand extends DeviceOverrideStateDuringConnection implements BotCo
123129
const result = await this.sendCommand(
124130
DEVICE_COMMANDS.BOT.PRESS,
125131
'press',
132+
undefined,
133+
BOT_BLE_COMMAND_WRITE_ONLY,
126134
)
127135
return result.success
128136
}
@@ -159,6 +167,8 @@ export class WoHand extends DeviceOverrideStateDuringConnection implements BotCo
159167
const result = await this.sendCommand(
160168
DEVICE_COMMANDS.BOT.UP,
161169
'turnOff',
170+
undefined,
171+
BOT_BLE_COMMAND_WRITE_ONLY,
162172
)
163173
return result.success
164174
}
@@ -170,6 +180,8 @@ export class WoHand extends DeviceOverrideStateDuringConnection implements BotCo
170180
const result = await this.sendCommand(
171181
DEVICE_COMMANDS.BOT.DOWN,
172182
'turnOn',
183+
undefined,
184+
BOT_BLE_COMMAND_WRITE_ONLY,
173185
)
174186
return result.success
175187
}
@@ -209,7 +221,7 @@ export class WoHand extends DeviceOverrideStateDuringConnection implements BotCo
209221
return true
210222
} catch (error) {
211223
this.logger.error('Password-protected command failed', error)
212-
this.emit('error', { type: 'ble', error, encrypted: true })
224+
this.emitError({ type: 'ble', error, encrypted: true })
213225
throw error
214226
}
215227
}

src/switchbot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,8 @@ export class SwitchBot extends EventEmitter {
549549
const { extractDeviceOptionsFromConfig } = await import('./utils/index.js')
550550
return new DeviceClass(info, {
551551
...extractDeviceOptionsFromConfig(this.config),
552+
apiClient: this.apiClient,
553+
bleConnection: this.bleConnection,
552554
})
553555
} catch (error) {
554556
this.logger.error(`createDevice: Failed to create device ${className}`, error)

0 commit comments

Comments
 (0)