homebridge-tuya-plus 3.14.0-dev.48 → 3.14.0-dev.49

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.
@@ -758,6 +758,12 @@
758
758
  "title": "Data-point (numeric id, or cloud code)",
759
759
  "placeholder": "1 or switch_1"
760
760
  },
761
+ "dpCountdown": {
762
+ "type": ["integer", "string"],
763
+ "title": "Countdown data-point (numeric id, or cloud code)",
764
+ "description": "Optional. The device's built-in auto-off timer for this zone. Defaults to the switch data-point + 16 (so switch 1 → countdown 17). Set this only for code-addressed or non-standard zones.",
765
+ "placeholder": "17 or countdown_1"
766
+ },
761
767
  "defaultDuration": {
762
768
  "type": "integer",
763
769
  "title": "Default run time (seconds, 0 = run indefinitely)",
@@ -782,7 +788,16 @@
782
788
  "type": "integer",
783
789
  "title": "Maximum Run Time (seconds)",
784
790
  "placeholder": 7200,
785
- "description": "Upper bound advertised to HomeKit for the duration picker (HAP default is 3600 / 1h). Apple's Home app only offers presets up to 1h regardless; longer values can be set from the config or apps like Eve.",
791
+ "description": "Upper bound advertised to HomeKit for the duration picker (HAP default is 3600 / 1h). Apple's Home app only offers presets up to 1h regardless; longer values can be set from the config or apps like Eve. Also bounds the hardware countdown: a device countdown longer than this (or the 120-min hardware cap) is corrected on connect.",
792
+ "condition": {
793
+ "functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
794
+ }
795
+ },
796
+ "nativeCountdown": {
797
+ "type": "boolean",
798
+ "title": "Use the device's built-in countdown timer",
799
+ "placeholder": true,
800
+ "description": "When the controller exposes per-zone countdown data-points (countdown_1.., DP 17.. by default), mirror each zone's duration to the hardware timer so a zone still auto-closes on schedule even if Homebridge or the network drops out mid-run. Disable to fall back to the software-timer-only behaviour. Has no effect on devices that don't report a countdown data-point.",
786
801
  "condition": {
787
802
  "functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
788
803
  }
@@ -24,6 +24,20 @@ const BaseAccessory = require('./BaseAccessory');
24
24
  * command (the device is a laggy, battery-powered Wi-Fi unit), so turning the
25
25
  * whole system on/off — or running a scene that toggles several zones at once —
26
26
  * results in one network round-trip rather than a burst of them.
27
+ *
28
+ * Auto-shutoff is enforced two ways. HomeKit always gets a live software
29
+ * countdown (RemainingDuration) and a local timer that closes the zone when it
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).
27
41
  */
28
42
  class IrrigationSystemAccessory extends BaseAccessory {
29
43
  static getCategory(Categories) {
@@ -57,11 +71,14 @@ class IrrigationSystemAccessory extends BaseAccessory {
57
71
  if (!dp) {
58
72
  throw new Error(`The valve definition #${i + 1} is missing a 'dp': ${JSON.stringify(valve)}`);
59
73
  }
74
+ const dpCountdownExplicit = (valve && valve.dpCountdown !== undefined && valve.dpCountdown !== null) ? ('' + valve.dpCountdown).trim() : '';
60
75
  return {
61
76
  dp,
62
77
  name: (('' + (valve.name || '')).trim()) || ('Zone ' + (i + 1)),
63
78
  index: i + 1,
64
- duration: isFinite(valve.defaultDuration) ? parseInt(valve.defaultDuration) : defaultDuration
79
+ duration: isFinite(valve.defaultDuration) ? parseInt(valve.defaultDuration) : defaultDuration,
80
+ dpCountdown: dpCountdownExplicit || this._defaultCountdownDP(dp),
81
+ countdownConfigured: dpCountdownExplicit !== ''
65
82
  };
66
83
  });
67
84
  }
@@ -70,16 +87,31 @@ class IrrigationSystemAccessory extends BaseAccessory {
70
87
  const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
71
88
  const configs = [];
72
89
  for (let i = 0; i < count; i++) {
90
+ const dp = String(i + 1);
73
91
  configs.push({
74
- dp: String(i + 1),
92
+ dp,
75
93
  name: 'Valve ' + (letters[i] || (i + 1)),
76
94
  index: i + 1,
77
- duration: defaultDuration
95
+ duration: defaultDuration,
96
+ dpCountdown: this._defaultCountdownDP(dp),
97
+ countdownConfigured: false
78
98
  });
79
99
  }
80
100
  return configs;
81
101
  }
82
102
 
103
+ // Default countdown data-point for a zone. On these devices the per-zone
104
+ // countdown timers sit 16 ids above the switches (switch_1=DP 1 → countdown_1
105
+ // =DP 17, … switch_4=DP 4 → countdown_4=DP 20), so derive it from a numeric
106
+ // switch dp. A code-addressed switch (e.g. "switch_1") has no derivable
107
+ // numeric neighbour — the user must set `dpCountdown` explicitly for those —
108
+ // so return '' (native countdown stays off for that zone unless configured).
109
+ _defaultCountdownDP(switchDp) {
110
+ const trimmed = ('' + switchDp).trim();
111
+ const n = parseInt(trimmed, 10);
112
+ return (isFinite(n) && String(n) === trimmed) ? String(n + 16) : '';
113
+ }
114
+
83
115
  // Resolve a configurable data-point (a numeric id or a Tuya code), trimming and
84
116
  // falling back to a default when it isn't set.
85
117
  _resolveDP(value, fallback) {
@@ -204,6 +236,21 @@ class IrrigationSystemAccessory extends BaseAccessory {
204
236
  this._cascadeOn = this._coerceBoolean(this.device.context.masterTurnsOnAllZones, true);
205
237
  this._cascadeOff = this._coerceBoolean(this.device.context.masterTurnsOffAllZones, true);
206
238
 
239
+ // Native (hardware) per-zone countdown. On by default, but only ever used
240
+ // for a zone whose countdown data-point the device actually reports (see
241
+ // _isCountdownSupported); set nativeCountdown:false to force the legacy
242
+ // software-timer-only behaviour even on a device that supports it.
243
+ this._nativeCountdown = this._coerceBoolean(this.device.context.nativeCountdown, true);
244
+ // Tuya's per-zone countdown is an integer number of minutes, hardware-
245
+ // capped at 120 (0 = no limit). The largest countdown we treat as valid is
246
+ // also bounded by what HomeKit's SetDuration can represent (its advertised
247
+ // maxValue, _maxDuration seconds), so the two views never disagree. With
248
+ // the default 7200s (=120min) max this is the full 120; lower maxDuration
249
+ // to e.g. 3600 to have the device's longer/over-cap countdowns corrected
250
+ // down to 60min on connect.
251
+ this._maxCountdownMinutes = 120;
252
+ this._representableCountdownMinutes = Math.max(1, Math.min(this._maxCountdownMinutes, Math.floor(this._maxDuration / 60)));
253
+
207
254
  // Data-points default to this device's numeric ids; a Tuya code may be
208
255
  // configured instead and works over either transport.
209
256
  this.dpBattery = this._resolveDP(this.device.context.dpBattery, '46');
@@ -278,6 +325,13 @@ class IrrigationSystemAccessory extends BaseAccessory {
278
325
  valve.getCharacteristic(Characteristic.InUse)
279
326
  .updateValue(on ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
280
327
 
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.
332
+ cfg.countdownSupported = this._isCountdownSupported(cfg, dps);
333
+ this._reconcileCountdown(cfg, dps);
334
+
281
335
  // Reflect any zone already running at startup (e.g. turned on at the
282
336
  // device, or Homebridge restarted mid-run) and (re)arm its timer.
283
337
  if (on) this._reflectValve(cfg, true);
@@ -330,9 +384,13 @@ class IrrigationSystemAccessory extends BaseAccessory {
330
384
 
331
385
  let valveChanged = false;
332
386
  this._valves.forEach(cfg => {
333
- if (!changes.hasOwnProperty(cfg.dp)) return;
334
- valveChanged = true;
335
- this._reflectValve(cfg, !!changes[cfg.dp]);
387
+ if (changes.hasOwnProperty(cfg.dp)) {
388
+ valveChanged = true;
389
+ this._reflectValve(cfg, !!changes[cfg.dp]);
390
+ }
391
+ if (cfg.countdownSupported && changes.hasOwnProperty(cfg.dpCountdown)) {
392
+ this._onCountdownChange(cfg, changes[cfg.dpCountdown]);
393
+ }
336
394
  });
337
395
 
338
396
  if (changes.hasOwnProperty(this.dpBattery) && this._hasBattery()) {
@@ -384,6 +442,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
384
442
  }
385
443
 
386
444
  this._queueWrite(cfg.dp, on);
445
+ if (on) this._queueCountdownWrite(cfg);
387
446
  this._reflectValve(cfg, on);
388
447
  this._syncAggregate();
389
448
  }
@@ -456,6 +515,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
456
515
  const currentlyOn = !!(service && service.getCharacteristic(Characteristic.Active).value);
457
516
  if (currentlyOn === on) return;
458
517
  this._queueWrite(cfg.dp, on);
518
+ if (on) this._queueCountdownWrite(cfg);
459
519
  this._reflectValve(cfg, on);
460
520
  });
461
521
  this._syncAggregate();
@@ -476,6 +536,11 @@ class IrrigationSystemAccessory extends BaseAccessory {
476
536
  this.accessory.context.durations[cfg.dp] = seconds;
477
537
  this.log.info('%s: zone "%s" duration set to %s', this.device.context.name, cfg.name, seconds ? (seconds + 's') : 'indefinite');
478
538
 
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
+
479
544
  // If the zone is running, re-base its countdown on the new duration.
480
545
  const service = this._valveService(cfg);
481
546
  if (service && service.getCharacteristic(Characteristic.InUse).value === Characteristic.InUse.IN_USE) {
@@ -513,6 +578,99 @@ class IrrigationSystemAccessory extends BaseAccessory {
513
578
  return max;
514
579
  }
515
580
 
581
+ /* ------------------------------------------------------------------ *
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
584
+ * ------------------------------------------------------------------ */
585
+
586
+ // A zone can use the hardware countdown when the feature isn't disabled, the
587
+ // zone has a countdown data-point, and either the user mapped that dp
588
+ // explicitly or the device actually reports a plausible value there — so we
589
+ // never write a countdown to a device (or a dp) that isn't really one.
590
+ _isCountdownSupported(cfg, state) {
591
+ if (!this._nativeCountdown || !cfg.dpCountdown) return false;
592
+ if (cfg.countdownConfigured) return true;
593
+ return this._looksLikeCountdown(state[cfg.dpCountdown]);
594
+ }
595
+
596
+ _looksLikeCountdown(value) {
597
+ const n = this._parseCountdown(value);
598
+ return n !== null && n >= 0 && n <= this._maxCountdownMinutes;
599
+ }
600
+
601
+ _parseCountdown(value) {
602
+ if (value === undefined || value === null || value === '') return null;
603
+ const n = parseInt(value, 10);
604
+ return isFinite(n) ? n : null;
605
+ }
606
+
607
+ // SetDuration (seconds) -> hardware countdown (whole minutes). 0 stays 0 (both
608
+ // sides mean "no limit"); any other value is forced to at least 1 minute so a
609
+ // sub-minute duration can never round down to 0 and silently make the run
610
+ // unbounded, and is capped at what both the hardware and HomeKit allow.
611
+ _durationToCountdownMinutes(seconds) {
612
+ const s = Math.max(0, parseInt(seconds) || 0);
613
+ if (s === 0) return 0;
614
+ return Math.min(this._representableCountdownMinutes, Math.max(1, Math.round(s / 60)));
615
+ }
616
+
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).
619
+ _queueCountdownWrite(cfg) {
620
+ if (!cfg.countdownSupported) return;
621
+ this._queueWrite(cfg.dpCountdown, this._durationToCountdownMinutes(this._getDuration(cfg)));
622
+ }
623
+
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);
666
+ }
667
+
668
+ _valveInUse(cfg) {
669
+ const {Characteristic} = this.hap;
670
+ const service = this._valveService(cfg);
671
+ return !!(service && service.getCharacteristic(Characteristic.InUse).value === Characteristic.InUse.IN_USE);
672
+ }
673
+
516
674
  /* ------------------------------------------------------------------ *
517
675
  * Aggregation: roll zone state up to the IrrigationSystem service
518
676
  * ------------------------------------------------------------------ */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.48",
3
+ "version": "3.14.0-dev.49",
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": {
@@ -422,6 +422,153 @@ describe('IrrigationSystemAccessory — device-side change reflection', () => {
422
422
  });
423
423
  });
424
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
+ /* --- turning a zone on hands the device its own timer (the offline safety net) --- */
460
+
461
+ 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 });
463
+ valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
464
+ jest.advanceTimersByTime(500);
465
+ expect(device.update).toHaveBeenCalledTimes(1);
466
+ // The countdown rides along so the device closes the valve itself even if
467
+ // Homebridge/the network drops while it's running.
468
+ expect(device.update).toHaveBeenCalledWith({ '1': true, '17': 10 });
469
+ });
470
+
471
+ test('master ON hands every zone its hardware countdown in one command', () => {
472
+ const { accessory, device } = makeHarness(
473
+ { '1': false, '2': false, '3': false, '4': false, '17': 10, '18': 10, '19': 10, '20': 10 },
474
+ { defaultDuration: 600 }
475
+ );
476
+ irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(1);
477
+ jest.advanceTimersByTime(500);
478
+ expect(device.update).toHaveBeenCalledTimes(1);
479
+ expect(device.update).toHaveBeenCalledWith({ '1': true, '2': true, '3': true, '4': true, '17': 10, '18': 10, '19': 10, '20': 10 });
480
+ });
481
+
482
+ test('an indefinite duration writes an unbounded (0) hardware countdown', () => {
483
+ const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 0 });
484
+ valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
485
+ jest.advanceTimersByTime(500);
486
+ expect(device.update).toHaveBeenCalledTimes(1);
487
+ expect(device.update).toHaveBeenCalledWith({ '1': true, '17': 0 });
488
+ });
489
+
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);
493
+ jest.advanceTimersByTime(500);
494
+ expect(device.update).toHaveBeenCalledWith({ '17': 20 });
495
+ });
496
+
497
+ /* --- reconciliation on connect --- */
498
+
499
+ test('on connect, an unbounded (0) device countdown is set to the HomeKit duration', () => {
500
+ const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 600 });
501
+ 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);
506
+ });
507
+
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);
511
+ jest.advanceTimersByTime(500);
512
+ expect(device.update).not.toHaveBeenCalled();
513
+ });
514
+
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 });
517
+ jest.advanceTimersByTime(500);
518
+ expect(device.update).toHaveBeenCalledWith({ '17': 10 });
519
+ expect(valve(accessory, 1).getCharacteristic(Characteristic.SetDuration).value).toBe(600);
520
+ });
521
+
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);
525
+ jest.advanceTimersByTime(500);
526
+ expect(device.update).not.toHaveBeenCalled();
527
+ });
528
+
529
+ test('a sub-minute duration is corrected to 1 min, never left unbounded', () => {
530
+ const { device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 30 });
531
+ 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);
541
+ });
542
+
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);
547
+ 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);
551
+ });
552
+
553
+ /* --- opt-out & unsupported devices --- */
554
+
555
+ test('nativeCountdown:false never touches the countdown data-point', () => {
556
+ const { accessory, device } = makeHarness({ '1': false, '17': 0 }, { defaultDuration: 600, nativeCountdown: false });
557
+ valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
558
+ jest.advanceTimersByTime(500);
559
+ expect(device.update).toHaveBeenCalledTimes(1);
560
+ expect(device.update).toHaveBeenCalledWith({ '1': true });
561
+ });
562
+
563
+ test('a device that reports no countdown data-point is unaffected (software timer only)', () => {
564
+ const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 600 });
565
+ valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
566
+ jest.advanceTimersByTime(500);
567
+ expect(device.update).toHaveBeenCalledTimes(1);
568
+ expect(device.update).toHaveBeenCalledWith({ '1': true });
569
+ });
570
+ });
571
+
425
572
  describe('IrrigationSystemAccessory — battery mapping', () => {
426
573
  test('clamps the level into 0–100', () => {
427
574
  const { instance } = makeHarness();
@@ -792,9 +792,15 @@ The defaults match the common 4-zone layout (valves A–D on data-points `1`–`
792
792
 
793
793
  #### Per-zone timers and "indefinite" mode
794
794
 
795
- Each zone has its own **Duration**. When a zone is switched on it runs for that duration and the plugin closes it automatically (HomeKit's countdown is display-only — the plugin always enforces the shut-off, even after a Homebridge restart).
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
- * Set a zone's duration to **`0`** to make it run **indefinitely** (until it is switched off again) handy for long, manual watering tasks.
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.
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.
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.
798
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.
799
805
 
800
806
  #### Master ("toggle all") switch
@@ -816,17 +822,23 @@ Switching the whole Irrigation System tile **off** closes every open zone, and s
816
822
  /* Simple: number of valves on sequential data-points 1, 2, 3, … */
817
823
  "valveCount": 4,
818
824
  /* …or, for custom names / non-sequential data-points, define them explicitly
819
- (this overrides valveCount). defaultDuration is in seconds; 0 = indefinite. */
825
+ (this overrides valveCount). defaultDuration is in seconds; 0 = indefinite.
826
+ dpCountdown is the device's built-in auto-off timer for the zone; it
827
+ defaults to the switch dp + 16 (so switch 1 → countdown 17) and only needs
828
+ setting for code-addressed or non-standard zones. */
820
829
  "valves": [
821
830
  { "name": "Front Lawn", "dp": 1, "defaultDuration": 900 },
822
831
  { "name": "Back Lawn", "dp": 2, "defaultDuration": 900 },
823
832
  { "name": "Flower Beds","dp": 3, "defaultDuration": 600 },
824
- { "name": "Drip Line", "dp": 4, "defaultDuration": 0 }
833
+ { "name": "Drip Line", "dp": 4, "defaultDuration": 0, "dpCountdown": 20 }
825
834
  ],
826
835
 
827
836
  /* --- Timers --- */
828
837
  "defaultDuration": 600, /* default per-zone run time, seconds (0 = indefinite) */
829
838
  "maxDuration": 7200, /* upper bound advertised to HomeKit, seconds */
839
+ "nativeCountdown": true, /* mirror durations to the device's own countdown timer
840
+ (countdown_1.., DP 17..) so zones still auto-close
841
+ offline; set false for software-timer-only */
830
842
 
831
843
  /* --- Master switch behaviour --- */
832
844
  "masterTurnsOnAllZones": true,