homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-dev.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish-dev.yml +298 -31
- package/AGENTS.md +162 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +16 -3
- package/Readme.MD +8 -32
- package/config.schema.json +79 -79
- package/index.js +599 -358
- package/lib/AirPurifierAccessory.js +1 -1
- package/lib/BaseAccessory.js +54 -17
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/DoorbellAccessory.js +9 -16
- package/lib/GarageDoorAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +260 -120
- package/lib/MultiOutletAccessory.js +3 -2
- package/lib/OilDiffuserAccessory.js +10 -8
- package/lib/RGBTWLightAccessory.js +13 -11
- package/lib/RGBTWOutletAccessory.js +11 -9
- package/lib/SimpleBlindsAccessory.js +5 -5
- package/lib/SimpleDimmer2Accessory.js +1 -1
- package/lib/SimpleDimmerAccessory.js +10 -6
- package/lib/SimpleGarageDoorAccessory.js +114 -25
- package/lib/SimpleHeaterAccessory.js +4 -4
- package/lib/SimpleLightAccessory.js +1 -1
- package/lib/SwitchAccessory.js +3 -2
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +75 -42
- package/lib/TuyaCloudApi.js +121 -8
- package/lib/TuyaCloudDevice.js +434 -31
- package/lib/TuyaCloudMessaging.js +34 -41
- package/lib/TuyaDevice.js +269 -0
- package/lib/TuyaDiscovery.js +25 -9
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +27 -25
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -6
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +293 -67
- package/test/MultiOutletAccessory.test.js +18 -3
- package/test/RGBTWLightAccessory.test.js +3 -3
- package/test/SimpleFanLightAccessory.test.js +3 -3
- package/test/SimpleGarageDoorAccessory.test.js +146 -19
- package/test/TuyaAccessory.protocol.test.js +76 -3
- package/test/TuyaCloudApi.test.js +110 -1
- package/test/TuyaCloudDevice.test.js +564 -2
- package/test/TuyaCloudMessaging.test.js +24 -5
- package/test/TuyaDevice.test.js +266 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +271 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +64 -34
- package/wiki/Tuya-Cloud-Setup.md +31 -108
|
@@ -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
|
|
129
|
-
const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false, '46': 80
|
|
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
|
|
141
|
+
// Battery present; no rain/leak sensor is ever created.
|
|
142
142
|
expect(accessory.getService(Service.Battery)).toBeDefined();
|
|
143
|
-
expect(accessory.getService(Service.ContactSensor)).
|
|
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
|
|
166
|
-
const { accessory } = makeHarness({ '1': false }, { noBattery: 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('
|
|
173
|
-
const { accessory } = makeHarness({ '
|
|
174
|
-
|
|
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,185 @@ describe('IrrigationSystemAccessory — device-side change reflection', () => {
|
|
|
361
412
|
expect(v.getCharacteristic(Characteristic.RemainingDuration).value).toBe(600);
|
|
362
413
|
});
|
|
363
414
|
|
|
364
|
-
test('battery
|
|
365
|
-
const { accessory, device } = makeHarness({ '46': 80
|
|
366
|
-
device.emitChange({ '46': 10
|
|
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);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe('IrrigationSystemAccessory — native (hardware) countdown', () => {
|
|
426
|
+
beforeEach(() => jest.useFakeTimers());
|
|
427
|
+
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
428
|
+
|
|
429
|
+
/* --- data-point resolution --- */
|
|
430
|
+
|
|
431
|
+
test('derives each zone\'s countdown data-point as its switch dp + 16 by default', () => {
|
|
432
|
+
const { instance } = makeHarness();
|
|
433
|
+
expect(instance._getValveConfigs().map(c => c.dpCountdown)).toEqual(['17', '18', '19', '20']);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('a custom zone may map an explicit countdown data-point', () => {
|
|
437
|
+
const { instance } = makeHarness({}, { valves: [{ name: 'Lawn', dp: 'switch_1', dpCountdown: 'countdown_1' }] });
|
|
438
|
+
const cfg = instance._getValveConfigs()[0];
|
|
439
|
+
expect(cfg.dpCountdown).toBe('countdown_1');
|
|
440
|
+
expect(cfg.countdownConfigured).toBe(true);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('a code-addressed zone gets no default countdown dp (must be set explicitly)', () => {
|
|
444
|
+
const { instance } = makeHarness({}, { valves: [{ name: 'Lawn', dp: 'switch_1' }] });
|
|
445
|
+
expect(instance._getValveConfigs()[0].dpCountdown).toBe('');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
/* --- seconds <-> minutes conversion --- */
|
|
449
|
+
|
|
450
|
+
test('converts SetDuration seconds to whole countdown minutes, never 0 for a real run', () => {
|
|
451
|
+
const { instance } = makeHarness({ '1': false, '17': 10 });
|
|
452
|
+
expect(instance._durationToCountdownMinutes(0)).toBe(0); // indefinite stays indefinite
|
|
453
|
+
expect(instance._durationToCountdownMinutes(30)).toBe(1); // sub-minute rounds up to 1, not 0
|
|
454
|
+
expect(instance._durationToCountdownMinutes(600)).toBe(10);
|
|
455
|
+
expect(instance._durationToCountdownMinutes(5400)).toBe(90);
|
|
456
|
+
expect(instance._durationToCountdownMinutes(999999)).toBe(120); // capped at the 120-min hardware max
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
/* --- THE pool-flood regression: writing a countdown opens the zone, so it
|
|
460
|
+
* must never be written while a zone is off or on connect --- */
|
|
461
|
+
|
|
462
|
+
test('on connect, zones sitting at countdown 0 are NEVER written to (must not open them)', () => {
|
|
463
|
+
const { device } = makeHarness(
|
|
464
|
+
{ '1': false, '2': false, '3': false, '4': false, '17': 0, '18': 0, '19': 0, '20': 0 },
|
|
465
|
+
{ defaultDuration: 600 }
|
|
466
|
+
);
|
|
467
|
+
jest.advanceTimersByTime(2000);
|
|
468
|
+
expect(device.update).not.toHaveBeenCalled();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('editing the Duration while a zone is OFF does not write to the device', () => {
|
|
472
|
+
const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 600 });
|
|
473
|
+
valve(accessory, 1).getCharacteristic(Characteristic.SetDuration).triggerSet(1800);
|
|
474
|
+
jest.advanceTimersByTime(2000);
|
|
475
|
+
expect(device.update).not.toHaveBeenCalled();
|
|
476
|
+
// The new duration is remembered (read back via the getter) for the next switch-on.
|
|
477
|
+
expect(valve(accessory, 1).getCharacteristic(Characteristic.SetDuration).triggerGet()).toBe(1800);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
/* --- turning a zone on hands the device its own timer (the offline safety net) --- */
|
|
481
|
+
|
|
482
|
+
test('turning a zone on sends the hardware countdown alongside the switch (one command)', () => {
|
|
483
|
+
const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 600 });
|
|
484
|
+
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
485
|
+
jest.advanceTimersByTime(500);
|
|
486
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
487
|
+
// The countdown rides along so the device closes the valve itself even if
|
|
488
|
+
// Homebridge/the network drops while it's running.
|
|
489
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true, '17': 10 });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('master ON hands every zone its hardware countdown in one command', () => {
|
|
493
|
+
const { accessory, device } = makeHarness(
|
|
494
|
+
{ '1': false, '2': false, '3': false, '4': false, '17': 0, '18': 0, '19': 0, '20': 0 },
|
|
495
|
+
{ defaultDuration: 600 }
|
|
496
|
+
);
|
|
497
|
+
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
498
|
+
jest.advanceTimersByTime(500);
|
|
499
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
500
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true, '2': true, '3': true, '4': true, '17': 10, '18': 10, '19': 10, '20': 10 });
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('an indefinite duration turns the zone on with an unbounded (0) countdown', () => {
|
|
504
|
+
const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 0 });
|
|
505
|
+
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
506
|
+
jest.advanceTimersByTime(500);
|
|
507
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
508
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true, '17': 0 });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('a sub-minute duration turns the zone on with a 1-min countdown, never 0', () => {
|
|
512
|
+
const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 30 });
|
|
513
|
+
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
514
|
+
jest.advanceTimersByTime(500);
|
|
515
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true, '17': 1 });
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('changing the Duration while a zone is RUNNING re-bases the hardware countdown', () => {
|
|
519
|
+
const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 600 });
|
|
520
|
+
const v = valve(accessory, 1);
|
|
521
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
522
|
+
jest.advanceTimersByTime(500);
|
|
523
|
+
device.update.mockClear();
|
|
524
|
+
v.getCharacteristic(Characteristic.SetDuration).triggerSet(1200);
|
|
525
|
+
jest.advanceTimersByTime(500);
|
|
526
|
+
expect(device.update).toHaveBeenCalledWith({ '17': 20 });
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test('the software timer still closes the zone after the duration, even with a hardware countdown set', () => {
|
|
530
|
+
// Belt-and-suspenders: if the hardware countdown is unreliable (e.g. the
|
|
531
|
+
// device clears it mid-run), Homebridge itself still switches the zone off
|
|
532
|
+
// once the duration elapses — independent of the hardware countdown.
|
|
533
|
+
const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 60 });
|
|
534
|
+
const v = valve(accessory, 1);
|
|
535
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
536
|
+
jest.advanceTimersByTime(500);
|
|
537
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true, '17': 1 }); // on + 1-min hardware countdown
|
|
538
|
+
device.update.mockClear();
|
|
539
|
+
|
|
540
|
+
jest.advanceTimersByTime(60 * 1000); // the HomeKit duration elapses
|
|
541
|
+
expect(v.getCharacteristic(Characteristic.Active).value).toBe(0);
|
|
542
|
+
jest.advanceTimersByTime(500); // flush the off-write
|
|
543
|
+
expect(device.update).toHaveBeenCalledWith({ '1': false });
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
/* --- zones turned on at the device (physical button / Tuya app) --- */
|
|
547
|
+
|
|
548
|
+
test('a zone switched on at the device with no countdown is given one so it still auto-closes', () => {
|
|
549
|
+
const { device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 600 });
|
|
550
|
+
device.update.mockClear();
|
|
551
|
+
device.emitChange({ '1': true }); // external on; device countdown still 0
|
|
552
|
+
jest.advanceTimersByTime(500);
|
|
553
|
+
expect(device.update).toHaveBeenCalledWith({ '17': 10 });
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('a zone switched on at the device with a valid countdown is left as the device set it', () => {
|
|
557
|
+
const { device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 600 });
|
|
558
|
+
device.update.mockClear();
|
|
559
|
+
device.emitChange({ '1': true, '17': 5 }); // external on with its own 5-min run
|
|
560
|
+
jest.advanceTimersByTime(500);
|
|
561
|
+
expect(device.update).not.toHaveBeenCalled();
|
|
562
|
+
});
|
|
371
563
|
|
|
372
|
-
|
|
373
|
-
|
|
564
|
+
test('an externally-started zone is left alone when its HomeKit duration is indefinite', () => {
|
|
565
|
+
const { device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 0 });
|
|
566
|
+
device.update.mockClear();
|
|
567
|
+
device.emitChange({ '1': true });
|
|
568
|
+
jest.advanceTimersByTime(500);
|
|
569
|
+
expect(device.update).not.toHaveBeenCalled();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('a zone already running at startup with no countdown is given one (offline-safe)', () => {
|
|
573
|
+
const { device } = makeHarness({ '1': true, '17': 0 }, { defaultDuration: 600 });
|
|
574
|
+
jest.advanceTimersByTime(500);
|
|
575
|
+
expect(device.update).toHaveBeenCalledWith({ '17': 10 });
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
/* --- opt-out & unsupported devices --- */
|
|
579
|
+
|
|
580
|
+
test('nativeCountdown:false never touches the countdown data-point', () => {
|
|
581
|
+
const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 600, nativeCountdown: false });
|
|
582
|
+
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
583
|
+
jest.advanceTimersByTime(500);
|
|
584
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
585
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true });
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('a device that reports no countdown data-point is unaffected (software timer only)', () => {
|
|
589
|
+
const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 600 });
|
|
590
|
+
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
591
|
+
jest.advanceTimersByTime(500);
|
|
592
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
593
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true });
|
|
374
594
|
});
|
|
375
595
|
});
|
|
376
596
|
|
|
@@ -425,71 +645,77 @@ describe('IrrigationSystemAccessory — charging state', () => {
|
|
|
425
645
|
});
|
|
426
646
|
});
|
|
427
647
|
|
|
428
|
-
describe('IrrigationSystemAccessory —
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
648
|
+
describe('IrrigationSystemAccessory — data-points addressed by code', () => {
|
|
649
|
+
// The accessory is transport-agnostic: a data-point may be a numeric id or a
|
|
650
|
+
// Tuya "code". The LAN+cloud TuyaDevice keeps state dual-keyed and translates
|
|
651
|
+
// writes, so irrigation has no cloud-specific logic — it just uses the dp
|
|
652
|
+
// strings it's given. A device reached over the cloud commonly addresses its
|
|
653
|
+
// zones as switch_1.. and battery as battery_percentage.
|
|
455
654
|
beforeEach(() => jest.useFakeTimers());
|
|
456
655
|
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
457
656
|
|
|
458
|
-
test('
|
|
459
|
-
const { accessory } = makeHarness(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
expect(valve(accessory, '
|
|
657
|
+
test('an explicit valve list may use string codes', () => {
|
|
658
|
+
const { accessory, device } = makeHarness(
|
|
659
|
+
{ zone_a: false, zone_b: false, battery_percentage: 50 },
|
|
660
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], dpBattery: 'battery_percentage' }
|
|
661
|
+
);
|
|
662
|
+
expect(valve(accessory, 'zone_a')).toBeTruthy();
|
|
663
|
+
valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
664
|
+
jest.advanceTimersByTime(500);
|
|
665
|
+
expect(device.update).toHaveBeenCalledWith({ zone_b: true });
|
|
464
666
|
});
|
|
465
667
|
|
|
466
|
-
test('
|
|
467
|
-
const { accessory
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
668
|
+
test('battery can be read from a code data-point', () => {
|
|
669
|
+
const { accessory } = makeHarness(
|
|
670
|
+
{ zone_a: false, battery_percentage: 99 },
|
|
671
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }], dpBattery: 'battery_percentage' }
|
|
672
|
+
);
|
|
673
|
+
expect(accessory.getService(Service.Battery).getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
|
|
471
674
|
});
|
|
472
675
|
|
|
473
|
-
test('
|
|
474
|
-
const { accessory } = makeHarness(
|
|
475
|
-
|
|
476
|
-
|
|
676
|
+
test('a device-side change keyed by code is reflected in HomeKit', () => {
|
|
677
|
+
const { accessory, device } = makeHarness(
|
|
678
|
+
{ zone_a: false, zone_b: false },
|
|
679
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true }
|
|
680
|
+
);
|
|
681
|
+
device.emitChange({ zone_b: true });
|
|
682
|
+
expect(valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
|
|
477
683
|
});
|
|
478
684
|
|
|
479
|
-
test('a
|
|
480
|
-
|
|
481
|
-
device.
|
|
482
|
-
|
|
685
|
+
test('turning a zone off still writes false when the device never echoed the "on"', () => {
|
|
686
|
+
// A device (notably over the cloud) may not optimistically advance `state`;
|
|
687
|
+
// it only moves when the device confirms. A follow-up "off" must STILL be
|
|
688
|
+
// sent — otherwise the valve stays open while HomeKit shows it closed (the
|
|
689
|
+
// exact "can turn on but not off" report).
|
|
690
|
+
const { accessory, device } = makeHarness(
|
|
691
|
+
{ zone_a: false },
|
|
692
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }], noBattery: true, defaultDuration: 0 }
|
|
693
|
+
);
|
|
694
|
+
device.update.mockImplementation(() => true); // writes never touch state
|
|
695
|
+
const v = valve(accessory, 'zone_a');
|
|
696
|
+
|
|
697
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
698
|
+
jest.advanceTimersByTime(500);
|
|
699
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: true });
|
|
700
|
+
|
|
701
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
702
|
+
jest.advanceTimersByTime(500);
|
|
703
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: false });
|
|
483
704
|
});
|
|
484
705
|
|
|
485
|
-
test('
|
|
706
|
+
test('master OFF closes zones the device has not echoed as open', () => {
|
|
486
707
|
const { accessory, device } = makeHarness(
|
|
487
|
-
{ zone_a: false, zone_b: false
|
|
488
|
-
{
|
|
708
|
+
{ zone_a: false, zone_b: false },
|
|
709
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true, defaultDuration: 0 }
|
|
489
710
|
);
|
|
490
|
-
|
|
711
|
+
device.update.mockImplementation(() => true); // writes never touch state
|
|
712
|
+
|
|
713
|
+
valve(accessory, 'zone_a').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
491
714
|
valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
492
715
|
jest.advanceTimersByTime(500);
|
|
493
|
-
|
|
716
|
+
|
|
717
|
+
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
718
|
+
jest.advanceTimersByTime(500);
|
|
719
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: false, zone_b: false });
|
|
494
720
|
});
|
|
495
721
|
});
|
|
@@ -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
|
-
|
|
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('
|
|
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(
|
|
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
|
|
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(
|
|
296
|
+
await expect(instance.setColorTemp(370)).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
297
297
|
expect(device.update).not.toHaveBeenCalled();
|
|
298
298
|
});
|
|
299
299
|
});
|