Skip to content

Commit e88d2ca

Browse files
committed
macOS Fixes
Audit remaining address-only paths Add id-based BLE lookups Add manufacturer MAC fallback Broaden discovery filters safely Add macOS/id-only regressions
1 parent 865ab7e commit e88d2ca

File tree

12 files changed

+450
-31
lines changed

12 files changed

+450
-31
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"test": "vitest run",
4747
"test:watch": "vitest watch",
4848
"test-coverage": "npm run test -- --coverage",
49+
"ble:scan": "node tmp-switchbot-scan.mjs",
4950
"docs": "typedoc",
5051
"lint-docs": "typedoc --emit none --treatWarningsAsErrors"
5152
},

src/ble.ts

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { EventEmitter } from 'node:events'
1111

1212
import { BLENotAvailableError, DeviceNotFoundError } from './errors.js'
1313
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'
14-
import { Logger, normalizeMAC, withTimeout } from './utils/index.js'
14+
import { Logger, macToDeviceId, normalizeMAC, withTimeout, extractMacFromManufacturerData } from './utils/index.js'
1515

1616
/**
1717
* BLE Scanner for discovering SwitchBot devices
@@ -124,8 +124,14 @@ export class BLEScanner extends EventEmitter {
124124
}
125125

126126
for (const serviceDataItem of advertisement.serviceData) {
127-
// SwitchBot service UUID
128-
if (serviceDataItem.uuid !== 'fd3d' && serviceDataItem.uuid !== '0000fd3d-0000-1000-8000-00805f9b34fb') {
127+
// SwitchBot service UUID (current: fd3d, legacy: 000d)
128+
const uuid = typeof serviceDataItem.uuid === 'string' ? serviceDataItem.uuid.toLowerCase() : ''
129+
const isSwitchBotUUID = uuid === 'fd3d' ||
130+
uuid === '0000fd3d-0000-1000-8000-00805f9b34fb' ||
131+
uuid === '000d' ||
132+
uuid === '0000000d-0000-1000-8000-00805f9b34fb'
133+
134+
if (!isSwitchBotUUID) {
129135
continue
130136
}
131137

@@ -135,17 +141,37 @@ export class BLEScanner extends EventEmitter {
135141
continue
136142
}
137143

138-
const mac = normalizeMAC(address)
144+
let normalizedAddress = typeof address === 'string' && address.length > 0 ? normalizeMAC(address) : undefined
145+
146+
// Fallback to manufacturer data MAC if service data address is empty
147+
if (!normalizedAddress) {
148+
const manufacturerMac = extractMacFromManufacturerData(peripheral.advertisement.manufacturerData)
149+
if (manufacturerMac) {
150+
normalizedAddress = manufacturerMac
151+
}
152+
}
153+
154+
const fallbackId = typeof peripheral.id === 'string' && peripheral.id.length > 0
155+
? peripheral.id
156+
: (normalizedAddress ? macToDeviceId(normalizedAddress) : undefined)
157+
158+
if (!fallbackId) {
159+
this.logger.debug('Skipping BLE discovery with no address and no peripheral id')
160+
continue
161+
}
162+
139163
const advertisement: BLEAdvertisement = {
140-
id: peripheral.id,
141-
address: mac,
164+
id: fallbackId,
165+
address: normalizedAddress,
166+
isAddressable: normalizedAddress !== undefined,
142167
rssi,
143168
serviceData,
144169
}
145170

146-
this.discoveredDevices.set(mac, advertisement)
171+
const discoveryKey = normalizedAddress ?? `id:${fallbackId}`
172+
this.discoveredDevices.set(discoveryKey, advertisement)
147173
this.emit('discover', advertisement)
148-
this.logger.debug(`Discovered ${serviceData.modelName} at ${mac}`)
174+
this.logger.debug(`Discovered ${serviceData.modelName} at ${normalizedAddress ?? fallbackId}`)
149175
}
150176
} catch (error) {
151177
this.logger.error('Error handling discovery', error)
@@ -247,10 +273,21 @@ export class BLEScanner extends EventEmitter {
247273
}
248274

249275
/**
250-
* Get discovered device by MAC
276+
* Get discovered device by MAC or BLE ID
251277
*/
252-
getDevice(mac: string): BLEAdvertisement | undefined {
253-
return this.discoveredDevices.get(normalizeMAC(mac))
278+
getDevice(mac: string, bleId?: string): BLEAdvertisement | undefined {
279+
// Try MAC lookup first
280+
const byMac = this.discoveredDevices.get(normalizeMAC(mac))
281+
if (byMac) {
282+
return byMac
283+
}
284+
285+
// Fall back to ID-based lookup
286+
if (bleId) {
287+
return this.discoveredDevices.get(`id:${bleId}`)
288+
}
289+
290+
return undefined
254291
}
255292

256293
/**
@@ -263,11 +300,11 @@ export class BLEScanner extends EventEmitter {
263300
/**
264301
* Wait for specific device
265302
*/
266-
async waitForDevice(mac: string, timeoutMs = BLE_SCAN_TIMEOUT): Promise<BLEAdvertisement> {
303+
async waitForDevice(mac: string, timeoutMs = BLE_SCAN_TIMEOUT, bleId?: string): Promise<BLEAdvertisement> {
267304
const normalizedMac = normalizeMAC(mac)
268305

269-
// Check if already discovered
270-
const existing = this.discoveredDevices.get(normalizedMac)
306+
// Check if already discovered (try MAC first, then ID)
307+
const existing = this.discoveredDevices.get(normalizedMac) || (bleId ? this.discoveredDevices.get(`id:${bleId}`) : undefined)
271308
if (existing) {
272309
return existing
273310
}
@@ -276,7 +313,10 @@ export class BLEScanner extends EventEmitter {
276313
return withTimeout(
277314
new Promise<BLEAdvertisement>((resolve) => {
278315
const handler = (advertisement: BLEAdvertisement) => {
279-
if (normalizeMAC(advertisement.address) === normalizedMac) {
316+
// Match by address (if available) or by ID
317+
const matches = (advertisement.address && normalizeMAC(advertisement.address) === normalizedMac) ||
318+
(bleId && advertisement.id === bleId)
319+
if (matches) {
280320
this.off('discover', handler)
281321
resolve(advertisement)
282322
}
@@ -368,9 +408,20 @@ export class BLEConnection {
368408

369409
this.logger.info(`Connecting to ${mac}`)
370410

371-
// Find peripheral
411+
// Find peripheral (by address or ID)
372412
const peripherals = await this.noble.peripherals || []
373-
const peripheral = peripherals.find((p: any) => normalizeMAC(p.address) === normalizedMac)
413+
let peripheral: any
414+
415+
// Try to find by normalized MAC first
416+
if (mac.startsWith('id:')) {
417+
// ID-based lookup: extract the ID and find by peripheral.id
418+
const bleId = mac.substring(3)
419+
// Look through peripherals to find matching ID
420+
peripheral = peripherals.find((p: any) => p.id === bleId)
421+
} else {
422+
// MAC-based lookup
423+
peripheral = peripherals.find((p: any) => p.address && normalizeMAC(p.address) === normalizedMac)
424+
}
374425

375426
if (!peripheral) {
376427
throw new DeviceNotFoundError(mac)

src/devices/base.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ export abstract class SwitchBotDevice extends EventEmitter {
4646
protected enableCircuitBreaker: boolean
4747
protected enableRetry: boolean
4848

49+
private defineCompatibilityProperties(): void {
50+
const properties: Array<'id' | 'name' | 'deviceType' | 'mac' | 'activeConnection'> = ['id', 'name', 'deviceType', 'mac', 'activeConnection']
51+
52+
for (const property of properties) {
53+
Object.defineProperty(this, property, {
54+
enumerable: true,
55+
configurable: true,
56+
get: () => {
57+
const value = this.info[property]
58+
59+
if ((property === 'id' || property === 'mac') && typeof value === 'string' && value.length === 0) {
60+
return undefined
61+
}
62+
63+
return value
64+
},
65+
})
66+
}
67+
}
68+
4969
constructor(
5070
protected info: DeviceInfo,
5171
options: {
@@ -63,6 +83,8 @@ export abstract class SwitchBotDevice extends EventEmitter {
6383
) {
6484
super()
6585

86+
this.defineCompatibilityProperties()
87+
6688
this.logger = new Logger(`${info.deviceType}:${info.id}`, options.logLevel)
6789
this.bleConnection = options.bleConnection
6890
this.apiClient = options.apiClient
@@ -105,8 +127,8 @@ export abstract class SwitchBotDevice extends EventEmitter {
105127
/**
106128
* Get device ID (property accessor for convenience)
107129
*/
108-
get id(): string {
109-
return this.info.id
130+
get id(): string | undefined {
131+
return this.info.id.length > 0 ? this.info.id : undefined
110132
}
111133

112134
/**
@@ -141,14 +163,14 @@ export abstract class SwitchBotDevice extends EventEmitter {
141163
* Get MAC address (if available)
142164
*/
143165
getMAC(): string | undefined {
144-
return this.info.mac
166+
return this.info.mac && this.info.mac.length > 0 ? this.info.mac : undefined
145167
}
146168

147169
/**
148170
* Get MAC address (property accessor for convenience)
149171
*/
150172
get mac(): string | undefined {
151-
return this.info.mac
173+
return this.info.mac && this.info.mac.length > 0 ? this.info.mac : undefined
152174
}
153175

154176
/**
@@ -215,7 +237,8 @@ export abstract class SwitchBotDevice extends EventEmitter {
215237
const buffer = Buffer.isBuffer(command) ? command : Buffer.from(command)
216238

217239
const startTime = Date.now()
218-
await this.bleConnection!.write(this.info.mac!, buffer)
240+
const mac = this.info.mac ?? `id:${this.info.bleId}`
241+
await this.bleConnection!.write(mac, buffer)
219242
const latencyMs = Date.now() - startTime
220243

221244
// Record success
@@ -474,7 +497,7 @@ export abstract class SwitchBotDevice extends EventEmitter {
474497
try {
475498
this.logger.debug('Reading BLE status')
476499
const startTime = Date.now()
477-
const data = await this.bleConnection!.read(this.info.mac!)
500+
const data = await this.bleConnection!.read(this.info.mac ?? `id:${this.info.bleId}`)
478501
const latencyMs = Date.now() - startTime
479502

480503
if (this.enableConnectionIntelligence) {

src/devices/wo-hand.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,11 @@ export class WoHand extends SwitchBotDevice implements BotCommands {
119119
this.logger.debug('Sending password-protected command', { action })
120120

121121
// Send command via BLE
122-
await this.bleConnection!.write(this.info.mac!, command)
122+
const mac = this.info.mac ?? `id:${this.info.bleId}`
123+
await this.bleConnection!.write(mac, command)
123124

124125
// Read response
125-
const responseBuffer = await this.bleConnection!.read(this.info.mac!)
126+
const responseBuffer = await this.bleConnection!.read(mac)
126127

127128
// Parse and validate response
128129
parseBotBleResponse(responseBuffer)

src/switchbot.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { WoSensorTHPro } from './devices/wo-sensor-th-pro.js'
4040
import { WoSensorTH } from './devices/wo-sensor-th.js'
4141
import { WoStrip } from './devices/wo-strip.js'
4242
import { BLE_SUPPORTED, DEFAULTS, DEVICE_CLASS_MAP } from './settings.js'
43-
import { Logger } from './utils/index.js'
43+
import { Logger, macToDeviceId } from './utils/index.js'
4444

4545
/**
4646
* Device class constructor type
@@ -241,15 +241,23 @@ export class SwitchBot extends EventEmitter {
241241
* Handle BLE device discovery
242242
*/
243243
private handleBLEDiscovery(advertisement: any): void {
244-
const { address, serviceData, rssi } = advertisement
244+
const { id: advertisementId, address, serviceData, rssi } = advertisement
245245
const deviceType = serviceData.modelName
246+
const mac = typeof address === 'string' && address.length > 0 ? address : undefined
247+
const deviceId = mac ? macToDeviceId(mac) : advertisementId
248+
249+
if (!deviceId) {
250+
this.logger.warn(`Skipping ${deviceType} BLE discovery: no address or advertisement id`)
251+
return
252+
}
246253

247254
// Create device info
248255
const info = {
249-
id: address.replace(/:/g, '').toUpperCase(),
256+
id: deviceId,
250257
name: deviceType,
251258
deviceType,
252-
mac: address,
259+
mac,
260+
bleId: advertisementId,
253261
connectionTypes: ['ble'] as any,
254262
activeConnection: undefined,
255263
battery: serviceData.battery,
@@ -262,7 +270,7 @@ export class SwitchBot extends EventEmitter {
262270
if (device) {
263271
// Update existing device info
264272
device.updateInfo({
265-
mac: address,
273+
mac: mac ?? device.getInfo().mac,
266274
connectionTypes: [...new Set([...device.getInfo().connectionTypes, 'ble' as ConnectionType])],
267275
battery: serviceData.battery,
268276
rssi,

src/types/ble.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ export interface RelaySwitchServiceData extends BLEServiceData {
192192
*/
193193
export interface BLEAdvertisement {
194194
id: string
195-
address: string
195+
address?: string
196+
isAddressable: boolean
196197
rssi: number
197198
serviceData: BLEServiceData
198199
}

src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export interface DeviceInfo {
8585
model?: string
8686
/** MAC address (for BLE devices) */
8787
mac?: string
88+
/** BLE peripheral/advertisement ID (for ID-based lookups on macOS) */
89+
bleId?: string
8890
/** Available connection types */
8991
connectionTypes: ConnectionType[]
9092
/** Current active connection type */

src/utils/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,37 @@ export function macToDeviceId(mac: string): string {
7171
return mac.replace(COLON_REGEX, '').toUpperCase()
7272
}
7373

74+
/**
75+
* Extract MAC address from manufacturer data (SwitchBot: company ID 0x0969)
76+
* Bytes: [2 bytes company ID (69 09)] + [6 bytes MAC] + ...
77+
*/
78+
export function extractMacFromManufacturerData(manufacturerDataHex?: unknown): string | undefined {
79+
if (!manufacturerDataHex || typeof manufacturerDataHex !== 'string') {
80+
return undefined
81+
}
82+
83+
// Check if hex string starts with 6909 (SwitchBot company ID in little-endian)
84+
if (!manufacturerDataHex.startsWith('6909')) {
85+
return undefined
86+
}
87+
88+
// Extract 6 bytes (12 hex chars) starting at position 4 (after company ID)
89+
const macHex = manufacturerDataHex.substring(4, 16)
90+
if (macHex.length !== 12) {
91+
return undefined
92+
}
93+
94+
// Convert to MAC address format: XX:XX:XX:XX:XX:XX
95+
return [
96+
macHex.substring(0, 2),
97+
macHex.substring(2, 4),
98+
macHex.substring(4, 6),
99+
macHex.substring(6, 8),
100+
macHex.substring(8, 10),
101+
macHex.substring(10, 12),
102+
].join(':').toUpperCase()
103+
}
104+
74105
/**
75106
* Delay utility
76107
*/

test/devices.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,40 @@ describe('device Base Functionality', () => {
7676
expect(retrievedInfo.mac).toBe('AA:BB:CC:DD:EE:FF')
7777
expect(retrievedInfo.battery).toBe(95)
7878
})
79+
80+
it('should expose enumerable compatibility properties', () => {
81+
const info: DeviceInfo = {
82+
id: 'compat-bot-1',
83+
name: 'Compat Bot',
84+
deviceType: 'WoHand',
85+
connectionTypes: ['ble'] as ConnectionType[],
86+
mac: 'AA:11:22:33:44:55',
87+
}
88+
89+
const bot = new WoHand(info)
90+
91+
expect(bot.id).toBe('compat-bot-1')
92+
expect(bot.name).toBe('Compat Bot')
93+
expect(bot.deviceType).toBe('WoHand')
94+
expect(bot.mac).toBe('AA:11:22:33:44:55')
95+
expect(Object.keys(bot)).toContain('id')
96+
})
97+
98+
it('should normalize empty compatibility id/mac getters to undefined', () => {
99+
const info: DeviceInfo = {
100+
id: '',
101+
name: 'Compat Empty Bot',
102+
deviceType: 'WoHand',
103+
connectionTypes: ['ble'] as ConnectionType[],
104+
mac: '',
105+
}
106+
107+
const bot = new WoHand(info)
108+
109+
expect(bot.id).toBeUndefined()
110+
expect(bot.getMAC()).toBeUndefined()
111+
expect(bot.mac).toBeUndefined()
112+
})
79113
})
80114

81115
describe('woCurtain', () => {

0 commit comments

Comments
 (0)