homebridge-lovesac-stealthtech 1.0.2 → 1.1.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.
- package/config.schema.json +1 -0
- package/dist/accessory.d.ts +3 -0
- package/dist/accessory.js +174 -21
- package/dist/ble/BleClient.d.ts +1 -0
- package/dist/ble/BleClient.js +29 -10
- package/dist/ble/BleConnectionManager.js +15 -1
- package/dist/platform.js +9 -3
- package/dist/protocol/LovesacDevice.d.ts +13 -0
- package/dist/protocol/LovesacDevice.js +85 -16
- package/dist/protocol/commands.js +3 -2
- package/dist/protocol/responses.d.ts +8 -2
- package/dist/protocol/responses.js +9 -0
- package/dist/settings.d.ts +11 -0
- package/dist/settings.js +21 -1
- package/package.json +1 -1
package/config.schema.json
CHANGED
package/dist/accessory.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare class LovesacAccessory {
|
|
|
14
14
|
private readonly presetSwitches;
|
|
15
15
|
private quietModeService;
|
|
16
16
|
private volumeDebounceTimer;
|
|
17
|
+
private volumeDebounceResolve;
|
|
17
18
|
private readonly Characteristic;
|
|
18
19
|
constructor(platform: LovesacPlatform, accessory: PlatformAccessory, config: LovesacDeviceConfig, device: LovesacDevice);
|
|
19
20
|
private setPower;
|
|
@@ -23,6 +24,7 @@ export declare class LovesacAccessory {
|
|
|
23
24
|
private setVolumeSelector;
|
|
24
25
|
private setVolumeOn;
|
|
25
26
|
private setVolumePercent;
|
|
27
|
+
shutdown(): void;
|
|
26
28
|
/**
|
|
27
29
|
* Workaround for tvOS 18+ Home Hub bug (homebridge/homebridge#3703).
|
|
28
30
|
* The Home Hub forcibly renames services to localized generic defaults
|
|
@@ -32,5 +34,6 @@ export declare class LovesacAccessory {
|
|
|
32
34
|
private setupConfiguredNameHandler;
|
|
33
35
|
private cyclePreset;
|
|
34
36
|
private updatePresetSwitches;
|
|
37
|
+
private handleUnreachable;
|
|
35
38
|
private handleStateChange;
|
|
36
39
|
}
|
package/dist/accessory.js
CHANGED
|
@@ -16,6 +16,7 @@ class LovesacAccessory {
|
|
|
16
16
|
presetSwitches = [];
|
|
17
17
|
quietModeService = null;
|
|
18
18
|
volumeDebounceTimer = null;
|
|
19
|
+
volumeDebounceResolve = null;
|
|
19
20
|
Characteristic;
|
|
20
21
|
constructor(platform, accessory, config, device) {
|
|
21
22
|
this.platform = platform;
|
|
@@ -48,7 +49,9 @@ class LovesacAccessory {
|
|
|
48
49
|
// See: https://github.com/homebridge/homebridge/issues/3703
|
|
49
50
|
this.tvService = this.accessory.addService(Service.Television, this.config.name, 'television');
|
|
50
51
|
this.tvService.setPrimaryService(true);
|
|
51
|
-
this.tvService
|
|
52
|
+
this.tvService
|
|
53
|
+
.setCharacteristic(this.Characteristic.Active, this.Characteristic.Active.INACTIVE)
|
|
54
|
+
.setCharacteristic(this.Characteristic.SleepDiscoveryMode, this.Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE);
|
|
52
55
|
// Use setValue() for ConfiguredName and Name to ensure proper HAP notification
|
|
53
56
|
this.tvService.getCharacteristic(this.Characteristic.ConfiguredName).setValue(this.config.name);
|
|
54
57
|
this.tvService.getCharacteristic(this.Characteristic.Name).setValue(this.config.name);
|
|
@@ -56,10 +59,24 @@ class LovesacAccessory {
|
|
|
56
59
|
this.setupConfiguredNameHandler(this.tvService, this.config.name);
|
|
57
60
|
// Active (power)
|
|
58
61
|
this.tvService.getCharacteristic(this.Characteristic.Active)
|
|
59
|
-
.onSet(this.setPower.bind(this))
|
|
62
|
+
.onSet(this.setPower.bind(this))
|
|
63
|
+
.onGet(() => {
|
|
64
|
+
if (!this.device.isStateInitialized()) {
|
|
65
|
+
throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
66
|
+
}
|
|
67
|
+
return this.device.state.power
|
|
68
|
+
? this.Characteristic.Active.ACTIVE
|
|
69
|
+
: this.Characteristic.Active.INACTIVE;
|
|
70
|
+
});
|
|
60
71
|
// ActiveIdentifier (input source)
|
|
61
72
|
this.tvService.getCharacteristic(this.Characteristic.ActiveIdentifier)
|
|
62
|
-
.onSet(this.setActiveIdentifier.bind(this))
|
|
73
|
+
.onSet(this.setActiveIdentifier.bind(this))
|
|
74
|
+
.onGet(() => {
|
|
75
|
+
if (!this.device.isStateInitialized()) {
|
|
76
|
+
throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
77
|
+
}
|
|
78
|
+
return this.device.state.source + 1;
|
|
79
|
+
});
|
|
63
80
|
// Remote key
|
|
64
81
|
this.tvService.getCharacteristic(this.Characteristic.RemoteKey)
|
|
65
82
|
.onSet(this.setRemoteKey.bind(this));
|
|
@@ -94,7 +111,13 @@ class LovesacAccessory {
|
|
|
94
111
|
this.speakerService
|
|
95
112
|
.setCharacteristic(this.Characteristic.VolumeControlType, this.Characteristic.VolumeControlType.RELATIVE_WITH_CURRENT);
|
|
96
113
|
this.speakerService.getCharacteristic(this.Characteristic.Mute)
|
|
97
|
-
.onSet(this.setMute.bind(this))
|
|
114
|
+
.onSet(this.setMute.bind(this))
|
|
115
|
+
.onGet(() => {
|
|
116
|
+
if (!this.device.isStateInitialized()) {
|
|
117
|
+
throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
118
|
+
}
|
|
119
|
+
return this.device.state.mute;
|
|
120
|
+
});
|
|
98
121
|
this.speakerService.getCharacteristic(this.Characteristic.VolumeSelector)
|
|
99
122
|
.onSet(this.setVolumeSelector.bind(this));
|
|
100
123
|
this.tvService.addLinkedService(this.speakerService);
|
|
@@ -131,7 +154,15 @@ class LovesacAccessory {
|
|
|
131
154
|
switchService.getCharacteristic(this.Characteristic.On)
|
|
132
155
|
.onSet(async (value) => {
|
|
133
156
|
if (!value) {
|
|
134
|
-
|
|
157
|
+
// Can't unselect a preset — push back the real state
|
|
158
|
+
this.updatePresetSwitches(this.device.state.preset);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!this.device.state.power) {
|
|
162
|
+
// Device is off — reject and push back current state
|
|
163
|
+
this.platform.log.info('Preset change ignored — device is off');
|
|
164
|
+
this.updatePresetSwitches(this.device.state.preset);
|
|
165
|
+
return;
|
|
135
166
|
}
|
|
136
167
|
try {
|
|
137
168
|
await this.device.setPreset(preset.writeVal);
|
|
@@ -155,6 +186,13 @@ class LovesacAccessory {
|
|
|
155
186
|
this.setupConfiguredNameHandler(this.quietModeService, 'Quiet Mode');
|
|
156
187
|
this.quietModeService.getCharacteristic(this.Characteristic.On)
|
|
157
188
|
.onSet(async (value) => {
|
|
189
|
+
if (!this.device.state.power) {
|
|
190
|
+
this.platform.log.info('Quiet mode change ignored — device is off');
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
this.quietModeService.getCharacteristic(this.Characteristic.On).updateValue(false);
|
|
193
|
+
}, 0);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
158
196
|
try {
|
|
159
197
|
await this.device.setQuietMode(value);
|
|
160
198
|
}
|
|
@@ -165,6 +203,7 @@ class LovesacAccessory {
|
|
|
165
203
|
});
|
|
166
204
|
// --- Listen for device state changes ---
|
|
167
205
|
this.device.onStateChange(this.handleStateChange.bind(this));
|
|
206
|
+
this.device.onUnreachable(this.handleUnreachable.bind(this));
|
|
168
207
|
// Start background polling (also triggers initial state fetch via onReconnect)
|
|
169
208
|
this.device.startPolling(this.config.pollInterval);
|
|
170
209
|
}
|
|
@@ -182,6 +221,14 @@ class LovesacAccessory {
|
|
|
182
221
|
// --- Input Source ---
|
|
183
222
|
// HomeKit Identifier 1-4 maps to SourceValue 0-3
|
|
184
223
|
async setActiveIdentifier(value) {
|
|
224
|
+
if (!this.device.state.power) {
|
|
225
|
+
this.platform.log.info('Source change ignored — device is off');
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
this.tvService.getCharacteristic(this.Characteristic.ActiveIdentifier)
|
|
228
|
+
.updateValue(this.device.state.source + 1);
|
|
229
|
+
}, 0);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
185
232
|
try {
|
|
186
233
|
const source = value - 1;
|
|
187
234
|
await this.device.setSource(source);
|
|
@@ -193,6 +240,10 @@ class LovesacAccessory {
|
|
|
193
240
|
}
|
|
194
241
|
// --- Remote Key ---
|
|
195
242
|
async setRemoteKey(value) {
|
|
243
|
+
if (!this.device.state.power) {
|
|
244
|
+
this.platform.log.info('Remote key ignored — device is off');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
196
247
|
try {
|
|
197
248
|
const RemoteKey = this.Characteristic.RemoteKey;
|
|
198
249
|
switch (value) {
|
|
@@ -218,6 +269,10 @@ class LovesacAccessory {
|
|
|
218
269
|
}
|
|
219
270
|
// --- Mute ---
|
|
220
271
|
async setMute(value) {
|
|
272
|
+
if (!this.device.state.power) {
|
|
273
|
+
this.platform.log.info('Mute change ignored — device is off');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
221
276
|
try {
|
|
222
277
|
await this.device.setMute(value);
|
|
223
278
|
}
|
|
@@ -228,6 +283,10 @@ class LovesacAccessory {
|
|
|
228
283
|
}
|
|
229
284
|
// --- Volume Selector (up/down buttons in Control Center remote) ---
|
|
230
285
|
async setVolumeSelector(value) {
|
|
286
|
+
if (!this.device.state.power) {
|
|
287
|
+
this.platform.log.info('Volume selector ignored — device is off');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
231
290
|
try {
|
|
232
291
|
if (value === this.Characteristic.VolumeSelector.INCREMENT) {
|
|
233
292
|
await this.device.volumeUp(this.config.volumeStep);
|
|
@@ -243,25 +302,73 @@ class LovesacAccessory {
|
|
|
243
302
|
}
|
|
244
303
|
// --- Volume Proxy (Fan/Lightbulb) ---
|
|
245
304
|
async setVolumeOn(value) {
|
|
246
|
-
if (!
|
|
247
|
-
|
|
305
|
+
if (!this.device.state.power) {
|
|
306
|
+
this.platform.log.info('Volume on/off ignored — device is off');
|
|
307
|
+
if (this.volumeService) {
|
|
308
|
+
setTimeout(() => {
|
|
309
|
+
this.volumeService.getCharacteristic(this.Characteristic.On).updateValue(false);
|
|
310
|
+
}, 0);
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
if (!value) {
|
|
316
|
+
await this.device.setMute(true);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
if (this.device.state.mute) {
|
|
320
|
+
await this.device.setMute(false);
|
|
321
|
+
}
|
|
322
|
+
if (this.device.state.volume <= 0) {
|
|
323
|
+
await this.device.setVolume(this.config.volumeStep);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
248
326
|
}
|
|
249
|
-
|
|
250
|
-
|
|
327
|
+
catch (err) {
|
|
328
|
+
this.platform.log.error('setVolumeOn failed: %s', (0, settings_1.errorMessage)(err));
|
|
329
|
+
throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
251
330
|
}
|
|
252
331
|
}
|
|
253
|
-
setVolumePercent(value) {
|
|
332
|
+
async setVolumePercent(value) {
|
|
333
|
+
if (!this.device.state.power) {
|
|
334
|
+
this.platform.log.info('Volume change ignored — device is off');
|
|
335
|
+
if (this.volumeService) {
|
|
336
|
+
const levelChar = this.config.volumeControl === 'fan'
|
|
337
|
+
? this.Characteristic.RotationSpeed : this.Characteristic.Brightness;
|
|
338
|
+
const currentPercent = this.device.volumeToPercent(this.device.state.volume);
|
|
339
|
+
setTimeout(() => {
|
|
340
|
+
this.volumeService.getCharacteristic(levelChar).updateValue(currentPercent);
|
|
341
|
+
}, 0);
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
254
345
|
const percent = value;
|
|
255
|
-
// Debounce: HomeKit slider sends many rapid updates
|
|
346
|
+
// Debounce: HomeKit slider sends many rapid updates.
|
|
347
|
+
// Superseded calls resolve immediately (their value was never sent).
|
|
256
348
|
if (this.volumeDebounceTimer) {
|
|
257
349
|
clearTimeout(this.volumeDebounceTimer);
|
|
350
|
+
this.volumeDebounceResolve?.();
|
|
351
|
+
}
|
|
352
|
+
return new Promise((resolve, reject) => {
|
|
353
|
+
this.volumeDebounceResolve = resolve;
|
|
354
|
+
this.volumeDebounceTimer = setTimeout(() => {
|
|
355
|
+
this.volumeDebounceTimer = null;
|
|
356
|
+
this.volumeDebounceResolve = null;
|
|
357
|
+
const volume = this.device.percentToVolume(percent);
|
|
358
|
+
this.device.setVolume(volume).then(resolve, (err) => {
|
|
359
|
+
this.platform.log.error('Failed to set volume: %s', (0, settings_1.errorMessage)(err));
|
|
360
|
+
reject(new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */));
|
|
361
|
+
});
|
|
362
|
+
}, 100);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
shutdown() {
|
|
366
|
+
if (this.volumeDebounceTimer) {
|
|
367
|
+
clearTimeout(this.volumeDebounceTimer);
|
|
368
|
+
this.volumeDebounceTimer = null;
|
|
369
|
+
this.volumeDebounceResolve?.();
|
|
370
|
+
this.volumeDebounceResolve = null;
|
|
258
371
|
}
|
|
259
|
-
this.volumeDebounceTimer = setTimeout(() => {
|
|
260
|
-
const volume = this.device.percentToVolume(percent);
|
|
261
|
-
this.device.setVolume(volume).catch((err) => {
|
|
262
|
-
this.platform.log.error('Failed to set volume: %s', (0, settings_1.errorMessage)(err));
|
|
263
|
-
});
|
|
264
|
-
}, 100);
|
|
265
372
|
}
|
|
266
373
|
// --- Helpers ---
|
|
267
374
|
/**
|
|
@@ -306,6 +413,24 @@ class LovesacAccessory {
|
|
|
306
413
|
.updateValue(ps.presetRead === activePreset);
|
|
307
414
|
}
|
|
308
415
|
}
|
|
416
|
+
// --- Unreachable Handler ---
|
|
417
|
+
handleUnreachable() {
|
|
418
|
+
this.platform.log.warn('Device unreachable — updating HomeKit to show inactive');
|
|
419
|
+
this.tvService.getCharacteristic(this.Characteristic.Active)
|
|
420
|
+
.updateValue(this.Characteristic.Active.INACTIVE);
|
|
421
|
+
if (this.volumeService) {
|
|
422
|
+
this.volumeService.getCharacteristic(this.Characteristic.On)
|
|
423
|
+
.updateValue(false);
|
|
424
|
+
}
|
|
425
|
+
for (const ps of this.presetSwitches) {
|
|
426
|
+
ps.service.getCharacteristic(this.Characteristic.On)
|
|
427
|
+
.updateValue(false);
|
|
428
|
+
}
|
|
429
|
+
if (this.quietModeService) {
|
|
430
|
+
this.quietModeService.getCharacteristic(this.Characteristic.On)
|
|
431
|
+
.updateValue(false);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
309
434
|
// --- State Change Handler ---
|
|
310
435
|
handleStateChange(code, _value) {
|
|
311
436
|
switch (code) {
|
|
@@ -314,6 +439,32 @@ class LovesacAccessory {
|
|
|
314
439
|
.updateValue(this.device.state.power
|
|
315
440
|
? this.Characteristic.Active.ACTIVE
|
|
316
441
|
: this.Characteristic.Active.INACTIVE);
|
|
442
|
+
// Also refresh volume proxy — after unreachable recovery, Power may
|
|
443
|
+
// arrive before Volume/Mute, leaving the proxy stale at "Off".
|
|
444
|
+
if (this.volumeService) {
|
|
445
|
+
this.volumeService.getCharacteristic(this.Characteristic.On)
|
|
446
|
+
.updateValue(this.device.state.power && !this.device.state.mute && this.device.state.volume > 0);
|
|
447
|
+
}
|
|
448
|
+
if (this.device.state.power) {
|
|
449
|
+
// Restore preset switches and quiet mode from cached state —
|
|
450
|
+
// they were cleared when power went off, and the poll may not
|
|
451
|
+
// re-fire those listeners if the values haven't changed.
|
|
452
|
+
this.updatePresetSwitches(this.device.state.preset);
|
|
453
|
+
if (this.quietModeService) {
|
|
454
|
+
this.quietModeService.getCharacteristic(this.Characteristic.On)
|
|
455
|
+
.updateValue(this.device.state.quietMode);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
// When powered off, clear preset switches and quiet mode so their
|
|
460
|
+
// tiles don't misleadingly show "On" / "Powered On".
|
|
461
|
+
for (const ps of this.presetSwitches) {
|
|
462
|
+
ps.service.getCharacteristic(this.Characteristic.On).updateValue(false);
|
|
463
|
+
}
|
|
464
|
+
if (this.quietModeService) {
|
|
465
|
+
this.quietModeService.getCharacteristic(this.Characteristic.On).updateValue(false);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
317
468
|
break;
|
|
318
469
|
case constants_1.ResponseCode.Volume:
|
|
319
470
|
if (this.volumeService) {
|
|
@@ -322,7 +473,7 @@ class LovesacAccessory {
|
|
|
322
473
|
? this.Characteristic.RotationSpeed : this.Characteristic.Brightness;
|
|
323
474
|
this.volumeService.getCharacteristic(levelChar).updateValue(percent);
|
|
324
475
|
this.volumeService.getCharacteristic(this.Characteristic.On)
|
|
325
|
-
.updateValue(!this.device.state.mute && this.device.state.volume > 0);
|
|
476
|
+
.updateValue(this.device.state.power && !this.device.state.mute && this.device.state.volume > 0);
|
|
326
477
|
}
|
|
327
478
|
break;
|
|
328
479
|
case constants_1.ResponseCode.Mute:
|
|
@@ -330,7 +481,7 @@ class LovesacAccessory {
|
|
|
330
481
|
.updateValue(this.device.state.mute);
|
|
331
482
|
if (this.volumeService) {
|
|
332
483
|
this.volumeService.getCharacteristic(this.Characteristic.On)
|
|
333
|
-
.updateValue(!this.device.state.mute && this.device.state.volume > 0);
|
|
484
|
+
.updateValue(this.device.state.power && !this.device.state.mute && this.device.state.volume > 0);
|
|
334
485
|
}
|
|
335
486
|
break;
|
|
336
487
|
case constants_1.ResponseCode.Source:
|
|
@@ -338,10 +489,12 @@ class LovesacAccessory {
|
|
|
338
489
|
.updateValue(this.device.state.source + 1);
|
|
339
490
|
break;
|
|
340
491
|
case constants_1.ResponseCode.Preset:
|
|
341
|
-
|
|
492
|
+
if (this.device.state.power) {
|
|
493
|
+
this.updatePresetSwitches(this.device.state.preset);
|
|
494
|
+
}
|
|
342
495
|
break;
|
|
343
496
|
case constants_1.ResponseCode.QuietMode:
|
|
344
|
-
if (this.quietModeService) {
|
|
497
|
+
if (this.quietModeService && this.device.state.power) {
|
|
345
498
|
this.quietModeService.getCharacteristic(this.Characteristic.On)
|
|
346
499
|
.updateValue(this.device.state.quietMode);
|
|
347
500
|
}
|
package/dist/ble/BleClient.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export declare class BleClient implements IBleClient {
|
|
|
15
15
|
private notificationHandler;
|
|
16
16
|
private _connected;
|
|
17
17
|
private _resolvedAddress;
|
|
18
|
+
private connectGeneration;
|
|
18
19
|
constructor(log: Logger);
|
|
19
20
|
get resolvedAddress(): string;
|
|
20
21
|
connect(address?: string): Promise<void>;
|
package/dist/ble/BleClient.js
CHANGED
|
@@ -13,6 +13,7 @@ class BleClient {
|
|
|
13
13
|
notificationHandler = null;
|
|
14
14
|
_connected = false;
|
|
15
15
|
_resolvedAddress = '';
|
|
16
|
+
connectGeneration = 0;
|
|
16
17
|
constructor(log) {
|
|
17
18
|
this.log = log;
|
|
18
19
|
}
|
|
@@ -23,6 +24,7 @@ class BleClient {
|
|
|
23
24
|
if (this._connected) {
|
|
24
25
|
return;
|
|
25
26
|
}
|
|
27
|
+
const gen = ++this.connectGeneration;
|
|
26
28
|
let peripheral;
|
|
27
29
|
if (address) {
|
|
28
30
|
this.log.debug('BLE: Starting scan for %s...', address);
|
|
@@ -43,18 +45,35 @@ class BleClient {
|
|
|
43
45
|
: peripheral.id ?? peripheral.uuid ?? 'unknown';
|
|
44
46
|
this.log.debug('BLE: Connecting to %s...', resolvedId);
|
|
45
47
|
this._resolvedAddress = resolvedId;
|
|
46
|
-
// Register disconnect handler BEFORE connecting to avoid race (P0-2)
|
|
48
|
+
// Register disconnect handler BEFORE connecting to avoid race (P0-2).
|
|
49
|
+
// Capture the generation so a stale handler from a timed-out attempt does
|
|
50
|
+
// not clear state that belongs to a newer connection.
|
|
47
51
|
peripheral.once('disconnect', () => {
|
|
48
52
|
this.log.debug('BLE: Disconnected');
|
|
49
|
-
this.
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
if (gen === this.connectGeneration) {
|
|
54
|
+
this._connected = false;
|
|
55
|
+
this.peripheral = null;
|
|
56
|
+
this.characteristics = {};
|
|
57
|
+
}
|
|
52
58
|
});
|
|
53
|
-
|
|
59
|
+
try {
|
|
60
|
+
await (0, settings_1.withTimeout)(peripheral.connectAsync(), settings_1.BLE_CONNECT_TIMEOUT, 'BLE connect');
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
peripheral.disconnectAsync().catch(() => { });
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
54
66
|
this._connected = true;
|
|
55
67
|
this.peripheral = peripheral;
|
|
56
68
|
this.log.debug('BLE: Discovering services and characteristics...');
|
|
57
|
-
|
|
69
|
+
let characteristics;
|
|
70
|
+
try {
|
|
71
|
+
({ characteristics } = await (0, settings_1.withTimeout)(peripheral.discoverSomeServicesAndCharacteristicsAsync([settings_1.SOFA_SERVICE_UUID_SHORT], Object.values(settings_1.CharUUID)), settings_1.BLE_DISCOVER_TIMEOUT, 'BLE service discovery'));
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
peripheral.disconnectAsync().catch(() => { });
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
58
77
|
for (const char of characteristics) {
|
|
59
78
|
this.characteristics[char.uuid] = char;
|
|
60
79
|
}
|
|
@@ -63,10 +82,10 @@ class BleClient {
|
|
|
63
82
|
async disconnect() {
|
|
64
83
|
if (this.peripheral && this._connected) {
|
|
65
84
|
try {
|
|
66
|
-
await this.peripheral.disconnectAsync();
|
|
85
|
+
await (0, settings_1.withTimeout)(this.peripheral.disconnectAsync(), settings_1.BLE_DISCONNECT_TIMEOUT, 'BLE disconnect');
|
|
67
86
|
}
|
|
68
87
|
catch {
|
|
69
|
-
// Already disconnected
|
|
88
|
+
// Already disconnected or timed out — clean up regardless
|
|
70
89
|
}
|
|
71
90
|
}
|
|
72
91
|
this._connected = false;
|
|
@@ -82,7 +101,7 @@ class BleClient {
|
|
|
82
101
|
throw new Error(`Characteristic ${characteristicUuid} not found. Available: ${Object.keys(this.characteristics).join(', ')}`);
|
|
83
102
|
}
|
|
84
103
|
// Write without response (as per protocol spec)
|
|
85
|
-
await char.writeAsync(data, true);
|
|
104
|
+
await (0, settings_1.withTimeout)(char.writeAsync(data, true), settings_1.BLE_WRITE_TIMEOUT, 'BLE write');
|
|
86
105
|
}
|
|
87
106
|
async subscribeNotifications(handler) {
|
|
88
107
|
this.notificationHandler = handler;
|
|
@@ -102,7 +121,7 @@ class BleClient {
|
|
|
102
121
|
}
|
|
103
122
|
}
|
|
104
123
|
});
|
|
105
|
-
await upstream.subscribeAsync();
|
|
124
|
+
await (0, settings_1.withTimeout)(upstream.subscribeAsync(), settings_1.BLE_DISCOVER_TIMEOUT, 'BLE subscribe');
|
|
106
125
|
this.log.debug('BLE: Subscribed to UpStream notifications');
|
|
107
126
|
}
|
|
108
127
|
scanForAnyDevice() {
|
|
@@ -34,7 +34,9 @@ class BleConnectionManager {
|
|
|
34
34
|
async enqueue(command) {
|
|
35
35
|
return new Promise((resolve, reject) => {
|
|
36
36
|
this.queue.push({ command, resolve, reject });
|
|
37
|
-
this.processQueue()
|
|
37
|
+
this.processQueue().catch(err => {
|
|
38
|
+
this.log.error('processQueue error: %s', (0, settings_1.errorMessage)(err));
|
|
39
|
+
});
|
|
38
40
|
});
|
|
39
41
|
}
|
|
40
42
|
async ensureConnected() {
|
|
@@ -123,16 +125,28 @@ class BleConnectionManager {
|
|
|
123
125
|
}
|
|
124
126
|
catch (retryErr) {
|
|
125
127
|
item.reject(retryErr);
|
|
128
|
+
// Systemic failure — drain remaining queue to avoid N more
|
|
129
|
+
// doomed reconnect attempts for N queued commands
|
|
130
|
+
const remaining = this.queue.splice(0);
|
|
131
|
+
for (const queued of remaining) {
|
|
132
|
+
queued.reject(new Error('Queue drained after connection failure'));
|
|
133
|
+
}
|
|
126
134
|
}
|
|
127
135
|
}
|
|
128
136
|
}
|
|
129
137
|
}
|
|
130
138
|
finally {
|
|
131
139
|
this.processing = false;
|
|
140
|
+
if (this.connected) {
|
|
141
|
+
this.resetIdleTimer();
|
|
142
|
+
}
|
|
132
143
|
}
|
|
133
144
|
}
|
|
134
145
|
resetIdleTimer() {
|
|
135
146
|
this.clearIdleTimer();
|
|
147
|
+
if (this.processing) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
136
150
|
this.idleTimer = setTimeout(() => {
|
|
137
151
|
this.log.info('Idle timeout, disconnecting from %s', this.resolvedAddress);
|
|
138
152
|
this.disconnect().catch((err) => {
|
package/dist/platform.js
CHANGED
|
@@ -22,6 +22,9 @@ class LovesacPlatform {
|
|
|
22
22
|
this.discoverDevices();
|
|
23
23
|
});
|
|
24
24
|
this.api.on('shutdown', () => {
|
|
25
|
+
for (const acc of this.accessories) {
|
|
26
|
+
acc.shutdown();
|
|
27
|
+
}
|
|
25
28
|
this.device?.stopPolling();
|
|
26
29
|
this.connectionManager?.disconnect().catch(() => { });
|
|
27
30
|
});
|
|
@@ -48,9 +51,12 @@ class LovesacPlatform {
|
|
|
48
51
|
else {
|
|
49
52
|
this.log.info('Setting up device: %s (auto-discovery)', deviceConfig.name);
|
|
50
53
|
}
|
|
51
|
-
// Generate a stable UUID from the BLE address + plugin name
|
|
52
|
-
// _testSuffix in config allows generating a fresh identity for testing
|
|
53
|
-
// For auto-discovery, use a fixed seed so the identity is stable
|
|
54
|
+
// Generate a stable UUID from the BLE address + plugin name.
|
|
55
|
+
// _testSuffix in config allows generating a fresh identity for testing.
|
|
56
|
+
// For auto-discovery, use a fixed seed so the identity is stable.
|
|
57
|
+
// NOTE: Multiple Homebridge instances using auto-discovery on the same
|
|
58
|
+
// network will collide on this UUID. Configure an explicit BLE address
|
|
59
|
+
// in that scenario.
|
|
54
60
|
const addressSeed = deviceConfig.address || 'auto';
|
|
55
61
|
const uuidSeed = 'lovesac-st:' + addressSeed + (rawConfig._testSuffix ?? '');
|
|
56
62
|
const uuid = this.api.hap.uuid.generate(uuidSeed);
|
|
@@ -3,21 +3,34 @@ import { BleConnectionManager } from '../ble/BleConnectionManager';
|
|
|
3
3
|
import { DeviceState } from './responses';
|
|
4
4
|
import { ResponseCode, PresetWriteValue, PresetReadValue, SourceValue } from './constants';
|
|
5
5
|
export type StateChangeListener = (code: ResponseCode, value: number) => void;
|
|
6
|
+
export type UnreachableListener = () => void;
|
|
6
7
|
export declare class LovesacDevice {
|
|
7
8
|
private readonly connectionManager;
|
|
8
9
|
private readonly log;
|
|
9
10
|
readonly state: DeviceState;
|
|
10
11
|
private stateListeners;
|
|
12
|
+
private unreachableListeners;
|
|
11
13
|
private stateInitialized;
|
|
14
|
+
private consecutiveFailures;
|
|
15
|
+
private reachable;
|
|
12
16
|
mcuVersion: string;
|
|
13
17
|
private versionListeners;
|
|
14
18
|
private pollTimer;
|
|
19
|
+
private pollIntervalMs;
|
|
20
|
+
private basePollIntervalMs;
|
|
15
21
|
constructor(connectionManager: BleConnectionManager, log: Logger);
|
|
16
22
|
onStateChange(listener: StateChangeListener): void;
|
|
23
|
+
onUnreachable(listener: UnreachableListener): void;
|
|
17
24
|
isStateInitialized(): boolean;
|
|
18
25
|
onVersionResolved(callback: () => void): void;
|
|
19
26
|
requestStateRefresh(): Promise<void>;
|
|
20
27
|
startPolling(intervalSeconds: number): void;
|
|
28
|
+
private poll;
|
|
29
|
+
private schedulePoll;
|
|
30
|
+
private static readonly MAX_POLL_INTERVAL_MS;
|
|
31
|
+
private onPollSuccess;
|
|
32
|
+
private onPollFailure;
|
|
33
|
+
private markUnreachable;
|
|
21
34
|
stopPolling(): void;
|
|
22
35
|
getResolvedAddress(): string;
|
|
23
36
|
setPower(on: boolean): Promise<void>;
|
|
@@ -76,10 +76,15 @@ class LovesacDevice {
|
|
|
76
76
|
log;
|
|
77
77
|
state;
|
|
78
78
|
stateListeners = [];
|
|
79
|
+
unreachableListeners = [];
|
|
79
80
|
stateInitialized = false;
|
|
81
|
+
consecutiveFailures = 0;
|
|
82
|
+
reachable = true;
|
|
80
83
|
mcuVersion = '';
|
|
81
84
|
versionListeners = [];
|
|
82
85
|
pollTimer = null;
|
|
86
|
+
pollIntervalMs = 0;
|
|
87
|
+
basePollIntervalMs = 0;
|
|
83
88
|
constructor(connectionManager, log) {
|
|
84
89
|
this.connectionManager = connectionManager;
|
|
85
90
|
this.log = log;
|
|
@@ -87,15 +92,13 @@ class LovesacDevice {
|
|
|
87
92
|
this.connectionManager.setNotificationHandler((data) => {
|
|
88
93
|
this.handleNotification(data);
|
|
89
94
|
});
|
|
90
|
-
this.connectionManager.onReconnect(() => {
|
|
91
|
-
this.requestStateRefresh().catch(err => {
|
|
92
|
-
this.log.warn('Reconnect state refresh failed: %s', (0, settings_1.errorMessage)(err));
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
95
|
}
|
|
96
96
|
onStateChange(listener) {
|
|
97
97
|
this.stateListeners.push(listener);
|
|
98
98
|
}
|
|
99
|
+
onUnreachable(listener) {
|
|
100
|
+
this.unreachableListeners.push(listener);
|
|
101
|
+
}
|
|
99
102
|
isStateInitialized() {
|
|
100
103
|
return this.stateInitialized;
|
|
101
104
|
}
|
|
@@ -107,24 +110,71 @@ class LovesacDevice {
|
|
|
107
110
|
await this.connectionManager.enqueue(commands.requestVersionInfo());
|
|
108
111
|
}
|
|
109
112
|
startPolling(intervalSeconds) {
|
|
113
|
+
if (this.pollTimer) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
110
116
|
if (intervalSeconds <= 0) {
|
|
111
117
|
this.log.info('Background polling disabled');
|
|
112
118
|
return;
|
|
113
119
|
}
|
|
120
|
+
this.basePollIntervalMs = intervalSeconds * 1000;
|
|
121
|
+
this.pollIntervalMs = this.basePollIntervalMs;
|
|
114
122
|
this.log.info('Starting background poll every %ds', intervalSeconds);
|
|
115
|
-
// Immediate initial fetch
|
|
116
|
-
this.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
this.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
// Immediate initial fetch, then schedule recurring
|
|
124
|
+
this.poll();
|
|
125
|
+
}
|
|
126
|
+
poll() {
|
|
127
|
+
this.requestStateRefresh()
|
|
128
|
+
.then(() => this.onPollSuccess())
|
|
129
|
+
.catch(err => this.onPollFailure((0, settings_1.errorMessage)(err)))
|
|
130
|
+
.finally(() => this.schedulePoll());
|
|
131
|
+
}
|
|
132
|
+
schedulePoll() {
|
|
133
|
+
this.pollTimer = setTimeout(() => this.poll(), this.pollIntervalMs);
|
|
134
|
+
}
|
|
135
|
+
static MAX_POLL_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
|
136
|
+
onPollSuccess() {
|
|
137
|
+
if (this.consecutiveFailures > 0) {
|
|
138
|
+
this.log.info('Poll succeeded after %d consecutive failure(s)', this.consecutiveFailures);
|
|
139
|
+
}
|
|
140
|
+
this.consecutiveFailures = 0;
|
|
141
|
+
this.reachable = true;
|
|
142
|
+
if (this.pollIntervalMs !== this.basePollIntervalMs) {
|
|
143
|
+
this.pollIntervalMs = this.basePollIntervalMs;
|
|
144
|
+
this.log.info('Poll interval reset to %ds', this.pollIntervalMs / 1000);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
onPollFailure(message) {
|
|
148
|
+
this.consecutiveFailures++;
|
|
149
|
+
this.log.warn('Background poll failed (%d/%d): %s', this.consecutiveFailures, settings_1.UNREACHABLE_THRESHOLD, message);
|
|
150
|
+
if (this.consecutiveFailures >= settings_1.UNREACHABLE_THRESHOLD && this.reachable) {
|
|
151
|
+
this.markUnreachable();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
markUnreachable() {
|
|
155
|
+
this.reachable = false;
|
|
156
|
+
// Exponential backoff: double the poll interval, capped at 10 minutes
|
|
157
|
+
const newInterval = Math.min(this.pollIntervalMs * 2, LovesacDevice.MAX_POLL_INTERVAL_MS);
|
|
158
|
+
if (newInterval !== this.pollIntervalMs) {
|
|
159
|
+
this.pollIntervalMs = newInterval;
|
|
160
|
+
this.log.info('Poll interval backed off to %ds', this.pollIntervalMs / 1000);
|
|
161
|
+
}
|
|
162
|
+
this.log.warn('Device unreachable after %d consecutive poll failures — resetting cached state', this.consecutiveFailures);
|
|
163
|
+
// Reset state to sentinels so next successful connection triggers full re-sync
|
|
164
|
+
(0, responses_1.resetState)(this.state);
|
|
165
|
+
this.stateInitialized = false;
|
|
166
|
+
for (const listener of this.unreachableListeners) {
|
|
167
|
+
try {
|
|
168
|
+
listener();
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
this.log.error('Unreachable listener error: %s', (0, settings_1.errorMessage)(err));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
124
174
|
}
|
|
125
175
|
stopPolling() {
|
|
126
176
|
if (this.pollTimer) {
|
|
127
|
-
|
|
177
|
+
clearTimeout(this.pollTimer);
|
|
128
178
|
this.pollTimer = null;
|
|
129
179
|
}
|
|
130
180
|
}
|
|
@@ -148,10 +198,18 @@ class LovesacDevice {
|
|
|
148
198
|
return Math.round((percent / 100) * settings_1.MAX_VOLUME);
|
|
149
199
|
}
|
|
150
200
|
async volumeUp(step) {
|
|
201
|
+
if (!this.stateInitialized) {
|
|
202
|
+
this.log.warn('volumeUp ignored — state not yet initialized');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
151
205
|
const newVol = Math.min(settings_1.MAX_VOLUME, this.state.volume + step);
|
|
152
206
|
await this.setVolume(newVol);
|
|
153
207
|
}
|
|
154
208
|
async volumeDown(step) {
|
|
209
|
+
if (!this.stateInitialized) {
|
|
210
|
+
this.log.warn('volumeDown ignored — state not yet initialized');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
155
213
|
const newVol = Math.max(0, this.state.volume - step);
|
|
156
214
|
await this.setVolume(newVol);
|
|
157
215
|
}
|
|
@@ -210,7 +268,18 @@ class LovesacDevice {
|
|
|
210
268
|
this.log.warn('Out-of-range value for %s: %d (ignored)', CODE_NAMES[parsed.code] ?? `0x${parsed.code.toString(16)}`, parsed.value);
|
|
211
269
|
return;
|
|
212
270
|
}
|
|
213
|
-
|
|
271
|
+
// Only mark initialized once we've received a Power notification,
|
|
272
|
+
// which is included in every state dump. This prevents onGet from
|
|
273
|
+
// returning stale sentinel values for fields we haven't received yet.
|
|
274
|
+
if (parsed.code === constants_1.ResponseCode.Power) {
|
|
275
|
+
this.stateInitialized = true;
|
|
276
|
+
}
|
|
277
|
+
// Any valid notification means the device is reachable
|
|
278
|
+
if (this.consecutiveFailures > 0 || !this.reachable) {
|
|
279
|
+
this.log.info('Device reachable again after %d poll failure(s)', this.consecutiveFailures);
|
|
280
|
+
this.consecutiveFailures = 0;
|
|
281
|
+
this.reachable = true;
|
|
282
|
+
}
|
|
214
283
|
if (changed) {
|
|
215
284
|
this.log.debug('State: %s = %s', CODE_NAMES[parsed.code] ?? `0x${parsed.code.toString(16)}`, formatStateValue(parsed.code, parsed.value));
|
|
216
285
|
for (const listener of this.stateListeners) {
|
|
@@ -29,7 +29,7 @@ function eqCommand(subCmdId, value) {
|
|
|
29
29
|
}
|
|
30
30
|
// Volume: 0-36
|
|
31
31
|
function setVolume(volume) {
|
|
32
|
-
return eqCommand(0x02, Math.max(0, Math.min(36, volume)));
|
|
32
|
+
return eqCommand(0x02, Math.max(0, Math.min(36, volume))); // MAX_VOLUME = 36 (from protocol)
|
|
33
33
|
}
|
|
34
34
|
// Bass: 0-20
|
|
35
35
|
function setBass(bass) {
|
|
@@ -90,7 +90,8 @@ function requestDeviceInfo() {
|
|
|
90
90
|
data: formatB(0x01, 0x01),
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
|
-
// Request version info
|
|
93
|
+
// Request version info — uses a 4-byte payload (not formatB's 3-byte) because
|
|
94
|
+
// the trailing 0x01 distinguishes it from the state request (AA 01 01 00).
|
|
94
95
|
function requestVersionInfo() {
|
|
95
96
|
return {
|
|
96
97
|
characteristicUuid: settings_1.CharUUID.DeviceInfo,
|
|
@@ -14,6 +14,12 @@ export interface DeviceState {
|
|
|
14
14
|
subwooferConnected: boolean;
|
|
15
15
|
}
|
|
16
16
|
export declare function createDefaultState(): DeviceState;
|
|
17
|
+
/**
|
|
18
|
+
* Reset an existing DeviceState in-place to sentinel defaults.
|
|
19
|
+
* This ensures the next state dump from the device will trigger
|
|
20
|
+
* change events for every field (same mechanism as initial state).
|
|
21
|
+
*/
|
|
22
|
+
export declare function resetState(state: DeviceState): void;
|
|
17
23
|
export interface ParsedResponse {
|
|
18
24
|
code: ResponseCode;
|
|
19
25
|
value: number;
|
|
@@ -31,6 +37,6 @@ export declare function parseNotification(data: Buffer): ParsedResponse | undefi
|
|
|
31
37
|
* Apply a parsed response to the device state. Returns true if state changed.
|
|
32
38
|
* Out-of-range values are logged and ignored to guard against firmware bugs.
|
|
33
39
|
*/
|
|
34
|
-
export declare function applyResponse(state: DeviceState, response: ParsedResponse): boolean;
|
|
40
|
+
export declare function applyResponse(state: DeviceState, response: ParsedResponse): boolean | typeof OUT_OF_RANGE;
|
|
35
41
|
/** Sentinel: applyResponse returns this for out-of-range values so the caller can log a warning. */
|
|
36
|
-
export declare const OUT_OF_RANGE:
|
|
42
|
+
export declare const OUT_OF_RANGE: "out_of_range";
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OUT_OF_RANGE = void 0;
|
|
4
4
|
exports.createDefaultState = createDefaultState;
|
|
5
|
+
exports.resetState = resetState;
|
|
5
6
|
exports.parseNotification = parseNotification;
|
|
6
7
|
exports.applyResponse = applyResponse;
|
|
7
8
|
const constants_1 = require("./constants");
|
|
@@ -23,6 +24,14 @@ function createDefaultState() {
|
|
|
23
24
|
subwooferConnected: false,
|
|
24
25
|
};
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Reset an existing DeviceState in-place to sentinel defaults.
|
|
29
|
+
* This ensures the next state dump from the device will trigger
|
|
30
|
+
* change events for every field (same mechanism as initial state).
|
|
31
|
+
*/
|
|
32
|
+
function resetState(state) {
|
|
33
|
+
Object.assign(state, createDefaultState());
|
|
34
|
+
}
|
|
26
35
|
/**
|
|
27
36
|
* Parse a BLE notification from the UpStream characteristic.
|
|
28
37
|
* Returns the response code and value, or undefined if not a standard status response.
|
package/dist/settings.d.ts
CHANGED
|
@@ -17,6 +17,17 @@ export declare const CharUUID: {
|
|
|
17
17
|
};
|
|
18
18
|
export declare const MAX_VOLUME = 36;
|
|
19
19
|
export declare const BLE_SCAN_TIMEOUT = 15000;
|
|
20
|
+
export declare const BLE_CONNECT_TIMEOUT = 30000;
|
|
21
|
+
export declare const BLE_WRITE_TIMEOUT = 10000;
|
|
22
|
+
export declare const BLE_DISCONNECT_TIMEOUT = 10000;
|
|
23
|
+
export declare const BLE_DISCOVER_TIMEOUT = 15000;
|
|
24
|
+
export declare const UNREACHABLE_THRESHOLD = 3;
|
|
25
|
+
/**
|
|
26
|
+
* Race a promise against a timeout. Rejects with a descriptive error if
|
|
27
|
+
* the timeout fires first. The underlying promise is NOT cancelled — the
|
|
28
|
+
* caller must handle cleanup.
|
|
29
|
+
*/
|
|
30
|
+
export declare function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T>;
|
|
20
31
|
export declare function errorMessage(err: unknown): string;
|
|
21
32
|
export interface LovesacDeviceConfig {
|
|
22
33
|
name: string;
|
package/dist/settings.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.BLE_SCAN_TIMEOUT = exports.MAX_VOLUME = exports.CharUUID = exports.SOFA_SERVICE_UUID_SHORT = exports.SOFA_SERVICE_UUID = exports.PLATFORM_NAME = exports.PLUGIN_NAME = void 0;
|
|
3
|
+
exports.UNREACHABLE_THRESHOLD = exports.BLE_DISCOVER_TIMEOUT = exports.BLE_DISCONNECT_TIMEOUT = exports.BLE_WRITE_TIMEOUT = exports.BLE_CONNECT_TIMEOUT = exports.BLE_SCAN_TIMEOUT = exports.MAX_VOLUME = exports.CharUUID = exports.SOFA_SERVICE_UUID_SHORT = exports.SOFA_SERVICE_UUID = exports.PLATFORM_NAME = exports.PLUGIN_NAME = void 0;
|
|
4
|
+
exports.withTimeout = withTimeout;
|
|
4
5
|
exports.errorMessage = errorMessage;
|
|
5
6
|
exports.resolveDeviceConfig = resolveDeviceConfig;
|
|
6
7
|
exports.PLUGIN_NAME = 'homebridge-lovesac-stealthtech';
|
|
@@ -23,6 +24,25 @@ exports.CharUUID = {
|
|
|
23
24
|
};
|
|
24
25
|
exports.MAX_VOLUME = 36;
|
|
25
26
|
exports.BLE_SCAN_TIMEOUT = 15000;
|
|
27
|
+
// BLE operation timeouts (milliseconds)
|
|
28
|
+
exports.BLE_CONNECT_TIMEOUT = 30_000;
|
|
29
|
+
exports.BLE_WRITE_TIMEOUT = 10_000;
|
|
30
|
+
exports.BLE_DISCONNECT_TIMEOUT = 10_000;
|
|
31
|
+
exports.BLE_DISCOVER_TIMEOUT = 15_000;
|
|
32
|
+
// Consecutive poll failures before marking device unreachable
|
|
33
|
+
exports.UNREACHABLE_THRESHOLD = 3;
|
|
34
|
+
/**
|
|
35
|
+
* Race a promise against a timeout. Rejects with a descriptive error if
|
|
36
|
+
* the timeout fires first. The underlying promise is NOT cancelled — the
|
|
37
|
+
* caller must handle cleanup.
|
|
38
|
+
*/
|
|
39
|
+
function withTimeout(promise, ms, label) {
|
|
40
|
+
let timer;
|
|
41
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
42
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
43
|
+
});
|
|
44
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
45
|
+
}
|
|
26
46
|
function errorMessage(err) {
|
|
27
47
|
return err instanceof Error ? err.message : String(err);
|
|
28
48
|
}
|