@switchbot/homebridge-switchbot 5.2.2-beta.0 → 5.2.2
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.
- package/.github/copilot-instructions.md +4 -0
- package/dist/SwitchBotHAPPlatform.d.ts.map +1 -1
- package/dist/SwitchBotHAPPlatform.js +8 -6
- package/dist/SwitchBotHAPPlatform.js.map +1 -1
- package/dist/cacheRepair.d.ts +19 -0
- package/dist/cacheRepair.d.ts.map +1 -0
- package/dist/cacheRepair.js +68 -0
- package/dist/cacheRepair.js.map +1 -0
- package/dist/deviceCommandMapper.d.ts +19 -5
- package/dist/deviceCommandMapper.d.ts.map +1 -1
- package/dist/deviceCommandMapper.js +132 -312
- package/dist/deviceCommandMapper.js.map +1 -1
- package/dist/devices/genericDevice.d.ts.map +1 -1
- package/dist/devices/genericDevice.js +8 -2
- package/dist/devices/genericDevice.js.map +1 -1
- package/dist/homebridge-ui/deviceCommandMapper.js +132 -312
- package/dist/homebridge-ui/deviceCommandMapper.js.map +1 -1
- package/dist/homebridge-ui/switchbotClient.js +30 -9
- package/dist/homebridge-ui/switchbotClient.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/switchbotClient.d.ts +1 -0
- package/dist/switchbotClient.d.ts.map +1 -1
- package/dist/switchbotClient.js +30 -9
- package/dist/switchbotClient.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +21 -1
- package/dist/utils.js.map +1 -1
- package/docs/variables/default.html +1 -1
- package/package.json +2 -2
- package/src/SwitchBotHAPPlatform.ts +10 -6
- package/src/cacheRepair.ts +79 -0
- package/src/deviceCommandMapper.ts +142 -317
- package/src/devices/genericDevice.ts +8 -2
- package/src/index.ts +3 -0
- package/src/switchbotClient.ts +30 -9
- package/src/utils.ts +21 -1
- package/test/client/node-switchbot-commands.spec.ts +85 -0
- package/test/client/switchbot-client-debounce.spec.ts +17 -0
- package/test/client/switchbot-client-discovery.spec.ts +40 -0
- package/test/device/genericDevice-blepoll.spec.ts +42 -0
- package/test/platform/hap-cache-restart.spec.ts +112 -0
- package/test/utils/utils.spec.ts +26 -0
- package/.github/npm-version-script-esm.js +0 -100
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// deviceCommandMapper.ts
|
|
2
|
-
// Maps HAP/Matter commands to node-switchbot device methods
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
326
|
-
export
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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
|
}
|
package/src/switchbotClient.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
148
|
+
const entry = this.pendingWrites.get(key)
|
|
141
149
|
if (!entry) {
|
|
142
150
|
return
|
|
143
151
|
}
|
|
144
|
-
this.pendingWrites.delete(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
this.
|
|
264
|
-
|
|
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
|
-
|
|
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.
|