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.
package/config.schema.json
CHANGED
|
@@ -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
|
|
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 (
|
|
334
|
-
|
|
335
|
-
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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,
|