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), each zone's duration is
32
- * mirrored to the hardware's own countdown timer (an integer number of minutes,
33
- * 0 = no limit, 120 = max), so the valve still closes on schedule even if
34
- * Homebridge or the network drops out while it is running. The two mechanisms
35
- * are complementary: the software timer drives the UI and the precise
36
- * (sub-minute) close while connected; the hardware timer is the offline safety
37
- * net. SetDuration is in seconds and capped by HomeKit at maxDuration, while the
38
- * hardware countdown is in minutes and capped at 120 _reconcileCountdown keeps
39
- * the two consistent on connect (and never leaves the device on a value HomeKit
40
- * can't represent, i.e. an unbounded 0 or anything above the advertised max).
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
- // Decide whether this zone can use the hardware countdown, then bring
329
- // the device and HomeKit into agreement: adopt a valid device value as
330
- // the zone's duration, or push HomeKit's duration down when the device
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
- if (on) this._reflectValve(cfg, true);
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
- if (cfg.countdownSupported && changes.hasOwnProperty(cfg.dpCountdown)) {
392
- this._onCountdownChange(cfg, changes[cfg.dpCountdown]);
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
- // Keep the device's hardware countdown in step with the new duration so a
540
- // later run including one started from the physical button or the Tuya
541
- // app still auto-closes on time (and offline).
542
- this._queueCountdownWrite(cfg);
543
-
544
- // If the zone is running, re-base its countdown on the new duration.
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 — keep the device's own per-zone timer
583
- * in step with HomeKit so a zone closes on schedule even while offline
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 a write of this zone's duration to its hardware countdown dp, coalesced
618
- // with whatever else is in flight (e.g. the switch-on it accompanies).
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
- // On connect, line the device's stored countdown up with HomeKit. A concrete,
625
- // representable value is adopted as the zone's duration (the hardware is the
626
- // source of truth for what it will actually do); an unbounded (0) or
627
- // out-of-range value is overwritten with HomeKit's own duration so the device
628
- // can't be left on a setting HomeKit can't show or one that would never
629
- // auto-close if the unit drops off the network mid-run.
630
- _reconcileCountdown(cfg, state) {
631
- if (!cfg.countdownSupported) return;
632
- const cd = this._parseCountdown(state[cfg.dpCountdown]);
633
- if (cd === null) return;
634
-
635
- if (cd >= 1 && cd <= this._representableCountdownMinutes) {
636
- // Don't adopt a value seen mid-run: a streamed "remaining" countdown
637
- // could masquerade as the set duration (see _onCountdownChange).
638
- if (!this._valveInUse(cfg)) this._adoptCountdown(cfg, cd);
639
- } else {
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.49",
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': 10 }, { defaultDuration: 600 });
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': 10, '18': 10, '19': 10, '20': 10 },
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 writes an unbounded (0) hardware countdown', () => {
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('changing SetDuration pushes the new value to the hardware countdown', () => {
491
- const { accessory, device } = makeHarness({ '1': false, '17': 10 }, { defaultDuration: 600 });
492
- valve(accessory, 1).getCharacteristic(Characteristic.SetDuration).triggerSet(1200);
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': 20 });
515
+ expect(device.update).toHaveBeenCalledWith({ '1': true, '17': 1 });
495
516
  });
496
517
 
497
- /* --- reconciliation on connect --- */
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
- expect(device.update).toHaveBeenCalledTimes(1);
503
- expect(device.update).toHaveBeenCalledWith({ '17': 10 });
504
- // HomeKit keeps its own duration; only the device was corrected.
505
- expect(valve(accessory, 1).getCharacteristic(Characteristic.SetDuration).value).toBe(600);
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('on connect, a valid device countdown is adopted as the zone duration (no write)', () => {
509
- const { accessory, device } = makeHarness({ '1': false, '17': 30 }, { defaultDuration: 600 });
510
- expect(valve(accessory, 1).getCharacteristic(Characteristic.SetDuration).value).toBe(1800);
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).not.toHaveBeenCalled();
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
- test('on connect, a countdown above the representable max is corrected (maxDuration 3600 60min cap)', () => {
516
- const { accessory, device } = makeHarness({ '1': false, '17': 90 }, { defaultDuration: 600, maxDuration: 3600 });
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('with the default 120-min cap, a 90-min device countdown is adopted rather than corrected', () => {
523
- const { accessory, device } = makeHarness({ '1': false, '17': 90 }, { defaultDuration: 600 });
524
- expect(valve(accessory, 1).getCharacteristic(Characteristic.SetDuration).value).toBe(5400);
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('a sub-minute duration is corrected to 1 min, never left unbounded', () => {
530
- const { device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 30 });
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).toHaveBeenCalledWith({ '17': 1 });
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 countdown change reported while the zone runs is ignored (could be a streamed remaining value)', () => {
544
- const { accessory, device } = makeHarness({ '1': false, '17': 10 }, { defaultDuration: 600 });
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
- const before = v.getCharacteristic(Characteristic.SetDuration).value;
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 re-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). Each zone's duration is mirrored to it (as whole minutes), 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.
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 connect the plugin lines the two up: a valid device countdown is adopted as the zone's Duration, while a device left on an unbounded (`0`) or out-of-range countdown is corrected to HomeKit's own Duration so a zone can never be stuck running with no auto-close.
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. (The hardware countdown is set to `0` too, which the device also reads as "no limit".)
803
- * The hardware countdown is whole **minutes**, capped by the device at **120 min**; HomeKit's Duration is in seconds. The largest device countdown the plugin treats as valid is also bounded by `maxDuration` (default `7200`s = 120 min) — lower `maxDuration` to e.g. `3600` to have any device countdown over 60 minutes corrected down on connect.
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