homebridge-tuya-plus 3.14.0-dev.4 → 3.14.0-dev.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +162 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +16 -3
  5. package/Readme.MD +8 -32
  6. package/config.schema.json +63 -78
  7. package/index.js +599 -358
  8. package/lib/AirPurifierAccessory.js +1 -1
  9. package/lib/BaseAccessory.js +54 -17
  10. package/lib/ConvectorAccessory.js +1 -1
  11. package/lib/CustomMultiOutletAccessory.js +2 -1
  12. package/lib/DoorbellAccessory.js +9 -16
  13. package/lib/GarageDoorAccessory.js +1 -1
  14. package/lib/IrrigationSystemAccessory.js +93 -114
  15. package/lib/MultiOutletAccessory.js +3 -2
  16. package/lib/OilDiffuserAccessory.js +10 -8
  17. package/lib/RGBTWLightAccessory.js +13 -11
  18. package/lib/RGBTWOutletAccessory.js +11 -9
  19. package/lib/SimpleBlindsAccessory.js +5 -5
  20. package/lib/SimpleDimmer2Accessory.js +1 -1
  21. package/lib/SimpleDimmerAccessory.js +10 -6
  22. package/lib/SimpleGarageDoorAccessory.js +78 -24
  23. package/lib/SimpleHeaterAccessory.js +4 -4
  24. package/lib/SimpleLightAccessory.js +1 -1
  25. package/lib/SwitchAccessory.js +3 -2
  26. package/lib/TWLightAccessory.js +2 -2
  27. package/lib/TuyaAccessory.js +75 -42
  28. package/lib/TuyaCloudApi.js +115 -7
  29. package/lib/TuyaCloudDevice.js +351 -28
  30. package/lib/TuyaCloudMessaging.js +28 -41
  31. package/lib/TuyaDevice.js +269 -0
  32. package/lib/TuyaDiscovery.js +25 -9
  33. package/lib/ValveAccessory.js +16 -12
  34. package/lib/VerticalBlindsWithTilt.js +27 -25
  35. package/lib/WledDimmerAccessory.js +46 -81
  36. package/package.json +5 -6
  37. package/scripts/cleanup-pr-tags.js +122 -0
  38. package/test/BaseAccessory.test.js +94 -15
  39. package/test/IrrigationSystemAccessory.test.js +122 -68
  40. package/test/MultiOutletAccessory.test.js +18 -3
  41. package/test/RGBTWLightAccessory.test.js +3 -3
  42. package/test/SimpleFanLightAccessory.test.js +3 -3
  43. package/test/SimpleGarageDoorAccessory.test.js +63 -9
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +110 -1
  46. package/test/TuyaCloudDevice.test.js +438 -2
  47. package/test/TuyaCloudMessaging.test.js +24 -5
  48. package/test/TuyaDevice.test.js +266 -0
  49. package/test/getCategory.test.js +1 -0
  50. package/test/index.test.js +271 -0
  51. package/test/support/mocks.js +13 -0
  52. package/wiki/Supported-Device-Types.md +48 -30
  53. package/wiki/Tuya-Cloud-Setup.md +31 -108
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const BaseAccessory = require('../lib/BaseAccessory');
4
- const { makeInstance } = require('./support/mocks');
4
+ const { makeInstance, HAP } = require('./support/mocks');
5
5
 
6
6
  // Minimal concrete subclass — BaseAccessory itself doesn't define _registerCharacteristics
7
7
  class TestAccessory extends BaseAccessory {
@@ -158,10 +158,13 @@ describe('getStateAsync', () => {
158
158
  expect(instance.getStateAsync(['1', '2'])).toEqual({ '1': true, '2': 50 });
159
159
  });
160
160
 
161
- test('throws when device is not connected', () => {
161
+ test('throws a comm-failure HapStatusError when device is not connected', () => {
162
162
  const { instance, device } = make();
163
163
  device.connected = false;
164
- expect(() => instance.getStateAsync('1')).toThrow('Not connected');
164
+ let err;
165
+ try { instance.getStateAsync('1'); } catch (e) { err = e; }
166
+ expect(err).toBeInstanceOf(HAP.HapStatusError);
167
+ expect(err.hapStatus).toBe(HAP.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
165
168
  });
166
169
  });
167
170
 
@@ -178,10 +181,10 @@ describe('setStateAsync', () => {
178
181
  expect(device.update).not.toHaveBeenCalled();
179
182
  });
180
183
 
181
- test('skips silently when device is not connected (issue #34)', () => {
184
+ test('rejects with a comm-failure HapStatusError when device is not connected', async () => {
182
185
  const { instance, device } = make({ '1': false });
183
186
  device.connected = false;
184
- expect(() => instance.setStateAsync('1', true)).not.toThrow();
187
+ await expect(instance.setStateAsync('1', true)).rejects.toBeInstanceOf(HAP.HapStatusError);
185
188
  expect(device.update).not.toHaveBeenCalled();
186
189
  });
187
190
  });
@@ -201,12 +204,29 @@ describe('setMultiStateAsync', () => {
201
204
  expect(device.update).not.toHaveBeenCalled();
202
205
  });
203
206
 
204
- test('skips silently when device is not connected (issue #34)', () => {
207
+ test('resolves when the device accepts the writes', async () => {
208
+ const { instance } = make({ '1': false });
209
+ await expect(instance.setMultiStateAsync({ '1': true })).resolves.toBeUndefined();
210
+ });
211
+
212
+ test('rejects with a comm-failure HapStatusError when device is not connected', async () => {
205
213
  const { instance, device } = make({ '1': false });
206
214
  device.connected = false;
207
- expect(() => instance.setMultiStateAsync({ '1': true, '2': 50 })).not.toThrow();
215
+ await expect(instance.setMultiStateAsync({ '1': true, '2': 50 })).rejects.toBeInstanceOf(HAP.HapStatusError);
208
216
  expect(device.update).not.toHaveBeenCalled();
209
217
  });
218
+
219
+ test('rejects when the device write is not accepted (returns false)', async () => {
220
+ const { instance, device } = make({ '1': false });
221
+ device.update.mockReturnValue(false);
222
+ await expect(instance.setMultiStateAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
223
+ });
224
+
225
+ test('awaits an async device write result (cloud) and rejects on failure', async () => {
226
+ const { instance, device } = make({ '1': false });
227
+ device.update.mockResolvedValue(false);
228
+ await expect(instance.setMultiStateAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
229
+ });
210
230
  });
211
231
 
212
232
  describe('setMultiStateLegacyAsync', () => {
@@ -217,12 +237,34 @@ describe('setMultiStateLegacyAsync', () => {
217
237
  expect(device.update).toHaveBeenCalledWith({ '1': true, '3': '2' });
218
238
  });
219
239
 
220
- test('skips silently when device is not connected (issue #34)', () => {
240
+ test('rejects with a comm-failure HapStatusError when device is not connected', async () => {
221
241
  const { instance, device } = make();
222
242
  device.connected = false;
223
- expect(() => instance.setMultiStateLegacyAsync({ '1': true })).not.toThrow();
243
+ await expect(instance.setMultiStateLegacyAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
224
244
  expect(device.update).not.toHaveBeenCalled();
225
245
  });
246
+
247
+ test('rejects when the device write is not accepted (returns false)', async () => {
248
+ const { instance, device } = make();
249
+ device.update.mockReturnValue(false);
250
+ await expect(instance.setMultiStateLegacyAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
251
+ });
252
+ });
253
+
254
+ describe('background write helpers (never throw/reject)', () => {
255
+ test('setStateInBackground swallows a disconnected-device failure', async () => {
256
+ const { instance, device } = make({ '1': false });
257
+ device.connected = false;
258
+ expect(() => instance.setStateInBackground('1', true)).not.toThrow();
259
+ // Give the rejected inner promise a tick to settle; it must be caught.
260
+ await Promise.resolve();
261
+ });
262
+
263
+ test('setMultiStateLegacyInBackground still dispatches the write when connected', () => {
264
+ const { instance, device } = make();
265
+ instance.setMultiStateLegacyInBackground({ '1': true });
266
+ expect(device.update).toHaveBeenCalledWith({ '1': true });
267
+ });
226
268
  });
227
269
 
228
270
  describe('getDividedStateAsync', () => {
@@ -231,20 +273,57 @@ describe('getDividedStateAsync', () => {
231
273
  expect(instance.getDividedStateAsync('5', 10)).toBeCloseTo(220);
232
274
  });
233
275
 
234
- test('throws when state value is not finite', () => {
276
+ test('throws a comm-failure HapStatusError when state value is not finite', () => {
235
277
  const { instance } = make({ '5': 'bad' });
236
- expect(() => instance.getDividedStateAsync('5', 10)).toThrow();
278
+ let err;
279
+ try { instance.getDividedStateAsync('5', 10); } catch (e) { err = e; }
280
+ expect(err).toBeInstanceOf(HAP.HapStatusError);
281
+ expect(err.hapStatus).toBe(HAP.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
282
+ });
283
+ });
284
+
285
+ describe('getState (callback)', () => {
286
+ test('returns the DP value via the callback when connected', done => {
287
+ const { instance } = make({ '1': true });
288
+ instance.getState('1', (err, value) => {
289
+ expect(err).toBeNull();
290
+ expect(value).toBe(true);
291
+ done();
292
+ });
293
+ });
294
+
295
+ test('invokes the callback with a comm-failure error when not connected', () => {
296
+ const { instance, device } = make({ '1': true });
297
+ device.connected = false;
298
+ const cb = jest.fn();
299
+ instance.getState('1', cb);
300
+ expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
237
301
  });
238
302
  });
239
303
 
240
304
  describe('setMultiState (legacy callback)', () => {
241
- test('skips silently and invokes callback without error when not connected (issue #34)', () => {
305
+ test('invokes the callback without error on a successful write', () => {
306
+ const { instance } = make({ '1': false });
307
+ const cb = jest.fn();
308
+ instance.setMultiState({ '1': true }, cb);
309
+ expect(cb).toHaveBeenCalledWith(null);
310
+ });
311
+
312
+ test('invokes the callback with a comm-failure error when not connected', () => {
242
313
  const { instance, device } = make({ '1': false });
243
314
  device.connected = false;
244
315
  const cb = jest.fn();
245
316
  instance.setMultiState({ '1': true }, cb);
246
317
  expect(device.update).not.toHaveBeenCalled();
247
- expect(cb).toHaveBeenCalledWith();
318
+ expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
319
+ });
320
+
321
+ test('invokes the callback with a comm-failure error when the write is not accepted', () => {
322
+ const { instance, device } = make({ '1': false });
323
+ device.update.mockReturnValue(false);
324
+ const cb = jest.fn();
325
+ instance.setMultiState({ '1': true }, cb);
326
+ expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
248
327
  });
249
328
 
250
329
  test('tolerates a missing callback when not connected', () => {
@@ -255,13 +334,13 @@ describe('setMultiState (legacy callback)', () => {
255
334
  });
256
335
 
257
336
  describe('setMultiStateLegacy (legacy callback)', () => {
258
- test('skips silently and invokes callback without error when not connected (issue #34)', () => {
337
+ test('invokes the callback with a comm-failure error when not connected', () => {
259
338
  const { instance, device } = make();
260
339
  device.connected = false;
261
340
  const cb = jest.fn();
262
341
  instance.setMultiStateLegacy({ '1': true }, cb);
263
342
  expect(device.update).not.toHaveBeenCalled();
264
- expect(cb).toHaveBeenCalledWith();
343
+ expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
265
344
  });
266
345
  });
267
346
 
@@ -125,8 +125,8 @@ describe('IrrigationSystemAccessory — category', () => {
125
125
  });
126
126
 
127
127
  describe('IrrigationSystemAccessory — service topology', () => {
128
- test('builds an IrrigationSystem (primary) + 4 linked valves + battery + contact sensor by default', () => {
129
- const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false, '46': 80, '49': 'no_rain' });
128
+ test('builds an IrrigationSystem (primary) + 4 linked valves + battery by default', () => {
129
+ const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false, '46': 80 });
130
130
 
131
131
  const irr = irrigation(accessory);
132
132
  expect(irr).toBeDefined();
@@ -138,9 +138,9 @@ describe('IrrigationSystemAccessory — service topology', () => {
138
138
  // All four valves are linked to the irrigation system.
139
139
  expect(irr.linked).toHaveLength(4);
140
140
 
141
- // Battery + ContactSensor present; LeakSensor absent.
141
+ // Battery present; no rain/leak sensor is ever created.
142
142
  expect(accessory.getService(Service.Battery)).toBeDefined();
143
- expect(accessory.getService(Service.ContactSensor)).toBeDefined();
143
+ expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
144
144
  expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
145
145
  });
146
146
 
@@ -154,6 +154,19 @@ describe('IrrigationSystemAccessory — service topology', () => {
154
154
  });
155
155
  });
156
156
 
157
+ test('adds a ServiceLabel (ARABIC_NUMERALS) so the multi-valve zones group under the system', () => {
158
+ const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false });
159
+ const label = accessory.getService(Service.ServiceLabel);
160
+ expect(label).toBeDefined();
161
+ expect(label.getCharacteristic(Characteristic.ServiceLabelNamespace).value)
162
+ .toBe(Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS);
163
+ });
164
+
165
+ test('a single-valve system omits the ServiceLabel (no collection to label)', () => {
166
+ const { accessory } = makeHarness({ '1': false }, { valveCount: 1 });
167
+ expect(accessory.getService(Service.ServiceLabel)).toBeUndefined();
168
+ });
169
+
157
170
  test('IrrigationSystem advertises the required characteristics', () => {
158
171
  const { accessory } = makeHarness({ '1': false });
159
172
  const irr = irrigation(accessory);
@@ -162,17 +175,23 @@ describe('IrrigationSystemAccessory — service topology', () => {
162
175
  expect(irr.getCharacteristic(Characteristic.InUse).value).toBe(Characteristic.InUse.NOT_IN_USE);
163
176
  });
164
177
 
165
- test('noBattery / noRainSensor omit those services', () => {
166
- const { accessory } = makeHarness({ '1': false }, { noBattery: true, noRainSensor: true });
178
+ test('noBattery omits the battery service', () => {
179
+ const { accessory } = makeHarness({ '1': false }, { noBattery: true });
167
180
  expect(accessory.getService(Service.Battery)).toBeUndefined();
168
- expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
169
- expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
170
181
  });
171
182
 
172
- test('rainSensorType "leak" uses a LeakSensor instead of a ContactSensor', () => {
173
- const { accessory } = makeHarness({ '49': 'rain' }, { rainSensorType: 'leak' });
174
- expect(accessory.getService(Service.LeakSensor)).toBeDefined();
183
+ test('removes a stale rain sensor service left over from an older version', () => {
184
+ const { instance, accessory } = makeHarness({ '1': false });
185
+ // An accessory cached by an older plugin version may still carry the
186
+ // ContactSensor/LeakSensor; reconciliation must drop it so the sprinkler
187
+ // stays a clean, single-category tile.
188
+ accessory.addService(Service.ContactSensor, 'Old Rain');
189
+ accessory.addService(Service.LeakSensor, 'Old Leak');
190
+
191
+ instance._verifyCachedPlatformAccessory();
192
+
175
193
  expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
194
+ expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
176
195
  });
177
196
  });
178
197
 
@@ -279,6 +298,38 @@ describe('IrrigationSystemAccessory — valve activation & timer', () => {
279
298
  jest.advanceTimersByTime(600 * 1000);
280
299
  expect(device.update.mock.calls.length).toBe(calls);
281
300
  });
301
+
302
+ test('valve Active onGet reports the optimistic value, not the lagging device state (no flicker)', () => {
303
+ // Cloud devices don't advance device.state until the realtime stream
304
+ // echoes the write back. Emulate that (update is a no-op on state) and
305
+ // confirm a freshly-pressed valve keeps reading ON instead of briefly
306
+ // reverting to the pre-press value.
307
+ const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 0 });
308
+ device.update.mockImplementation(() => true);
309
+ const v = valve(accessory, 1);
310
+
311
+ v.getCharacteristic(Characteristic.Active).triggerSet(1);
312
+ jest.advanceTimersByTime(500);
313
+
314
+ expect(device.state['1']).toBe(false); // not echoed yet
315
+ expect(v.getCharacteristic(Characteristic.Active).triggerGet()).toBe(Characteristic.Active.ACTIVE);
316
+ });
317
+
318
+ test('system Active onGet reports the cached aggregate, not the lagging device state', () => {
319
+ const { accessory, device } = makeHarness({ '1': false, '2': false }, { defaultDuration: 0 });
320
+ device.update.mockImplementation(() => true);
321
+ valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
322
+ jest.advanceTimersByTime(500);
323
+
324
+ expect(irrigation(accessory).getCharacteristic(Characteristic.Active).triggerGet())
325
+ .toBe(Characteristic.Active.ACTIVE);
326
+ });
327
+
328
+ test('Active onGet still throws while disconnected (HomeKit shows "No Response")', () => {
329
+ const { accessory, device } = makeHarness({ '1': false });
330
+ device.connected = false;
331
+ expect(() => valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerGet()).toThrow();
332
+ });
282
333
  });
283
334
 
284
335
  describe('IrrigationSystemAccessory — write batching (one Tuya command)', () => {
@@ -361,16 +412,13 @@ describe('IrrigationSystemAccessory — device-side change reflection', () => {
361
412
  expect(v.getCharacteristic(Characteristic.RemainingDuration).value).toBe(600);
362
413
  });
363
414
 
364
- test('battery + rain telemetry updates the corresponding characteristics', () => {
365
- const { accessory, device } = makeHarness({ '46': 80, '49': 'no_rain' });
366
- device.emitChange({ '46': 10, '49': 'rain' });
415
+ test('battery telemetry updates the corresponding characteristics', () => {
416
+ const { accessory, device } = makeHarness({ '46': 80 });
417
+ device.emitChange({ '46': 10 });
367
418
 
368
419
  const battery = accessory.getService(Service.Battery);
369
420
  expect(battery.getCharacteristic(Characteristic.BatteryLevel).value).toBe(10);
370
421
  expect(battery.getCharacteristic(Characteristic.StatusLowBattery).value).toBe(Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW);
371
-
372
- const contact = accessory.getService(Service.ContactSensor);
373
- expect(contact.getCharacteristic(Characteristic.ContactSensorState).value).toBe(Characteristic.ContactSensorState.CONTACT_NOT_DETECTED);
374
422
  });
375
423
  });
376
424
 
@@ -425,71 +473,77 @@ describe('IrrigationSystemAccessory — charging state', () => {
425
473
  });
426
474
  });
427
475
 
428
- describe('IrrigationSystemAccessory — rain mapping', () => {
429
- test('contact sensor: rain NOT_DETECTED, no_rain DETECTED', () => {
430
- const { instance } = makeHarness();
431
- expect(instance._contactState('rain')).toBe(Characteristic.ContactSensorState.CONTACT_NOT_DETECTED);
432
- expect(instance._contactState('no_rain')).toBe(Characteristic.ContactSensorState.CONTACT_DETECTED);
433
- });
434
-
435
- test('rainInverted flips the polarity', () => {
436
- const { instance } = makeHarness({}, { rainInverted: true });
437
- expect(instance._contactState('rain')).toBe(Characteristic.ContactSensorState.CONTACT_DETECTED);
438
- expect(instance._contactState('no_rain')).toBe(Characteristic.ContactSensorState.CONTACT_NOT_DETECTED);
439
- });
440
-
441
- test('leak sensor maps rain → detected', () => {
442
- const { instance } = makeHarness({}, { rainSensorType: 'leak' });
443
- expect(instance._rainDetected('rain')).toBe(true);
444
- expect(instance._rainDetected('no_rain')).toBe(false);
445
- });
446
- });
447
-
448
- describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)', () => {
449
- // Mirrors a real Tuya "sfkzq" watering controller reached over the cloud:
450
- // valves are switch_1..4, battery is battery_percentage, and there is no
451
- // rain sensor.
452
- const cloudState = () => ({ switch_1: false, switch_2: false, switch_3: false, switch_4: false, battery_percentage: 99 });
453
- const cloudCtx = { cloud: true, noRainSensor: true };
454
-
476
+ describe('IrrigationSystemAccessory — data-points addressed by code', () => {
477
+ // The accessory is transport-agnostic: a data-point may be a numeric id or a
478
+ // Tuya "code". The LAN+cloud TuyaDevice keeps state dual-keyed and translates
479
+ // writes, so irrigation has no cloud-specific logic — it just uses the dp
480
+ // strings it's given. A device reached over the cloud commonly addresses its
481
+ // zones as switch_1.. and battery as battery_percentage.
455
482
  beforeEach(() => jest.useFakeTimers());
456
483
  afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
457
484
 
458
- test('default valves use Tuya codes switch_1..switch_4 (not numeric ids)', () => {
459
- const { accessory } = makeHarness(cloudState(), cloudCtx);
460
- ['switch_1', 'switch_2', 'switch_3', 'switch_4'].forEach(code => {
461
- expect(valve(accessory, code)).toBeTruthy();
462
- });
463
- expect(valve(accessory, '1')).toBeFalsy();
485
+ test('an explicit valve list may use string codes', () => {
486
+ const { accessory, device } = makeHarness(
487
+ { zone_a: false, zone_b: false, battery_percentage: 50 },
488
+ { valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], dpBattery: 'battery_percentage' }
489
+ );
490
+ expect(valve(accessory, 'zone_a')).toBeTruthy();
491
+ valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
492
+ jest.advanceTimersByTime(500);
493
+ expect(device.update).toHaveBeenCalledWith({ zone_b: true });
464
494
  });
465
495
 
466
- test('turning a zone on writes the code/value to the device', () => {
467
- const { accessory, device } = makeHarness(cloudState(), cloudCtx);
468
- valve(accessory, 'switch_1').getCharacteristic(Characteristic.Active).triggerSet(1);
469
- jest.advanceTimersByTime(500);
470
- expect(device.update).toHaveBeenCalledWith({ switch_1: true });
496
+ test('battery can be read from a code data-point', () => {
497
+ const { accessory } = makeHarness(
498
+ { zone_a: false, battery_percentage: 99 },
499
+ { valves: [{ name: 'A', dp: 'zone_a' }], dpBattery: 'battery_percentage' }
500
+ );
501
+ expect(accessory.getService(Service.Battery).getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
471
502
  });
472
503
 
473
- test('battery is read from the battery_percentage code', () => {
474
- const { accessory } = makeHarness(cloudState(), cloudCtx);
475
- const battery = accessory.getService(Service.Battery);
476
- expect(battery.getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
504
+ test('a device-side change keyed by code is reflected in HomeKit', () => {
505
+ const { accessory, device } = makeHarness(
506
+ { zone_a: false, zone_b: false },
507
+ { valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true }
508
+ );
509
+ device.emitChange({ zone_b: true });
510
+ expect(valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
477
511
  });
478
512
 
479
- test('a realtime change keyed by code is reflected in HomeKit', () => {
480
- const { accessory, device } = makeHarness(cloudState(), cloudCtx);
481
- device.emitChange({ switch_2: true });
482
- expect(valve(accessory, 'switch_2').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
513
+ test('turning a zone off still writes false when the device never echoed the "on"', () => {
514
+ // A device (notably over the cloud) may not optimistically advance `state`;
515
+ // it only moves when the device confirms. A follow-up "off" must STILL be
516
+ // sent — otherwise the valve stays open while HomeKit shows it closed (the
517
+ // exact "can turn on but not off" report).
518
+ const { accessory, device } = makeHarness(
519
+ { zone_a: false },
520
+ { valves: [{ name: 'A', dp: 'zone_a' }], noBattery: true, defaultDuration: 0 }
521
+ );
522
+ device.update.mockImplementation(() => true); // writes never touch state
523
+ const v = valve(accessory, 'zone_a');
524
+
525
+ v.getCharacteristic(Characteristic.Active).triggerSet(1);
526
+ jest.advanceTimersByTime(500);
527
+ expect(device.update).toHaveBeenLastCalledWith({ zone_a: true });
528
+
529
+ v.getCharacteristic(Characteristic.Active).triggerSet(0);
530
+ jest.advanceTimersByTime(500);
531
+ expect(device.update).toHaveBeenLastCalledWith({ zone_a: false });
483
532
  });
484
533
 
485
- test('an explicit valve list may use custom codes', () => {
534
+ test('master OFF closes zones the device has not echoed as open', () => {
486
535
  const { accessory, device } = makeHarness(
487
- { zone_a: false, zone_b: false, battery_percentage: 50 },
488
- { cloud: true, noRainSensor: true, valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }] }
536
+ { zone_a: false, zone_b: false },
537
+ { valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true, defaultDuration: 0 }
489
538
  );
490
- expect(valve(accessory, 'zone_a')).toBeTruthy();
539
+ device.update.mockImplementation(() => true); // writes never touch state
540
+
541
+ valve(accessory, 'zone_a').getCharacteristic(Characteristic.Active).triggerSet(1);
491
542
  valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
492
543
  jest.advanceTimersByTime(500);
493
- expect(device.update).toHaveBeenCalledWith({ zone_b: true });
544
+
545
+ irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
546
+ jest.advanceTimersByTime(500);
547
+ expect(device.update).toHaveBeenLastCalledWith({ zone_a: false, zone_b: false });
494
548
  });
495
549
  });
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const MultiOutletAccessory = require('../lib/MultiOutletAccessory');
4
- const { makeInstance } = require('./support/mocks');
4
+ const { makeInstance, HAP } = require('./support/mocks');
5
5
 
6
6
  function makeOutlet(state = {}) {
7
7
  return makeInstance(MultiOutletAccessory, state);
@@ -14,10 +14,13 @@ describe('MultiOutletAccessory.getPower', () => {
14
14
  expect(instance.getPower('2')).toBe(false);
15
15
  });
16
16
 
17
- test('throws when device is disconnected', () => {
17
+ test('throws a comm-failure HapStatusError when device is disconnected', () => {
18
18
  const { instance, device } = makeOutlet();
19
19
  device.connected = false;
20
- expect(() => instance.getPower('1')).toThrow('Not connected');
20
+ let err;
21
+ try { instance.getPower('1'); } catch (e) { err = e; }
22
+ expect(err).toBeInstanceOf(HAP.HapStatusError);
23
+ expect(err.hapStatus).toBe(HAP.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
21
24
  });
22
25
  });
23
26
 
@@ -83,4 +86,16 @@ describe('MultiOutletAccessory.setPower — debounce batching', () => {
83
86
  await p;
84
87
  expect(device.update).not.toHaveBeenCalled();
85
88
  });
89
+
90
+ test('surfaces a comm-failure error (No Response) when disconnected', () => {
91
+ // A tap on an unreachable outlet must fail in HomeKit instead of being
92
+ // silently dropped after the debounce.
93
+ const { instance, device } = makeOutlet({ '1': false });
94
+ device.connected = false;
95
+ let err;
96
+ try { instance.setPower('1', true); } catch (e) { err = e; }
97
+ expect(err).toBeInstanceOf(HAP.HapStatusError);
98
+ expect(err.hapStatus).toBe(HAP.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
99
+ expect(device.update).not.toHaveBeenCalled();
100
+ });
86
101
  });
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const RGBTWLightAccessory = require('../lib/RGBTWLightAccessory');
4
- const { makeInstance, makeMockCharacteristic } = require('./support/mocks');
4
+ const { makeInstance, makeMockCharacteristic, HAP } = require('./support/mocks');
5
5
 
6
6
  function makeLight(state = {}, context = {}) {
7
7
  const result = makeInstance(RGBTWLightAccessory, state, { colorFunction: 'HEXHSB', ...context });
@@ -88,12 +88,12 @@ describe('RGBTWLightAccessory.getColorTemperature', () => {
88
88
  // setColorTemperature
89
89
  // ---------------------------------------------------------------------------
90
90
  describe('RGBTWLightAccessory.setColorTemperature', () => {
91
- test('does not throw when device is not connected (issue #34)', () => {
91
+ test('rejects (No Response) and writes nothing when device is not connected', async () => {
92
92
  const { instance, device } = makeLight({ '2': 'white', '4': 255 });
93
93
  instance.characteristicHue = makeMockCharacteristic(0);
94
94
  instance.characteristicSaturation = makeMockCharacteristic(0);
95
95
  device.connected = false;
96
- expect(() => instance.setColorTemperature(200)).not.toThrow();
96
+ await expect(instance.setColorTemperature(200)).rejects.toBeInstanceOf(HAP.HapStatusError);
97
97
  expect(device.update).not.toHaveBeenCalled();
98
98
  });
99
99
 
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const SimpleFanLightAccessory = require('../lib/SimpleFanLightAccessory');
4
- const { makeInstance } = require('./support/mocks');
4
+ const { makeInstance, HAP } = require('./support/mocks');
5
5
 
6
6
  // Mirror the state that _registerCharacteristics would establish (the mock
7
7
  // service shares a single characteristic, so wiring the fields manually keeps
@@ -290,10 +290,10 @@ describe('SimpleFanLightAccessory.getColorTemp / setColorTemp', () => {
290
290
  expect(device.update).toHaveBeenCalledWith({ '23': 1000 });
291
291
  });
292
292
 
293
- test('setColorTemp does not write when the device is disconnected', () => {
293
+ test('setColorTemp rejects (No Response) and writes nothing when disconnected', async () => {
294
294
  const { instance, device } = makeFanLight({ '23': 500 });
295
295
  device.connected = false;
296
- expect(() => instance.setColorTemp(370)).not.toThrow();
296
+ await expect(instance.setColorTemp(370)).rejects.toBeInstanceOf(HAP.HapStatusError);
297
297
  expect(device.update).not.toHaveBeenCalled();
298
298
  });
299
299
  });