@switchbot/homebridge-switchbot 5.2.2-beta.0 → 5.2.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.github/copilot-instructions.md +4 -0
  2. package/dist/SwitchBotHAPPlatform.d.ts.map +1 -1
  3. package/dist/SwitchBotHAPPlatform.js +8 -6
  4. package/dist/SwitchBotHAPPlatform.js.map +1 -1
  5. package/dist/cacheRepair.d.ts +19 -0
  6. package/dist/cacheRepair.d.ts.map +1 -0
  7. package/dist/cacheRepair.js +68 -0
  8. package/dist/cacheRepair.js.map +1 -0
  9. package/dist/deviceCommandMapper.d.ts +19 -5
  10. package/dist/deviceCommandMapper.d.ts.map +1 -1
  11. package/dist/deviceCommandMapper.js +132 -312
  12. package/dist/deviceCommandMapper.js.map +1 -1
  13. package/dist/devices/genericDevice.d.ts.map +1 -1
  14. package/dist/devices/genericDevice.js +8 -2
  15. package/dist/devices/genericDevice.js.map +1 -1
  16. package/dist/homebridge-ui/deviceCommandMapper.js +132 -312
  17. package/dist/homebridge-ui/deviceCommandMapper.js.map +1 -1
  18. package/dist/homebridge-ui/switchbotClient.js +30 -9
  19. package/dist/homebridge-ui/switchbotClient.js.map +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/switchbotClient.d.ts +1 -0
  24. package/dist/switchbotClient.d.ts.map +1 -1
  25. package/dist/switchbotClient.js +30 -9
  26. package/dist/switchbotClient.js.map +1 -1
  27. package/dist/utils.d.ts.map +1 -1
  28. package/dist/utils.js +21 -1
  29. package/dist/utils.js.map +1 -1
  30. package/docs/variables/default.html +1 -1
  31. package/package.json +2 -2
  32. package/src/SwitchBotHAPPlatform.ts +10 -6
  33. package/src/cacheRepair.ts +79 -0
  34. package/src/deviceCommandMapper.ts +142 -317
  35. package/src/devices/genericDevice.ts +8 -2
  36. package/src/index.ts +3 -0
  37. package/src/switchbotClient.ts +30 -9
  38. package/src/utils.ts +21 -1
  39. package/test/client/node-switchbot-commands.spec.ts +85 -0
  40. package/test/client/switchbot-client-debounce.spec.ts +17 -0
  41. package/test/client/switchbot-client-discovery.spec.ts +40 -0
  42. package/test/device/genericDevice-blepoll.spec.ts +42 -0
  43. package/test/platform/hap-cache-restart.spec.ts +112 -0
  44. package/test/utils/utils.spec.ts +26 -0
@@ -1,8 +1,10 @@
1
1
  // deviceCommandMapper.ts
2
- // Maps HAP/Matter commands to node-switchbot device methods for all supported device types
3
-
4
- // This is a starter mapping. Expand as needed for new device types and commands.
5
- // Each device type maps to a set of commands, each command maps to a function that takes (device, body) and returns a Promise.
2
+ // Maps HAP/Matter commands to node-switchbot device methods.
3
+ //
4
+ // Accessories send generic commands (turnOn, setBrightness, setPosition, ...). Each command maps to
5
+ // the node-switchbot method that performs it, and is used when the device has that method. This
6
+ // works for every node-switchbot device class without listing device type strings, which differ
7
+ // between the OpenAPI ("Plug Mini (US)") and BLE ("Meter Plus") and were often missing here.
6
8
 
7
9
  export type DeviceCommandHandler = (device: any, body: any) => Promise<any>
8
10
 
@@ -10,324 +12,147 @@ export interface DeviceCommandMap {
10
12
  [command: string]: DeviceCommandHandler
11
13
  }
12
14
 
13
- export interface DeviceTypeCommandMap {
14
- [deviceType: string]: DeviceCommandMap
15
+ interface CommandSpec {
16
+ /** Methods that can carry out the command; the first one the device has is used */
17
+ methods: string[]
18
+ handler: (device: any, body: any, method: string) => Promise<any>
15
19
  }
16
20
 
17
- export const deviceTypeCommandMap: DeviceTypeCommandMap = {
18
- // SwitchBot AI Hub (read-only/generic)
19
- 'ai hub': {},
20
- // SwitchBot Cameras (read-only/generic)
21
- 'indoor cam': {},
22
- 'pan/tilt cam': {},
23
- 'pan/tilt cam 2k': {},
24
- 'pan/tilt cam plus 2k': {},
25
- 'pan/tilt cam plus 3k': {},
26
- // SwitchBot Air Purifiers (read-only/generic)
27
- 'air purifier pm2.5': {},
28
- 'air purifier table pm2.5': {},
29
- 'air purifier voc': {},
30
- 'air purifier table voc': {},
31
- // SwitchBot Home Climate Panel (read-only/generic)
32
- 'home climate panel': {},
33
- // SwitchBot WoIOSensor (read-only/generic)
34
- 'woiosensor': {},
35
- // SwitchBot Candle Warmer Lamp
36
- 'candle warmer lamp': {
37
- turnOn: async device => device.turnOn(),
38
- turnOff: async device => device.turnOff(),
39
- },
40
- // SwitchBot Hubs (read-only/generic)
41
- 'hub': {},
42
- 'hub 2': {},
43
- 'hub 3': {},
44
- 'hub mini': {},
45
- 'hub plus': {},
46
- // SwitchBot Robot Vacuum Cleaner K20 Plus Pro
47
- 'robot vacuum cleaner k20 plus pro': {
48
- start: async device => device.start(),
49
- stop: async device => device.stop(),
50
- pause: async device => device.pause(),
51
- dock: async device => device.dock(),
52
- resume: async device => device.resume(),
53
- locate: async device => device.locate(),
54
- },
55
- // SwitchBot Robot Vacuum Cleaner S1
56
- 'robot vacuum cleaner s1': {
57
- start: async device => device.start(),
58
- stop: async device => device.stop(),
59
- pause: async device => device.pause(),
60
- dock: async device => device.dock(),
61
- resume: async device => device.resume(),
62
- locate: async device => device.locate(),
63
- },
64
- // SwitchBot Robot Vacuum Cleaner S1 Plus
65
- 'robot vacuum cleaner s1 plus': {
66
- start: async device => device.start(),
67
- stop: async device => device.stop(),
68
- pause: async device => device.pause(),
69
- dock: async device => device.dock(),
70
- resume: async device => device.resume(),
71
- locate: async device => device.locate(),
72
- },
73
- // SwitchBot Robot Vacuum Cleaner S10
74
- 'robot vacuum cleaner s10': {
75
- start: async device => device.start(),
76
- stop: async device => device.stop(),
77
- pause: async device => device.pause(),
78
- dock: async device => device.dock(),
79
- resume: async device => device.resume(),
80
- locate: async device => device.locate(),
81
- },
82
- // SwitchBot Robot Vacuum Cleaner S20
83
- 'robot vacuum cleaner s20': {
84
- start: async device => device.start(),
85
- stop: async device => device.stop(),
86
- pause: async device => device.pause(),
87
- dock: async device => device.dock(),
88
- resume: async device => device.resume(),
89
- locate: async device => device.locate(),
90
- },
91
- // SwitchBot Relay Switch 2PM
92
- 'relay switch 2pm': {
93
- turnOn: async device => device.turnOn(),
94
- turnOff: async device => device.turnOff(),
95
- },
96
- // SwitchBot K10+
97
- 'k10+': {
98
- start: async device => device.start(),
99
- stop: async device => device.stop(),
100
- pause: async device => device.pause(),
101
- dock: async device => device.dock(),
102
- resume: async device => device.resume(),
103
- locate: async device => device.locate(),
104
- },
105
- // SwitchBot K10+ Pro
106
- 'k10+ pro': {
107
- start: async device => device.start(),
108
- stop: async device => device.stop(),
109
- pause: async device => device.pause(),
110
- dock: async device => device.dock(),
111
- resume: async device => device.resume(),
112
- locate: async device => device.locate(),
113
- },
114
- // SwitchBot Robot Vacuum Cleaner K10+ Pro Combo
115
- 'robot vacuum cleaner k10+ pro combo': {
116
- start: async device => device.start(),
117
- stop: async device => device.stop(),
118
- pause: async device => device.pause(),
119
- dock: async device => device.dock(),
120
- resume: async device => device.resume(),
121
- locate: async device => device.locate(),
122
- },
123
- // SwitchBot Robot Vacuum Cleaner K11+
124
- 'robot vacuum cleaner k11+': {
125
- start: async device => device.start(),
126
- stop: async device => device.stop(),
127
- pause: async device => device.pause(),
128
- dock: async device => device.dock(),
129
- resume: async device => device.resume(),
130
- locate: async device => device.locate(),
131
- },
132
- // SwitchBot MeterPro(CO2) (read-only)
133
- 'meterpro(co2)': {},
134
- // SwitchBot Water Detector (read-only)
135
- 'water detector': {},
136
- // SwitchBot Garage Door Opener
137
- 'garage door opener': {
138
- open: async device => device.open(),
139
- close: async device => device.close(),
140
- stop: async device => device.stop(),
141
- },
142
- // SwitchBot Relay Switch 1
143
- 'relay switch 1': {
144
- turnOn: async device => device.turnOn(),
145
- turnOff: async device => device.turnOff(),
146
- },
147
- // SwitchBot Relay Switch 1PM
148
- 'relay switch 1pm': {
149
- turnOn: async device => device.turnOn(),
150
- turnOff: async device => device.turnOff(),
151
- },
152
- // SwitchBot Smart Radiator Thermostat (read-only or not directly controllable)
153
- 'smart radiator thermostat': {},
154
- // SwitchBot Humidifier2
155
- 'humidifier2': {
156
- turnOn: async device => device.turnOn(),
157
- turnOff: async device => device.turnOff(),
158
- setMode: async (device, body) => device.setMode(body?.parameter),
159
- setHumidity: async (device, body) => device.setHumidity(body?.parameter),
160
- },
161
- // SwitchBot MeterPlus (read-only)
162
- 'meterplus': {},
163
- // SwitchBot Meter Pro (read-only)
164
- 'meter pro': {},
165
- // SwitchBot MeterPro (read-only)
166
- 'meterpro': {},
167
- // SwitchBot RGBICWW Floor Lamp
168
- 'rgbicww floor lamp': {
169
- turnOn: async device => device.turnOn(),
170
- turnOff: async device => device.turnOff(),
171
- setBrightness: async (device, body) => device.setBrightness(body?.parameter),
172
- setColor: async (device, body) => device.setColor(body?.parameter),
173
- },
174
- // SwitchBot RGBICWW Strip Light
175
- 'rgbicww strip light': {
176
- turnOn: async device => device.turnOn(),
177
- turnOff: async device => device.turnOff(),
178
- setBrightness: async (device, body) => device.setBrightness(body?.parameter),
179
- setColor: async (device, body) => device.setColor(body?.parameter),
180
- },
181
- // SwitchBot Circulator Fan
182
- 'circulator fan': {
183
- turnOn: async device => device.turnOn(),
184
- turnOff: async device => device.turnOff(),
185
- setSpeed: async (device, body) => device.setSpeed(body?.parameter),
186
- swing: async device => device.swing(),
187
- },
188
- // SwitchBot Battery Circulator Fan
189
- 'battery circulator fan': {
190
- turnOn: async device => device.turnOn(),
191
- turnOff: async device => device.turnOff(),
192
- setSpeed: async (device, body) => device.setSpeed(body?.parameter),
193
- swing: async device => device.swing(),
194
- },
195
- // SwitchBot Standing Circulator Fan
196
- 'standing circulator fan': {
197
- turnOn: async device => device.turnOn(),
198
- turnOff: async device => device.turnOff(),
199
- setSpeed: async (device, body) => device.setSpeed(body?.parameter),
200
- swing: async device => device.swing(),
201
- },
202
- // SwitchBot Bot
203
- 'bot': {
204
- turnOn: async device => device.turnOn(),
205
- turnOff: async device => device.turnOff(),
206
- press: async device => device.press(),
207
- },
208
- // SwitchBot Curtain
209
- 'curtain': {
210
- open: async device => device.open(),
211
- close: async device => device.close(),
212
- pause: async device => device.pause(),
213
- setPosition: async (device, body) => device.setPosition(body?.parameter),
214
- },
215
- // SwitchBot Blind Tilt
216
- 'blind tilt': {
217
- open: async device => device.open(),
218
- close: async device => device.close(),
219
- pause: async device => device.pause(),
220
- setPosition: async (device, body) => device.setPosition(body?.parameter),
221
- },
222
- // SwitchBot Roller Shade
223
- 'roller shade': {
224
- open: async device => device.open(),
225
- close: async device => device.close(),
226
- pause: async device => device.pause(),
227
- setPosition: async (device, body) => device.setPosition(body?.parameter),
228
- },
229
- // SwitchBot Plug
230
- 'plug': {
231
- turnOn: async device => device.turnOn(),
232
- turnOff: async device => device.turnOff(),
233
- },
234
- // SwitchBot Strip Light 3
235
- 'strip light 3': {
236
- turnOn: async device => device.turnOn(),
237
- turnOff: async device => device.turnOff(),
238
- setBrightness: async (device, body) => device.setBrightness(body?.parameter),
239
- setColor: async (device, body) => device.setColor(body?.parameter),
240
- },
241
- // SwitchBot Floor Lamp
242
- 'floor lamp': {
243
- turnOn: async device => device.turnOn(),
244
- turnOff: async device => device.turnOff(),
245
- setBrightness: async (device, body) => device.setBrightness(body?.parameter),
246
- setColor: async (device, body) => device.setColor(body?.parameter),
247
- },
248
- // SwitchBot RGBIC Neon Rope Light
249
- 'rgbic neon rope light': {
250
- turnOn: async device => device.turnOn(),
251
- turnOff: async device => device.turnOff(),
252
- setBrightness: async (device, body) => device.setBrightness(body?.parameter),
253
- setColor: async (device, body) => device.setColor(body?.parameter),
254
- },
255
- // SwitchBot RGBIC Neon Wire Rope Light
256
- 'rgbic neon wire rope light': {
257
- turnOn: async device => device.turnOn(),
258
- turnOff: async device => device.turnOff(),
259
- setBrightness: async (device, body) => device.setBrightness(body?.parameter),
260
- setColor: async (device, body) => device.setColor(body?.parameter),
261
- },
262
- // SwitchBot Plug Mini
263
- 'plug mini': {
264
- turnOn: async device => device.turnOn(),
265
- turnOff: async device => device.turnOff(),
266
- },
267
- // SwitchBot Color Bulb
268
- 'color bulb': {
269
- turnOn: async device => device.turnOn(),
270
- turnOff: async device => device.turnOff(),
271
- setBrightness: async (device, body) => device.setBrightness(body?.parameter),
272
- setColor: async (device, body) => device.setColor(body?.parameter),
273
- },
274
- // SwitchBot Light Strip
275
- 'light strip': {
276
- turnOn: async device => device.turnOn(),
277
- turnOff: async device => device.turnOff(),
278
- setBrightness: async (device, body) => device.setBrightness(body?.parameter),
279
- setColor: async (device, body) => device.setColor(body?.parameter),
280
- },
281
- // SwitchBot Fan
282
- 'fan': {
283
- turnOn: async device => device.turnOn(),
284
- turnOff: async device => device.turnOff(),
285
- setSpeed: async (device, body) => device.setSpeed(body?.parameter),
286
- swing: async device => device.swing(),
287
- },
288
- // SwitchBot Lock
289
- 'lock': {
290
- lock: async device => device.lock(),
291
- unlock: async device => device.unlock(),
292
- },
293
- // SwitchBot Curtain 3
294
- 'curtain3': {
295
- open: async device => device.open(),
296
- close: async device => device.close(),
297
- pause: async device => device.pause(),
298
- setPosition: async (device, body) => device.setPosition(body?.parameter),
299
- },
300
- // SwitchBot Meter
301
- 'meter': {}, // Read-only
302
- // SwitchBot Motion Sensor
303
- 'motion sensor': {}, // Read-only
304
- // SwitchBot Contact Sensor
305
- 'contact sensor': {}, // Read-only
306
- // SwitchBot Humidifier
307
- 'humidifier': {
308
- turnOn: async device => device.turnOn(),
309
- turnOff: async device => device.turnOff(),
310
- setMode: async (device, body) => device.setMode(body?.parameter),
311
- setHumidity: async (device, body) => device.setHumidity(body?.parameter),
312
- },
313
- // SwitchBot Vacuum
314
- 'vacuum': {
315
- start: async device => device.start(),
316
- stop: async device => device.stop(),
317
- pause: async device => device.pause(),
318
- dock: async device => device.dock(),
319
- resume: async device => device.resume(),
320
- locate: async device => device.locate(),
21
+ function numberParameter(body: any): number {
22
+ const value = Number(body?.parameter)
23
+ if (!Number.isFinite(value)) {
24
+ throw new TypeError(`Expected a number, got ${JSON.stringify(body?.parameter)}`)
25
+ }
26
+ return value
27
+ }
28
+
29
+ /**
30
+ * HomeKit and Matter send color temperature in mireds (about 140-500); node-switchbot expects
31
+ * kelvin (2700-6500). Values above 1000 are taken as kelvin already.
32
+ */
33
+ export function toKelvin(value: number): number {
34
+ const kelvin = value > 1000 ? value : 1_000_000 / value
35
+ return Math.round(Math.min(6500, Math.max(2700, kelvin)))
36
+ }
37
+
38
+ /** Hue 0-360, saturation 0-100, at full value, to RGB 0-255 */
39
+ export function hsToRgb(hue: number, saturation: number): [number, number, number] {
40
+ const h = (((hue % 360) + 360) % 360) / 60
41
+ const s = Math.min(100, Math.max(0, saturation)) / 100
42
+ const c = s
43
+ const x = c * (1 - Math.abs((h % 2) - 1))
44
+ const [r, g, b] = h < 1
45
+ ? [c, x, 0]
46
+ : h < 2
47
+ ? [x, c, 0]
48
+ : h < 3
49
+ ? [0, c, x]
50
+ : h < 4
51
+ ? [0, x, c]
52
+ : h < 5 ? [x, 0, c] : [c, 0, x]
53
+ const m = 1 - c
54
+ return [r, g, b].map(v => Math.round((v + m) * 255)) as [number, number, number]
55
+ }
56
+
57
+ /**
58
+ * Parse a color parameter: `"hue,saturation"` (from HomeKit), `"r:g:b"` (OpenAPI style) or
59
+ * `"#rrggbb"`.
60
+ */
61
+ export function toRgb(parameter: unknown): [number, number, number] {
62
+ const text = String(parameter ?? '').trim()
63
+ const hex = /^#?([0-9a-f]{6})$/i.exec(text)
64
+ if (hex) {
65
+ const n = Number.parseInt(hex[1], 16)
66
+ return [(n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF]
67
+ }
68
+ if (text.includes(':')) {
69
+ const parts = text.split(':').map(Number)
70
+ if (parts.length === 3 && parts.every(Number.isFinite)) {
71
+ return parts.map(v => Math.round(Math.min(255, Math.max(0, v)))) as [number, number, number]
72
+ }
73
+ }
74
+ if (text.includes(',')) {
75
+ const [hue, saturation] = text.split(',').map(Number)
76
+ if (Number.isFinite(hue) && Number.isFinite(saturation)) {
77
+ return hsToRgb(hue, saturation)
78
+ }
79
+ }
80
+ throw new TypeError(`Unrecognised color ${JSON.stringify(parameter)}`)
81
+ }
82
+
83
+ const call = (method: string) => ({ methods: [method], handler: (device: any) => device[method]() })
84
+
85
+ const commandSpecs: Record<string, CommandSpec> = {
86
+ turnOn: call('turnOn'),
87
+ turnOff: call('turnOff'),
88
+ toggle: call('toggle'),
89
+ press: call('press'),
90
+ open: call('open'),
91
+ close: call('close'),
92
+ pause: call('pause'),
93
+ lock: call('lock'),
94
+ unlock: call('unlock'),
95
+ setBrightness: {
96
+ methods: ['setBrightness'],
97
+ handler: (device, body) => device.setBrightness(numberParameter(body)),
98
+ },
99
+ setColorTemperature: {
100
+ methods: ['setColorTemperature', 'setColorTemp'],
101
+ handler: (device, body, method) => {
102
+ const kelvin = toKelvin(numberParameter(body))
103
+ return method === 'setColorTemp' ? device.setColorTemp(2700, 6500, kelvin) : device.setColorTemperature(kelvin)
104
+ },
105
+ },
106
+ setColor: {
107
+ methods: ['setColor'],
108
+ handler: (device, body) => device.setColor(...toRgb(body?.parameter)),
109
+ },
110
+ setPosition: {
111
+ methods: ['setPosition'],
112
+ handler: (device, body) => device.setPosition(numberParameter(body)),
113
+ },
114
+ setFanSpeed: {
115
+ methods: ['setFanSpeed', 'setSpeed'],
116
+ handler: (device, body, method) => device[method](numberParameter(body)),
117
+ },
118
+ setMode: {
119
+ methods: ['setMode'],
120
+ handler: (device, body) => device.setMode(body?.parameter),
121
+ },
122
+ setHumidity: {
123
+ methods: ['setTargetHumidity', 'setHumidity', 'setLevel'],
124
+ handler: (device, body, method) => device[method](numberParameter(body)),
125
+ },
126
+ // Vacuums: node-switchbot uses cleanUp/returnToDock with the BLE protocol version
127
+ start: {
128
+ methods: ['cleanUp', 'start'],
129
+ handler: (device, body, method) => method === 'cleanUp' ? device.cleanUp(Number(body?.protocolVersion) || 1) : device.start(),
130
+ },
131
+ dock: {
132
+ methods: ['returnToDock', 'dock'],
133
+ handler: (device, body, method) => method === 'returnToDock' ? device.returnToDock(Number(body?.protocolVersion) || 1) : device.dock(),
134
+ },
135
+ stop: {
136
+ methods: ['returnToDock', 'stop', 'pause'],
137
+ handler: (device, body, method) => method === 'returnToDock' ? device.returnToDock(Number(body?.protocolVersion) || 1) : device[method](),
321
138
  },
322
- // Add more device types and commands as needed
323
139
  }
324
140
 
325
- // Helper to get the handler for a device type and command
326
- export function getDeviceCommandHandler(deviceType: string, command: string): DeviceCommandHandler | undefined {
327
- const typeKey = deviceType.toLowerCase()
328
- const typeMap = deviceTypeCommandMap[typeKey]
329
- if (!typeMap) {
141
+ /** Commands this module can map, for documentation and tests */
142
+ export const supportedCommands = Object.keys(commandSpecs)
143
+
144
+ /**
145
+ * Get the handler for a command on a node-switchbot device, or undefined when the device has no
146
+ * method for it. `deviceType` is kept for callers and logging; dispatch uses the device itself.
147
+ */
148
+ export function getDeviceCommandHandler(deviceType: string, command: string, device?: any): DeviceCommandHandler | undefined {
149
+ const spec = Object.hasOwn(commandSpecs, command) ? commandSpecs[command] : undefined
150
+ if (!spec) {
151
+ return undefined
152
+ }
153
+ const method = device ? spec.methods.find(name => typeof device[name] === 'function') : spec.methods[0]
154
+ if (!method) {
330
155
  return undefined
331
156
  }
332
- return typeMap[command]
157
+ return async (target: any, body: any) => spec.handler(target, body, method)
333
158
  }
@@ -91,8 +91,8 @@ export class GenericDevice extends DeviceBase {
91
91
  this._blePollIntervalMs = pollMs
92
92
  // Subscribe to BLE notifications if supported (node-switchbot v4+)
93
93
  this._subscribeBLENotifications()
94
- // Start BLE polling fallback if enabled
95
- if (this._blePollingEnabled) {
94
+ // Start BLE polling fallback if enabled, and only when BLE itself is enabled
95
+ if (this._blePollingEnabled && (cfg as any)?.enableBLE !== false) {
96
96
  this._startBlePolling()
97
97
  }
98
98
  }
@@ -106,6 +106,12 @@ export class GenericDevice extends DeviceBase {
106
106
  }
107
107
  this._blePollTimer = setInterval(async () => {
108
108
  try {
109
+ // Only poll over BLE. For a device without a BLE connection, getState() reads status over
110
+ // the OpenAPI, which the regular refresh already does within the daily API budget.
111
+ const nodeDevice = this.client?.devices?.get?.(this.opts.id)
112
+ if (typeof nodeDevice?.hasBLE !== 'function' || !nodeDevice.hasBLE()) {
113
+ return
114
+ }
109
115
  this.log.debug(`[BLE] Polling getState() for device ${this.opts.id}`)
110
116
  await this.getState()
111
117
  } catch (e) {
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import type { API } from 'homebridge'
6
6
 
7
+ import { repairCachedAccessories } from './cacheRepair.js'
7
8
  import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
8
9
  import { SwitchBotHAPPlatform } from './SwitchBotHAPPlatform.js'
9
10
  import { SwitchBotMatterPlatform } from './SwitchBotMatterPlatform.js'
@@ -20,6 +21,8 @@ import { createPlatformProxy } from './utils.js'
20
21
  * @property {Function} createPlatformProxy - Factory for the platform proxy constructor.
21
22
  */
22
23
  export default (api: API): void => {
24
+ // Runs before a child bridge loads its cache, so a cache HAP cannot read is fixed first (#1424).
25
+ repairCachedAccessories(api, message => console.warn(`[${PLATFORM_NAME}] ${message}`))
23
26
  const ProxyCtor = createPlatformProxy(SwitchBotHAPPlatform, SwitchBotMatterPlatform)
24
27
  api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, ProxyCtor as any)
25
28
  }
@@ -4,6 +4,9 @@ import type { SwitchBot } from 'node-switchbot'
4
4
  import { getDeviceCommandHandler } from './deviceCommandMapper.js'
5
5
  import { CharacteristicMissingError, SwitchbotAuthenticationError, SwitchbotOperationError } from './errors.js'
6
6
 
7
+ // Commands that set the same state and may replace each other while debounced
8
+ const WRITE_GROUP: Record<string, string> = { turnOn: 'power', turnOff: 'power', toggle: 'power' }
9
+
7
10
  export interface ISwitchBotClient {
8
11
  init: () => Promise<void>
9
12
  getDevice: (id: string) => Promise<any>
@@ -23,6 +26,7 @@ export class SwitchBotClient implements ISwitchBotClient {
23
26
  private writeDebounceMs = 100
24
27
  private discoveryCacheTtlMs = 30_000
25
28
  private lastDiscoveryAt = 0
29
+ private discoveryInFlight?: Promise<any[]>
26
30
  private logger: import('homebridge').Logger
27
31
  private pendingWrites: Map<string, { timer: any, body: any, resolvers: Array<{ resolve: (v: any) => void, reject: (e: any) => void }> }> = new Map()
28
32
 
@@ -127,8 +131,12 @@ export class SwitchBotClient implements ISwitchBotClient {
127
131
  return this._doSetDeviceState(id, body)
128
132
  }
129
133
 
134
+ // Coalesce repeated writes of the same operation only. Power commands share one key, so a
135
+ // quick on/off/on sends the last one; other commands (brightness, color, position...) keep
136
+ // their own key so a turnOn sent together with setBrightness is not dropped.
137
+ const key = `${id}\u0000${WRITE_GROUP[body?.command] ?? body?.command ?? ''}`
130
138
  return new Promise((resolve, reject) => {
131
- const existing = this.pendingWrites.get(id)
139
+ const existing = this.pendingWrites.get(key)
132
140
  if (existing) {
133
141
  existing.body = body
134
142
  existing.resolvers.push({ resolve, reject })
@@ -137,11 +145,11 @@ export class SwitchBotClient implements ISwitchBotClient {
137
145
 
138
146
  const resolvers: Array<{ resolve: (v: any) => void, reject: (e: any) => void }> = [{ resolve, reject }]
139
147
  const timer = setTimeout(async () => {
140
- const entry = this.pendingWrites.get(id)
148
+ const entry = this.pendingWrites.get(key)
141
149
  if (!entry) {
142
150
  return
143
151
  }
144
- this.pendingWrites.delete(id)
152
+ this.pendingWrites.delete(key)
145
153
  try {
146
154
  const out = await this._doSetDeviceState(id, entry.body)
147
155
  for (const r of entry.resolvers) r.resolve(out)
@@ -157,7 +165,7 @@ export class SwitchBotClient implements ISwitchBotClient {
157
165
  }
158
166
  }, this.writeDebounceMs)
159
167
 
160
- this.pendingWrites.set(id, { timer, body, resolvers })
168
+ this.pendingWrites.set(key, { timer, body, resolvers })
161
169
  })
162
170
  }
163
171
 
@@ -175,7 +183,7 @@ export class SwitchBotClient implements ISwitchBotClient {
175
183
  if (!command) {
176
184
  throw new SwitchbotOperationError('No command specified in body', 'no_command')
177
185
  }
178
- const handler = getDeviceCommandHandler(deviceType, command)
186
+ const handler = getDeviceCommandHandler(deviceType, command, device)
179
187
  if (!handler) {
180
188
  throw new SwitchbotOperationError(`Unsupported command '${command}' for device type '${deviceType}'`, 'unsupported_command')
181
189
  }
@@ -255,12 +263,25 @@ export class SwitchBotClient implements ISwitchBotClient {
255
263
 
256
264
  const fromManager = this.getManagedDevices()
257
265
  const cacheValid = this.discoveryCacheTtlMs > 0 && (Date.now() - this.lastDiscoveryAt) < this.discoveryCacheTtlMs
258
- if (!force && cacheValid && fromManager.length > 0) {
266
+ // A forced refresh (a device was not found) still respects the cache period once a discovery
267
+ // has run: an offline device would otherwise trigger a BLE scan and an OpenAPI call per lookup.
268
+ if (cacheValid && (!force || fromManager.length > 0)) {
259
269
  return fromManager
260
270
  }
261
271
 
262
- const discovered = await this.client.discover()
263
- this.lastDiscoveryAt = Date.now()
264
- return discovered
272
+ // Share one discovery between concurrent callers; each BLE scan also resets the last one's results.
273
+ if (!this.discoveryInFlight) {
274
+ const client = this.client
275
+ this.discoveryInFlight = (async () => {
276
+ try {
277
+ const discovered = await client.discover()
278
+ this.lastDiscoveryAt = Date.now()
279
+ return discovered
280
+ } finally {
281
+ this.discoveryInFlight = undefined
282
+ }
283
+ })()
284
+ }
285
+ return this.discoveryInFlight
265
286
  }
266
287
  }
package/src/utils.ts CHANGED
@@ -1134,7 +1134,27 @@ export function normalizeConfig(raw?: PlatformConfig): SwitchBotPluginConfig {
1134
1134
  if (!raw) {
1135
1135
  return {}
1136
1136
  }
1137
- return { ...(raw as any) } as SwitchBotPluginConfig
1137
+ const cfg: any = { ...(raw as any) }
1138
+ // v4 kept these under `credentials` and `options`. Configs carried over from v4 often still do,
1139
+ // and nothing migrates them, so read them when the v5 key is not set.
1140
+ const credentials = cfg.credentials && typeof cfg.credentials === 'object' ? cfg.credentials : {}
1141
+ const options = cfg.options && typeof cfg.options === 'object' ? cfg.options : {}
1142
+ if (!cfg.openApiToken && typeof credentials.token === 'string' && credentials.token) {
1143
+ cfg.openApiToken = credentials.token
1144
+ }
1145
+ if (!cfg.openApiSecret && typeof credentials.secret === 'string' && credentials.secret) {
1146
+ cfg.openApiSecret = credentials.secret
1147
+ }
1148
+ if (cfg.enableBLE === undefined && typeof options.BLE === 'boolean') {
1149
+ cfg.enableBLE = options.BLE
1150
+ }
1151
+ if (cfg.openApiRefreshRate === undefined && Number(options.refreshRate) > 0) {
1152
+ cfg.openApiRefreshRate = Number(options.refreshRate)
1153
+ }
1154
+ if (!Array.isArray(cfg.devices) && Array.isArray(options.devices)) {
1155
+ cfg.devices = options.devices
1156
+ }
1157
+ return cfg as SwitchBotPluginConfig
1138
1158
  }
1139
1159
 
1140
1160
  // Create a Proxy constructor that instantiates the right platform implementation at runtime.