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.
@@ -10,6 +10,7 @@
10
10
  "devices": {
11
11
  "title": "Devices",
12
12
  "type": "array",
13
+ "maxItems": 1,
13
14
  "items": {
14
15
  "type": "object",
15
16
  "properties": {
@@ -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.setCharacteristic(this.Characteristic.SleepDiscoveryMode, this.Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE);
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
- return; // Turning off is a no-opcan't unselect a preset
157
+ // Can't unselect a presetpush 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 (!value) {
247
- await this.device.setMute(true);
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
- else if (this.device.state.mute) {
250
- await this.device.setMute(false);
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
- this.updatePresetSwitches(this.device.state.preset);
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
  }
@@ -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>;
@@ -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._connected = false;
50
- this.peripheral = null;
51
- this.characteristics = {};
53
+ if (gen === this.connectGeneration) {
54
+ this._connected = false;
55
+ this.peripheral = null;
56
+ this.characteristics = {};
57
+ }
52
58
  });
53
- await peripheral.connectAsync();
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
- const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync([settings_1.SOFA_SERVICE_UUID_SHORT], Object.values(settings_1.CharUUID));
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 onReconnect will also fire on first connect
116
- this.requestStateRefresh().catch(err => {
117
- this.log.warn('Initial state refresh failed (will retry on next poll): %s', (0, settings_1.errorMessage)(err));
118
- });
119
- this.pollTimer = setInterval(() => {
120
- this.requestStateRefresh().catch(err => {
121
- this.log.warn('Background poll failed: %s', (0, settings_1.errorMessage)(err));
122
- });
123
- }, intervalSeconds * 1000);
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
- clearInterval(this.pollTimer);
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
- this.stateInitialized = true;
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 (AA 01 01 01 — last byte 01 distinguishes from state request)
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: boolean;
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.
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-lovesac-stealthtech",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "Homebridge plugin for Lovesac StealthTech Sound + Charge BLE control",
5
5
  "main": "dist/index.js",
6
6
  "files": [