homebridge-tuya-plus 3.14.0-dev.49 → 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.
|
@@ -28,16 +28,26 @@ const BaseAccessory = require('./BaseAccessory');
|
|
|
28
28
|
* Auto-shutoff is enforced two ways. HomeKit always gets a live software
|
|
29
29
|
* countdown (RemainingDuration) and a local timer that closes the zone when it
|
|
30
30
|
* expires. On top of that, when the device exposes Tuya's per-zone countdown
|
|
31
|
-
* data-points (countdown_1..n, DP 17.. by default),
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* Homebridge or the network drops out
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
31
|
+
* data-points (countdown_1..n, DP 17.. by default), switching a zone ON also
|
|
32
|
+
* sends the run length (whole minutes, in the SAME command as the switch) to the
|
|
33
|
+
* device's own countdown, so the valve still closes on schedule even if
|
|
34
|
+
* Homebridge or the network drops out mid-run. The two mechanisms are
|
|
35
|
+
* complementary: the software timer drives the UI and the precise (sub-minute)
|
|
36
|
+
* close while connected; the hardware countdown is the offline safety net.
|
|
37
|
+
*
|
|
38
|
+
* IMPORTANT: on these controllers the countdown dp is NOT a passive "duration"
|
|
39
|
+
* setting — writing it STARTS a run and switches the zone ON. It is therefore
|
|
40
|
+
* written only together with a switch-on, or to re-base/repair a zone that is
|
|
41
|
+
* already running; never while a zone is off, on connect, or merely because the
|
|
42
|
+
* Duration was edited. (An earlier version wrote it on connect to "fix" zones
|
|
43
|
+
* reading 0 and opened every valve at once — see the regression tests.)
|
|
44
|
+
*
|
|
45
|
+
* The countdown is (re)written on EVERY switch-on, in the SAME command as the
|
|
46
|
+
* switch — we do not rely on the device persisting it (it appears to reset once a
|
|
47
|
+
* run finishes), so each run carries its own fresh countdown matching the zone's
|
|
48
|
+
* current Duration (0 = indefinite). A zone opened at the device instead
|
|
49
|
+
* (physical button / Tuya app) is caught by the change handler, which gives it a
|
|
50
|
+
* countdown too when it comes up without a usable one.
|
|
41
51
|
*/
|
|
42
52
|
class IrrigationSystemAccessory extends BaseAccessory {
|
|
43
53
|
static getCategory(Categories) {
|
|
@@ -325,16 +335,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
325
335
|
valve.getCharacteristic(Characteristic.InUse)
|
|
326
336
|
.updateValue(on ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
|
|
327
337
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
// the
|
|
331
|
-
// is on an unbounded (0) or out-of-range countdown HomeKit can't show.
|
|
338
|
+
// Whether this zone can use the device's hardware countdown. Detection
|
|
339
|
+
// only gates the write paths; nothing is written on connect — writing
|
|
340
|
+
// the countdown dp starts a run and would open the zone.
|
|
332
341
|
cfg.countdownSupported = this._isCountdownSupported(cfg, dps);
|
|
333
|
-
this._reconcileCountdown(cfg, dps);
|
|
334
342
|
|
|
335
343
|
// Reflect any zone already running at startup (e.g. turned on at the
|
|
336
|
-
// device, or Homebridge restarted mid-run) and (re)arm its timer.
|
|
337
|
-
|
|
344
|
+
// device, or Homebridge restarted mid-run) and (re)arm its timer. A
|
|
345
|
+
// zone that is genuinely running can safely be given a countdown, so
|
|
346
|
+
// repair an unbounded/invalid one here too — closed zones are skipped.
|
|
347
|
+
if (on) {
|
|
348
|
+
this._reflectValve(cfg, true);
|
|
349
|
+
this._ensureRunningCountdown(cfg);
|
|
350
|
+
}
|
|
338
351
|
});
|
|
339
352
|
|
|
340
353
|
// --- Service Label namespace ---
|
|
@@ -388,8 +401,12 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
388
401
|
valveChanged = true;
|
|
389
402
|
this._reflectValve(cfg, !!changes[cfg.dp]);
|
|
390
403
|
}
|
|
391
|
-
|
|
392
|
-
|
|
404
|
+
// A zone switched on at the device (or already on) that's left without a
|
|
405
|
+
// usable countdown gets one so it still auto-closes, offline included.
|
|
406
|
+
// _ensureRunningCountdown only writes while the zone is on, so this can
|
|
407
|
+
// never open a closed zone.
|
|
408
|
+
if (cfg.countdownSupported && (changes.hasOwnProperty(cfg.dp) || changes.hasOwnProperty(cfg.dpCountdown))) {
|
|
409
|
+
this._ensureRunningCountdown(cfg);
|
|
393
410
|
}
|
|
394
411
|
});
|
|
395
412
|
|
|
@@ -536,15 +553,16 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
536
553
|
this.accessory.context.durations[cfg.dp] = seconds;
|
|
537
554
|
this.log.info('%s: zone "%s" duration set to %s', this.device.context.name, cfg.name, seconds ? (seconds + 's') : 'indefinite');
|
|
538
555
|
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
//
|
|
556
|
+
// If the zone is running, re-base its countdown on the new duration — both
|
|
557
|
+
// the software timer and (safely, since the zone is already on) the device's
|
|
558
|
+
// hardware countdown. We deliberately do NOT write the hardware countdown
|
|
559
|
+
// while the zone is off: writing it starts a run and would open the valve.
|
|
560
|
+
// An off zone's new duration is applied the next time it is switched on
|
|
561
|
+
// (and the device persists it from there).
|
|
545
562
|
const service = this._valveService(cfg);
|
|
546
563
|
if (service && service.getCharacteristic(Characteristic.InUse).value === Characteristic.InUse.IN_USE) {
|
|
547
564
|
this._clearTimer(cfg.dp);
|
|
565
|
+
this._queueCountdownWrite(cfg);
|
|
548
566
|
const remainingChar = service.getCharacteristic(Characteristic.RemainingDuration);
|
|
549
567
|
if (seconds > 0) {
|
|
550
568
|
this._endTimes[cfg.dp] = Date.now() + seconds * 1000;
|
|
@@ -579,8 +597,15 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
579
597
|
}
|
|
580
598
|
|
|
581
599
|
/* ------------------------------------------------------------------ *
|
|
582
|
-
* Native (hardware) countdown
|
|
583
|
-
*
|
|
600
|
+
* Native (hardware) countdown
|
|
601
|
+
*
|
|
602
|
+
* Lets the device close a zone by itself after N minutes, so a run still
|
|
603
|
+
* ends even if Homebridge or the network drops out. The catch: writing the
|
|
604
|
+
* countdown dp STARTS a run and switches the zone ON, so it is only ever
|
|
605
|
+
* written as part of a switch-on, or to a zone that is already on — never
|
|
606
|
+
* while a zone is off and never on connect. It is re-sent on every switch-on
|
|
607
|
+
* (the device doesn't reliably keep it between runs), and the software timer
|
|
608
|
+
* is the second line of defence if the hardware countdown ever misbehaves.
|
|
584
609
|
* ------------------------------------------------------------------ */
|
|
585
610
|
|
|
586
611
|
// A zone can use the hardware countdown when the feature isn't disabled, the
|
|
@@ -614,55 +639,33 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
614
639
|
return Math.min(this._representableCountdownMinutes, Math.max(1, Math.round(s / 60)));
|
|
615
640
|
}
|
|
616
641
|
|
|
617
|
-
// Queue
|
|
618
|
-
// with whatever else is in flight (
|
|
642
|
+
// Queue this zone's current HomeKit duration to its hardware countdown dp,
|
|
643
|
+
// coalesced with whatever else is in flight (the switch-ON it accompanies, so
|
|
644
|
+
// both data-points are written in one command). MUST only be used as part of
|
|
645
|
+
// switching the zone on, or for a zone already on — writing the countdown
|
|
646
|
+
// starts a run. Sent on every switch-on (we don't rely on the device
|
|
647
|
+
// persisting it) so each run carries the current Duration; 0 = indefinite.
|
|
619
648
|
_queueCountdownWrite(cfg) {
|
|
620
649
|
if (!cfg.countdownSupported) return;
|
|
621
650
|
this._queueWrite(cfg.dpCountdown, this._durationToCountdownMinutes(this._getDuration(cfg)));
|
|
622
651
|
}
|
|
623
652
|
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
if (
|
|
634
|
-
|
|
635
|
-
if (
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
const minutes = this._durationToCountdownMinutes(this._getDuration(cfg));
|
|
641
|
-
if (minutes !== cd) {
|
|
642
|
-
this.log.debug('%s: zone "%s" hardware countdown was %s; setting it to %s', this.device.context.name, cfg.name, cd === 0 ? 'unbounded' : (cd + ' min'), minutes ? (minutes + ' min') : 'unbounded');
|
|
643
|
-
this._queueWrite(cfg.dpCountdown, minutes);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// A duration change reported by the device (e.g. set from the Tuya app). Only
|
|
649
|
-
// adopt it while the zone is idle: some units stream the *remaining* countdown
|
|
650
|
-
// down to zero while running, and treating that as a new SetDuration would
|
|
651
|
-
// corrupt the user's setting. A change seen while idle is a real reconfigure.
|
|
652
|
-
_onCountdownChange(cfg, value) {
|
|
653
|
-
if (this._valveInUse(cfg)) return;
|
|
654
|
-
const cd = this._parseCountdown(value);
|
|
655
|
-
if (cd !== null && cd >= 1 && cd <= this._representableCountdownMinutes) this._adoptCountdown(cfg, cd);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
_adoptCountdown(cfg, minutes) {
|
|
659
|
-
const {Characteristic} = this.hap;
|
|
660
|
-
const seconds = minutes * 60;
|
|
661
|
-
if (this._getDuration(cfg) === seconds) return;
|
|
662
|
-
this.accessory.context.durations[cfg.dp] = seconds;
|
|
663
|
-
const service = this._valveService(cfg);
|
|
664
|
-
if (service) service.getCharacteristic(Characteristic.SetDuration).updateValue(seconds);
|
|
665
|
-
this.log.debug('%s: zone "%s" adopted the device\'s %s min countdown as its duration', this.device.context.name, cfg.name, minutes);
|
|
653
|
+
// For a zone that is RUNNING, make sure the device has a usable countdown so it
|
|
654
|
+
// still auto-closes (offline included). Used when a zone is switched on at the
|
|
655
|
+
// device, or was already on at startup. Only ever writes while the zone is on,
|
|
656
|
+
// so it can never open a closed one. A countdown the device already holds at a
|
|
657
|
+
// valid value (e.g. a duration the device or Tuya app set for this run) is
|
|
658
|
+
// respected; only an unbounded (0) or out-of-range one is replaced with the
|
|
659
|
+
// zone's HomeKit duration. Does nothing for an indefinite (0) HomeKit
|
|
660
|
+
// duration — there's no finite run to enforce.
|
|
661
|
+
_ensureRunningCountdown(cfg) {
|
|
662
|
+
if (!cfg.countdownSupported || !this._valveInUse(cfg)) return;
|
|
663
|
+
const minutes = this._durationToCountdownMinutes(this._getDuration(cfg));
|
|
664
|
+
if (minutes <= 0) return;
|
|
665
|
+
const cd = this._parseCountdown(this.device.state[cfg.dpCountdown]);
|
|
666
|
+
if (cd === null || (cd >= 1 && cd <= this._representableCountdownMinutes)) return;
|
|
667
|
+
this.log.debug('%s: zone "%s" is running with countdown %s; setting it to %s min', this.device.context.name, cfg.name, cd === 0 ? 'unbounded' : cd, minutes);
|
|
668
|
+
this._queueWrite(cfg.dpCountdown, minutes);
|
|
666
669
|
}
|
|
667
670
|
|
|
668
671
|
_valveInUse(cfg) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.14.0-dev.
|
|
3
|
+
"version": "3.14.0-dev.51",
|
|
4
4
|
"description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -456,10 +456,31 @@ describe('IrrigationSystemAccessory — native (hardware) countdown', () => {
|
|
|
456
456
|
expect(instance._durationToCountdownMinutes(999999)).toBe(120); // capped at the 120-min hardware max
|
|
457
457
|
});
|
|
458
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
|
+
|
|
459
480
|
/* --- turning a zone on hands the device its own timer (the offline safety net) --- */
|
|
460
481
|
|
|
461
482
|
test('turning a zone on sends the hardware countdown alongside the switch (one command)', () => {
|
|
462
|
-
const { accessory, device } = makeHarness({ '1': false, '17':
|
|
483
|
+
const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 600 });
|
|
463
484
|
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
464
485
|
jest.advanceTimersByTime(500);
|
|
465
486
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
@@ -470,7 +491,7 @@ describe('IrrigationSystemAccessory — native (hardware) countdown', () => {
|
|
|
470
491
|
|
|
471
492
|
test('master ON hands every zone its hardware countdown in one command', () => {
|
|
472
493
|
const { accessory, device } = makeHarness(
|
|
473
|
-
{ '1': false, '2': false, '3': false, '4': false, '17':
|
|
494
|
+
{ '1': false, '2': false, '3': false, '4': false, '17': 0, '18': 0, '19': 0, '20': 0 },
|
|
474
495
|
{ defaultDuration: 600 }
|
|
475
496
|
);
|
|
476
497
|
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
@@ -479,7 +500,7 @@ describe('IrrigationSystemAccessory — native (hardware) countdown', () => {
|
|
|
479
500
|
expect(device.update).toHaveBeenCalledWith({ '1': true, '2': true, '3': true, '4': true, '17': 10, '18': 10, '19': 10, '20': 10 });
|
|
480
501
|
});
|
|
481
502
|
|
|
482
|
-
test('an indefinite duration
|
|
503
|
+
test('an indefinite duration turns the zone on with an unbounded (0) countdown', () => {
|
|
483
504
|
const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 0 });
|
|
484
505
|
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
485
506
|
jest.advanceTimersByTime(500);
|
|
@@ -487,67 +508,71 @@ describe('IrrigationSystemAccessory — native (hardware) countdown', () => {
|
|
|
487
508
|
expect(device.update).toHaveBeenCalledWith({ '1': true, '17': 0 });
|
|
488
509
|
});
|
|
489
510
|
|
|
490
|
-
test('
|
|
491
|
-
const { accessory, device } = makeHarness({ '1': false, '17':
|
|
492
|
-
valve(accessory, 1).getCharacteristic(Characteristic.
|
|
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);
|
|
493
514
|
jest.advanceTimersByTime(500);
|
|
494
|
-
expect(device.update).toHaveBeenCalledWith({ '17':
|
|
515
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true, '17': 1 });
|
|
495
516
|
});
|
|
496
517
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
test('on connect, an unbounded (0) device countdown is set to the HomeKit duration', () => {
|
|
518
|
+
test('changing the Duration while a zone is RUNNING re-bases the hardware countdown', () => {
|
|
500
519
|
const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 600 });
|
|
520
|
+
const v = valve(accessory, 1);
|
|
521
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
501
522
|
jest.advanceTimersByTime(500);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
expect(
|
|
523
|
+
device.update.mockClear();
|
|
524
|
+
v.getCharacteristic(Characteristic.SetDuration).triggerSet(1200);
|
|
525
|
+
jest.advanceTimersByTime(500);
|
|
526
|
+
expect(device.update).toHaveBeenCalledWith({ '17': 20 });
|
|
506
527
|
});
|
|
507
528
|
|
|
508
|
-
test('
|
|
509
|
-
|
|
510
|
-
|
|
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);
|
|
511
536
|
jest.advanceTimersByTime(500);
|
|
512
|
-
expect(device.update).
|
|
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 });
|
|
513
544
|
});
|
|
514
545
|
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
517
552
|
jest.advanceTimersByTime(500);
|
|
518
553
|
expect(device.update).toHaveBeenCalledWith({ '17': 10 });
|
|
519
|
-
expect(valve(accessory, 1).getCharacteristic(Characteristic.SetDuration).value).toBe(600);
|
|
520
554
|
});
|
|
521
555
|
|
|
522
|
-
test('
|
|
523
|
-
const {
|
|
524
|
-
|
|
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
|
|
525
560
|
jest.advanceTimersByTime(500);
|
|
526
561
|
expect(device.update).not.toHaveBeenCalled();
|
|
527
562
|
});
|
|
528
563
|
|
|
529
|
-
test('
|
|
530
|
-
const { device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration:
|
|
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 });
|
|
531
568
|
jest.advanceTimersByTime(500);
|
|
532
|
-
expect(device.update).
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
/* --- device-side countdown changes --- */
|
|
536
|
-
|
|
537
|
-
test('a duration change from the device (Tuya app) is adopted while the zone is idle', () => {
|
|
538
|
-
const { accessory, device } = makeHarness({ '1': false, '17': 10 }, { defaultDuration: 600 });
|
|
539
|
-
device.emitChange({ '17': 25 });
|
|
540
|
-
expect(valve(accessory, 1).getCharacteristic(Characteristic.SetDuration).value).toBe(1500);
|
|
569
|
+
expect(device.update).not.toHaveBeenCalled();
|
|
541
570
|
});
|
|
542
571
|
|
|
543
|
-
test('a
|
|
544
|
-
const {
|
|
545
|
-
const v = valve(accessory, 1);
|
|
546
|
-
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
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 });
|
|
547
574
|
jest.advanceTimersByTime(500);
|
|
548
|
-
|
|
549
|
-
device.emitChange({ '17': 5 });
|
|
550
|
-
expect(v.getCharacteristic(Characteristic.SetDuration).value).toBe(before);
|
|
575
|
+
expect(device.update).toHaveBeenCalledWith({ '17': 10 });
|
|
551
576
|
});
|
|
552
577
|
|
|
553
578
|
/* --- opt-out & unsupported devices --- */
|
|
@@ -794,13 +794,13 @@ The defaults match the common 4-zone layout (valves A–D on data-points `1`–`
|
|
|
794
794
|
|
|
795
795
|
Each zone has its own **Duration**. When a zone is switched on it runs for that duration and is then closed automatically. Two mechanisms enforce this together:
|
|
796
796
|
|
|
797
|
-
* a **software timer** in the plugin drives HomeKit's live countdown and the precise (sub-minute) shut-off while Homebridge is connected — it also
|
|
798
|
-
* the device's **own countdown timer**, when the controller exposes one (`countdown_1..n`, DP `17..` by default).
|
|
797
|
+
* a **software timer** in the plugin drives HomeKit's live countdown and the precise (sub-minute) shut-off while Homebridge is connected — it also closes a zone that was switched on at the device or that was already running when Homebridge restarted; and
|
|
798
|
+
* the device's **own countdown timer**, when the controller exposes one (`countdown_1..n`, DP `17..` by default). When a zone is switched **on**, its run length (whole minutes) is sent to the hardware countdown in the *same command* as the switch, so the valve still closes on schedule **even if Homebridge or the network drops out while it's running** — the hardware closes itself. This is on by default whenever the device reports these data-points; set `nativeCountdown: false` to fall back to the software timer alone.
|
|
799
799
|
|
|
800
|
-
On
|
|
800
|
+
> On these controllers the countdown is **not** a passive setting — *writing it starts a run and opens the zone*. So the plugin only ever sets it while switching a zone on (or for a zone that's already running); it is **never** written on connect or while a zone is off. The two mechanisms are belt-and-suspenders: the hardware countdown handles the offline case, and the software timer still switches the zone off after its Duration if the hardware countdown is unreliable. A zone switched on at the device with no usable countdown is given one (so it still auto-closes offline); a valid countdown the device set itself (e.g. a duration chosen in the Tuya app) is left alone.
|
|
801
801
|
|
|
802
|
-
* Set a zone's duration to **`0`** to make it run **indefinitely** (until it is switched off again) — handy for long, manual watering tasks. (
|
|
803
|
-
* The hardware countdown is whole **minutes**, capped by the device at **120 min**; HomeKit's Duration is in seconds. The
|
|
802
|
+
* Set a zone's duration to **`0`** to make it run **indefinitely** (until it is switched off again) — handy for long, manual watering tasks. (No finite hardware countdown is sent, so the device won't auto-close it either.)
|
|
803
|
+
* The hardware countdown is whole **minutes**, capped by the device at **120 min**; HomeKit's Duration is in seconds. The value written is bounded by `maxDuration` (default `7200`s = 120 min) — lower `maxDuration` to e.g. `3600` to cap zone runs at 60 minutes.
|
|
804
804
|
* Apple's Home app only offers duration presets up to **1 hour**. For longer runs (up to `maxDuration`) or to preset "indefinite" zones, set `defaultDuration` / the per-valve `defaultDuration` in the config, or use the Eve app.
|
|
805
805
|
|
|
806
806
|
#### Master ("toggle all") switch
|