homebridge-tuya-plus 3.14.0-dev.47 → 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
  * ------------------------------------------------------------------ */
@@ -120,6 +120,11 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
120
120
  ? Characteristic.TargetDoorState.OPEN
121
121
  : Characteristic.TargetDoorState.CLOSED;
122
122
  this.accessory.context.cachedTargetDoorState = initialTarget;
123
+ // The level-triggered dispatch baseline (see setTargetDoorState). Held
124
+ // separately from cachedTargetDoorState because that one mirrors every
125
+ // reported value for HomeKit's benefit — including the transient "stopped"
126
+ // our own stop-before-close produces — which must not move this baseline.
127
+ this._committedTarget = initialTarget;
123
128
 
124
129
  this.characteristicTargetDoorState = service.getCharacteristic(Characteristic.TargetDoorState)
125
130
  .updateValue(initialTarget)
@@ -201,11 +206,24 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
201
206
  // (=OPEN), which would fight the close we're about to send. Ignore
202
207
  // status reports until the close has gone out.
203
208
  if (this.pendingCloseTimer) return;
204
- const isOpen = this._mapDpState(changes[this.dpState]);
209
+ const raw = typeof changes[this.dpState] === 'string' ? parseInt(changes[this.dpState], 10) : changes[this.dpState];
210
+ const isOpen = this._mapDpState(raw);
205
211
  if (isOpen === null) {
206
212
  this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: ignoring unknown state DP value ${JSON.stringify(changes[this.dpState])}`);
207
213
  return;
208
214
  }
215
+ // Keep the level-triggered dispatch baseline honest with the gate's real
216
+ // position, but only from a DEFINITIVE report — 12 (opening/open) or 13
217
+ // (closing/closed). A bare 11 (stopped) is skipped: it is exactly what our
218
+ // own stop-before-close pulse makes the controller report, and over the LAN
219
+ // that report can land just after the stop→close window. Letting it flip
220
+ // the committed target back to OPEN is what let HomeKit's repeat close fire
221
+ // a second stop-before-close into the already-closing gate. A genuine
222
+ // external open always passes through 12 first, so nothing is missed.
223
+ if (raw === STATE_OPENING_OR_OPEN || raw === STATE_CLOSING_OR_CLOSED) {
224
+ const {Characteristic} = this.hap;
225
+ this._committedTarget = isOpen ? Characteristic.TargetDoorState.OPEN : Characteristic.TargetDoorState.CLOSED;
226
+ }
209
227
  this._applyReportedState(isOpen);
210
228
  }
211
229
 
@@ -259,16 +277,17 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
259
277
  this._cancelPartialStop();
260
278
 
261
279
  // The opener is level-triggered: act only when the requested target
262
- // differs from the one already committed. HomeKit re-sends the same
263
- // target after a tap — a second controller echoing it, or its own retry
264
- // a few seconds later — and acting on that repeat would fire another
280
+ // differs from the one we've already committed to. HomeKit re-sends the
281
+ // same target after a tap — a second controller echoing it, or its own
282
+ // retry a few seconds later — and acting on that repeat would fire another
265
283
  // open/close. For a close that means a second stop-before-close into the
266
- // already-closing gate, halting it mid-travel and restarting it. A real
267
- // request always flips the target (the Home app toggles to the opposite
268
- // state), and the reported state keeps cachedTargetDoorState honest a
269
- // command the controller drops is reverted by the next status read — so
270
- // this never swallows a genuine request, only the redundant echoes.
271
- if (value === this.accessory.context.cachedTargetDoorState) {
284
+ // already-closing gate, halting it mid-travel and restarting it (the
285
+ // reported stutter). A genuine request always flips the target (the Home
286
+ // app toggles to the opposite state). The committed target is reconciled
287
+ // with the gate's real position in _onDeviceChange so external operation
288
+ // still works but only from a definitive report, never from the
289
+ // "stopped" transient our own stop produces, so the repeat stays caught.
290
+ if (value === this._committedTarget) {
272
291
  this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: target unchanged — ignoring repeat request`);
273
292
  return;
274
293
  }
@@ -282,6 +301,7 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
282
301
  _applyTarget(value) {
283
302
  const {Characteristic} = this.hap;
284
303
  const open = value === Characteristic.TargetDoorState.OPEN;
304
+ this._committedTarget = value;
285
305
  this.accessory.context.cachedTargetDoorState = value;
286
306
  if (this.characteristicTargetDoorState) this.characteristicTargetDoorState.updateValue(value);
287
307
  if (open) this._sendOpen();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.47",
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();
@@ -68,6 +68,7 @@ function makeSimpleGarage(initialContext = {}) {
68
68
  updateValue: jest.fn().mockImplementation(function(v) { this.value = v; return this; }),
69
69
  };
70
70
  accessory.context.cachedTargetDoorState = TDS.CLOSED;
71
+ instance._committedTarget = TDS.CLOSED;
71
72
 
72
73
  // Mirror the persistent change listener registered in production.
73
74
  device.on('change', changes => instance._onDeviceChange(changes));
@@ -233,6 +234,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
233
234
  function openGate(instance, device, state = STATE_OPENING_OR_OPEN) {
234
235
  if (state !== undefined) device.state['105'] = state;
235
236
  instance.accessory.context.cachedTargetDoorState = TDS.OPEN;
237
+ instance._committedTarget = TDS.OPEN;
236
238
  }
237
239
 
238
240
  test('CLOSE fires immediately when the gate is already stopped (state 11)', () => {
@@ -330,6 +332,25 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
330
332
  expect(device.update).toHaveBeenCalledTimes(2); // swallowed — no second stop/close
331
333
  });
332
334
 
335
+ test('A stopped (11) report after the close does not re-enable the repeat (LAN stutter)', () => {
336
+ // Over the LAN the stop pulse makes the controller report 11 (=OPEN) just
337
+ // after the close has gone out — outside the stop-before-close window. That
338
+ // 11 must NOT move the committed target back to OPEN, or HomeKit's repeat
339
+ // close would fire a second stop-before-close into the already-closing gate.
340
+ const { instance, device } = makeSimpleGarage();
341
+ instance.stopBeforeCloseMs = 1500;
342
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
343
+
344
+ instance.setTargetDoorState(TDS.CLOSED);
345
+ jest.advanceTimersByTime(1500);
346
+ expect(device.update).toHaveBeenCalledTimes(2); // stop + close
347
+
348
+ emitState(device, STATE_STOPPED); // the stop's late 11 report
349
+ expect(instance._committedTarget).toBe(TDS.CLOSED); // not bounced to OPEN
350
+ instance.setTargetDoorState(TDS.CLOSED); // HomeKit's repeat
351
+ expect(device.update).toHaveBeenCalledTimes(2); // swallowed
352
+ });
353
+
333
354
  test('A close runs again once the gate is reported open (committed target tracks reports)', () => {
334
355
  const { instance, device } = makeSimpleGarage();
335
356
  instance.stopBeforeCloseMs = 1500;
@@ -631,7 +652,7 @@ describe('SimpleGarageDoorAccessory — force switches', () => {
631
652
  test('Force Close fires the close action (immediately when already stopped)', () => {
632
653
  const { instance, device, accessory } = makeSimpleGarage();
633
654
  device.state['105'] = STATE_STOPPED;
634
- accessory.context.cachedTargetDoorState = TDS.OPEN; // gate open → close is a real transition
655
+ instance._committedTarget = TDS.OPEN; // gate open → close is a real transition
635
656
 
636
657
  instance.setTargetDoorState(TDS.CLOSED);
637
658
 
@@ -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,