homebridge-tuya-plus 3.13.0 → 3.13.1-dev.1

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.
@@ -0,0 +1,611 @@
1
+ const BaseAccessory = require('./BaseAccessory');
2
+
3
+ /**
4
+ * IrrigationSystemAccessory
5
+ *
6
+ * A fully-fledged HomeKit irrigation controller for Tuya multi-valve devices
7
+ * (e.g. the 4-zone faucet/valve timers that expose `switch_1..switch_n`, a
8
+ * `battery_percentage` and a `rain_sensor_state`).
9
+ *
10
+ * HomeKit modelling (one bridged accessory, category SPRINKLER):
11
+ *
12
+ * IrrigationSystem (primary service — the "system" tile + master on/off)
13
+ * ├─ Valve "Zone A" (linked, ValveType=IRRIGATION, ServiceLabelIndex 1)
14
+ * ├─ Valve "Zone B" (linked, ServiceLabelIndex 2)
15
+ * ├─ ...
16
+ * Battery (BatteryLevel + StatusLowBattery)
17
+ * ContactSensor (rain — or LeakSensor, configurable)
18
+ *
19
+ * Each Valve carries its own SetDuration/RemainingDuration so the Home app shows
20
+ * the familiar per-zone "Duration" picker and a live countdown. A zone whose
21
+ * duration is 0 runs indefinitely (until it is turned off again) — handy for
22
+ * long manual watering tasks. All writes are coalesced into a single Tuya
23
+ * command (the device is a laggy, battery-powered Wi-Fi unit), so turning the
24
+ * whole system on/off — or running a scene that toggles several zones at once —
25
+ * results in one network round-trip rather than a burst of them.
26
+ */
27
+ class IrrigationSystemAccessory extends BaseAccessory {
28
+ static getCategory(Categories) {
29
+ return Categories.SPRINKLER;
30
+ }
31
+
32
+ constructor(...props) {
33
+ super(...props);
34
+ }
35
+
36
+ /* ------------------------------------------------------------------ *
37
+ * Configuration helpers
38
+ * ------------------------------------------------------------------ */
39
+
40
+ // Resolve the list of valves/zones from the device config. Defaults to the
41
+ // 4-zone A/B/C/D layout of the reference device (switch_1..switch_4 on
42
+ // DP 1..4). A `valves` array lets non-sequential / custom devices map their
43
+ // own data-points and names.
44
+ _getValveConfigs() {
45
+ // Self-contained (no reliance on instance state) so it is safe to call
46
+ // both during early service reconciliation and later when wiring
47
+ // characteristics.
48
+ const defaultDuration = isFinite(this.device.context.defaultDuration) ? parseInt(this.device.context.defaultDuration) : 600;
49
+
50
+ if (Array.isArray(this.device.context.valves) && this.device.context.valves.length) {
51
+ return this.device.context.valves.map((valve, i) => {
52
+ if (!valve || !isFinite(valve.dp) || parseInt(valve.dp) <= 0) {
53
+ throw new Error(`The valve definition #${i + 1} is missing a valid 'dp': ${JSON.stringify(valve)}`);
54
+ }
55
+ return {
56
+ dp: String(parseInt(valve.dp)),
57
+ name: (('' + (valve.name || '')).trim()) || ('Zone ' + (i + 1)),
58
+ index: i + 1,
59
+ duration: isFinite(valve.defaultDuration) ? parseInt(valve.defaultDuration) : defaultDuration
60
+ };
61
+ });
62
+ }
63
+
64
+ const count = Math.max(1, parseInt(this.device.context.valveCount) || 4);
65
+ const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
66
+ const configs = [];
67
+ for (let i = 0; i < count; i++) {
68
+ configs.push({
69
+ dp: String(i + 1),
70
+ name: 'Valve ' + (letters[i] || (i + 1)),
71
+ index: i + 1,
72
+ duration: defaultDuration
73
+ });
74
+ }
75
+ return configs;
76
+ }
77
+
78
+ _hasBattery() {
79
+ return !this._coerceBoolean(this.device.context.noBattery, false);
80
+ }
81
+
82
+ _hasRainSensor() {
83
+ return !this._coerceBoolean(this.device.context.noRainSensor, false);
84
+ }
85
+
86
+ _usesLeakSensor() {
87
+ return ('' + (this.device.context.rainSensorType || 'contact')).trim().toLowerCase() === 'leak';
88
+ }
89
+
90
+ /* ------------------------------------------------------------------ *
91
+ * Service registration / cache reconciliation
92
+ * ------------------------------------------------------------------ */
93
+
94
+ _registerPlatformAccessory() {
95
+ this._verifyCachedPlatformAccessory();
96
+ this._justRegistered = true;
97
+
98
+ super._registerPlatformAccessory();
99
+ }
100
+
101
+ _verifyCachedPlatformAccessory() {
102
+ if (this._justRegistered) return;
103
+
104
+ const {Service, Characteristic} = this.hap;
105
+
106
+ // --- IrrigationSystem (primary) ---
107
+ let irrigation = this.accessory.getService(Service.IrrigationSystem);
108
+ if (irrigation) this._checkServiceName(irrigation, this.device.context.name);
109
+ else irrigation = this.accessory.addService(Service.IrrigationSystem, this.device.context.name);
110
+ irrigation.setPrimaryService(true);
111
+
112
+ // --- Valves (one per zone), linked to the irrigation system ---
113
+ const valveConfigs = this._getValveConfigs();
114
+ const validValveSubtypes = [];
115
+ valveConfigs.forEach(cfg => {
116
+ const subtype = 'valve-' + cfg.dp;
117
+ validValveSubtypes.push(subtype);
118
+
119
+ let valve = this.accessory.getServiceById(Service.Valve, subtype);
120
+ if (valve) this._checkServiceName(valve, cfg.name);
121
+ else valve = this.accessory.addService(Service.Valve, cfg.name, subtype);
122
+
123
+ // Linking is what makes the Home app nest the zones under the single
124
+ // irrigation tile instead of scattering them as separate tiles.
125
+ // addLinkedService is idempotent, so it is safe to call on every
126
+ // cache reconcile.
127
+ irrigation.addLinkedService(valve);
128
+ });
129
+
130
+ // --- Battery (optional) ---
131
+ if (this._hasBattery()) {
132
+ if (!this.accessory.getService(Service.Battery)) {
133
+ this.accessory.addService(Service.Battery, this.device.context.name + ' Battery');
134
+ }
135
+ }
136
+
137
+ // --- Rain sensor (optional): Contact (default) or Leak ---
138
+ if (this._hasRainSensor()) {
139
+ const WantedSensor = this._usesLeakSensor() ? Service.LeakSensor : Service.ContactSensor;
140
+ const UnwantedSensor = this._usesLeakSensor() ? Service.ContactSensor : Service.LeakSensor;
141
+ // Drop the other sensor type if the user switched `rainSensorType`.
142
+ const stale = this.accessory.getService(UnwantedSensor);
143
+ if (stale) this.accessory.removeService(stale);
144
+ if (!this.accessory.getService(WantedSensor)) {
145
+ this.accessory.addService(WantedSensor, this.device.context.name + ' Rain');
146
+ }
147
+ }
148
+
149
+ // --- Remove services that no longer belong (config changed) ---
150
+ this.accessory.services
151
+ .filter(service => service.UUID === Service.Valve.UUID && !validValveSubtypes.includes(service.subtype))
152
+ .forEach(service => {
153
+ this.log.info('Removing stale valve service %s', service.displayName);
154
+ this.accessory.removeService(service);
155
+ });
156
+
157
+ if (!this._hasBattery()) {
158
+ const battery = this.accessory.getService(Service.Battery);
159
+ if (battery) this.accessory.removeService(battery);
160
+ }
161
+ if (!this._hasRainSensor()) {
162
+ [Service.ContactSensor, Service.LeakSensor].forEach(S => {
163
+ const svc = this.accessory.getService(S);
164
+ if (svc) this.accessory.removeService(svc);
165
+ });
166
+ }
167
+ }
168
+
169
+ /* ------------------------------------------------------------------ *
170
+ * Characteristic wiring
171
+ * ------------------------------------------------------------------ */
172
+
173
+ _registerCharacteristics(dps) {
174
+ this._verifyCachedPlatformAccessory();
175
+
176
+ const {Service, Characteristic} = this.hap;
177
+
178
+ // Tunables
179
+ this._defaultDuration = isFinite(this.device.context.defaultDuration) ? parseInt(this.device.context.defaultDuration) : 600;
180
+ // Max selectable duration. HAP's default max for SetDuration/
181
+ // RemainingDuration is 3600s (1h); raise it so longer runs aren't
182
+ // silently clamped. Apple's Home app honours the advertised maxValue.
183
+ this._maxDuration = isFinite(this.device.context.maxDuration) ? parseInt(this.device.context.maxDuration) : 7200;
184
+ this._lowBatteryThreshold = isFinite(this.device.context.lowBatteryThreshold) ? parseInt(this.device.context.lowBatteryThreshold) : 20;
185
+ this._debounce = isFinite(this.device.context.commandDebounce) ? parseInt(this.device.context.commandDebounce) : 500;
186
+ this._cascadeOn = this._coerceBoolean(this.device.context.masterTurnsOnAllZones, true);
187
+ this._cascadeOff = this._coerceBoolean(this.device.context.masterTurnsOffAllZones, true);
188
+
189
+ this.dpBattery = this._getCustomDP(this.device.context.dpBattery) || '46';
190
+ this.dpCharging = this._getCustomDP(this.device.context.dpCharging) || '101';
191
+ this.dpRain = this._getCustomDP(this.device.context.dpRain) || '49';
192
+ this._rainOnValue = ('' + (this.device.context.rainOnValue || 'rain')).trim();
193
+ this._rainInverted = this._coerceBoolean(this.device.context.rainInverted, false);
194
+
195
+ // Per-zone runtime state
196
+ this._valves = this._getValveConfigs();
197
+ this._timers = {}; // dp -> setTimeout handle for auto-shutoff
198
+ this._endTimes = {}; // dp -> ms timestamp when the run ends
199
+ this._pendingWrite = null;
200
+
201
+ // Persisted, user-editable per-zone durations and names.
202
+ this.accessory.context.durations = this.accessory.context.durations || {};
203
+ this.accessory.context.zoneNames = this.accessory.context.zoneNames || {};
204
+
205
+ const irrigation = this.accessory.getService(Service.IrrigationSystem);
206
+
207
+ // --- IrrigationSystem characteristics ---
208
+ irrigation.getCharacteristic(Characteristic.ProgramMode)
209
+ .updateValue(Characteristic.ProgramMode.NO_PROGRAM_SCHEDULED);
210
+
211
+ this._systemActiveChar = irrigation.getCharacteristic(Characteristic.Active)
212
+ .updateValue(this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
213
+ .onGet(() => this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
214
+ .onSet(value => this._setSystemActive(value));
215
+
216
+ this._systemInUseChar = irrigation.getCharacteristic(Characteristic.InUse)
217
+ .updateValue(this._anyValveOn() ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
218
+
219
+ this._systemRemainingChar = irrigation.getCharacteristic(Characteristic.RemainingDuration)
220
+ .setProps({maxValue: this._maxDuration})
221
+ .updateValue(0)
222
+ .onGet(() => this._systemRemaining());
223
+
224
+ // --- Valve characteristics ---
225
+ this._valves.forEach(cfg => {
226
+ const valve = this.accessory.getServiceById(Service.Valve, 'valve-' + cfg.dp);
227
+ const on = !!dps[cfg.dp];
228
+
229
+ valve.getCharacteristic(Characteristic.ValveType)
230
+ .updateValue(Characteristic.ValveType.IRRIGATION);
231
+ valve.getCharacteristic(Characteristic.IsConfigured)
232
+ .updateValue(Characteristic.IsConfigured.CONFIGURED);
233
+ valve.getCharacteristic(Characteristic.ServiceLabelIndex)
234
+ .updateValue(cfg.index);
235
+
236
+ // ConfiguredName so each zone shows its own name in the Home app
237
+ // (otherwise every zone inherits the accessory/system name).
238
+ if (Characteristic.ConfiguredName) {
239
+ const savedName = this.accessory.context.zoneNames[cfg.dp] || cfg.name;
240
+ valve.getCharacteristic(Characteristic.ConfiguredName)
241
+ .updateValue(savedName)
242
+ .onSet(value => { this.accessory.context.zoneNames[cfg.dp] = value; });
243
+ }
244
+
245
+ valve.getCharacteristic(Characteristic.SetDuration)
246
+ .setProps({maxValue: this._maxDuration})
247
+ .updateValue(this._getDuration(cfg))
248
+ .onGet(() => this._getDuration(cfg))
249
+ .onSet(value => this._setDuration(cfg, value));
250
+
251
+ valve.getCharacteristic(Characteristic.RemainingDuration)
252
+ .setProps({maxValue: this._maxDuration})
253
+ .updateValue(0)
254
+ .onGet(() => this._valveRemaining(cfg));
255
+
256
+ valve.getCharacteristic(Characteristic.Active)
257
+ .updateValue(on ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
258
+ .onGet(() => (this.getStateAsync(cfg.dp) ? 1 : 0))
259
+ .onSet(value => this._setValveActive(cfg, value));
260
+
261
+ valve.getCharacteristic(Characteristic.InUse)
262
+ .updateValue(on ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
263
+
264
+ // Reflect any zone already running at startup (e.g. turned on at the
265
+ // device, or Homebridge restarted mid-run) and (re)arm its timer.
266
+ if (on) this._reflectValve(cfg, true);
267
+ });
268
+
269
+ // --- Battery ---
270
+ if (this._hasBattery()) {
271
+ const battery = this.accessory.getService(Service.Battery);
272
+ const level = this._batteryLevel(dps[this.dpBattery]);
273
+ battery.getCharacteristic(Characteristic.BatteryLevel)
274
+ .updateValue(level)
275
+ .onGet(() => this._batteryLevel(this.getStateAsync(this.dpBattery)));
276
+ battery.getCharacteristic(Characteristic.StatusLowBattery)
277
+ .updateValue(this._lowBattery(level))
278
+ .onGet(() => this._lowBattery(this._batteryLevel(this.getStateAsync(this.dpBattery))));
279
+ // ChargingState follows the device's charging data-point when it
280
+ // reports one (solar / USB-C rechargeable units); devices that
281
+ // don't report charging fall back to NOT_CHARGEABLE.
282
+ battery.getCharacteristic(Characteristic.ChargingState)
283
+ .updateValue(this._chargingState(dps[this.dpCharging]))
284
+ .onGet(() => this._chargingState(this.getStateAsync(this.dpCharging)));
285
+ }
286
+
287
+ // --- Rain sensor ---
288
+ if (this._hasRainSensor()) {
289
+ if (this._usesLeakSensor()) {
290
+ const leak = this.accessory.getService(Service.LeakSensor);
291
+ leak.getCharacteristic(Characteristic.LeakDetected)
292
+ .updateValue(this._rainDetected(dps[this.dpRain]) ? Characteristic.LeakDetected.LEAK_DETECTED : Characteristic.LeakDetected.LEAK_NOT_DETECTED)
293
+ .onGet(() => this._rainDetected(this.getStateAsync(this.dpRain)) ? 1 : 0);
294
+ } else {
295
+ const contact = this.accessory.getService(Service.ContactSensor);
296
+ contact.getCharacteristic(Characteristic.ContactSensorState)
297
+ .updateValue(this._contactState(dps[this.dpRain]))
298
+ .onGet(() => this._contactState(this.getStateAsync(this.dpRain)));
299
+ }
300
+ }
301
+
302
+ this._syncAggregate();
303
+
304
+ // --- React to device-side changes (physical buttons, our own writes
305
+ // being confirmed, battery/rain telemetry) ---
306
+ this.device.on('change', (changes) => this._onDeviceChange(changes));
307
+ }
308
+
309
+ /* ------------------------------------------------------------------ *
310
+ * Device change handling
311
+ * ------------------------------------------------------------------ */
312
+
313
+ _onDeviceChange(changes) {
314
+ const {Service, Characteristic} = this.hap;
315
+
316
+ let valveChanged = false;
317
+ this._valves.forEach(cfg => {
318
+ if (!changes.hasOwnProperty(cfg.dp)) return;
319
+ valveChanged = true;
320
+ this._reflectValve(cfg, !!changes[cfg.dp]);
321
+ });
322
+
323
+ if (changes.hasOwnProperty(this.dpBattery) && this._hasBattery()) {
324
+ const battery = this.accessory.getService(Service.Battery);
325
+ if (battery) {
326
+ const level = this._batteryLevel(changes[this.dpBattery]);
327
+ battery.getCharacteristic(Characteristic.BatteryLevel).updateValue(level);
328
+ battery.getCharacteristic(Characteristic.StatusLowBattery).updateValue(this._lowBattery(level));
329
+ }
330
+ }
331
+
332
+ if (changes.hasOwnProperty(this.dpCharging) && this._hasBattery()) {
333
+ const battery = this.accessory.getService(Service.Battery);
334
+ if (battery) battery.getCharacteristic(Characteristic.ChargingState)
335
+ .updateValue(this._chargingState(changes[this.dpCharging]));
336
+ }
337
+
338
+ if (changes.hasOwnProperty(this.dpRain) && this._hasRainSensor()) {
339
+ if (this._usesLeakSensor()) {
340
+ const leak = this.accessory.getService(Service.LeakSensor);
341
+ if (leak) leak.getCharacteristic(Characteristic.LeakDetected)
342
+ .updateValue(this._rainDetected(changes[this.dpRain]) ? 1 : 0);
343
+ } else {
344
+ const contact = this.accessory.getService(Service.ContactSensor);
345
+ if (contact) contact.getCharacteristic(Characteristic.ContactSensorState)
346
+ .updateValue(this._contactState(changes[this.dpRain]));
347
+ }
348
+ }
349
+
350
+ if (valveChanged) this._syncAggregate();
351
+ }
352
+
353
+ /* ------------------------------------------------------------------ *
354
+ * Valve activation + timer logic
355
+ * ------------------------------------------------------------------ */
356
+
357
+ // Called from HomeKit (Valve.Active onSet). Writes to the device and
358
+ // mirrors the state locally.
359
+ _setValveActive(cfg, value) {
360
+ const {Characteristic} = this.hap;
361
+ const on = value === Characteristic.Active.ACTIVE || value === true || value === 1;
362
+
363
+ const service = this._valveService(cfg);
364
+ // Guard against iOS firing duplicate onSet callbacks.
365
+ if (service && service.getCharacteristic(Characteristic.Active).value === (on ? 1 : 0) &&
366
+ !!this.device.state[cfg.dp] === on) {
367
+ return;
368
+ }
369
+
370
+ this._queueWrite(cfg.dp, on);
371
+ this._reflectValve(cfg, on);
372
+ this._syncAggregate();
373
+ }
374
+
375
+ // Mirror a zone's on/off state into HomeKit and (dis)arm its auto-shutoff
376
+ // timer. Does NOT write to the device — call _queueWrite separately for that.
377
+ _reflectValve(cfg, on) {
378
+ const {Characteristic} = this.hap;
379
+ const service = this._valveService(cfg);
380
+ if (!service) return;
381
+
382
+ const activeChar = service.getCharacteristic(Characteristic.Active);
383
+ const inUseChar = service.getCharacteristic(Characteristic.InUse);
384
+ const remainingChar = service.getCharacteristic(Characteristic.RemainingDuration);
385
+
386
+ this._clearTimer(cfg.dp);
387
+
388
+ if (on) {
389
+ const duration = this._getDuration(cfg);
390
+ if (activeChar.value !== Characteristic.Active.ACTIVE) activeChar.updateValue(Characteristic.Active.ACTIVE);
391
+ if (inUseChar.value !== Characteristic.InUse.IN_USE) inUseChar.updateValue(Characteristic.InUse.IN_USE);
392
+
393
+ if (duration > 0) {
394
+ this._endTimes[cfg.dp] = Date.now() + duration * 1000;
395
+ remainingChar.updateValue(duration);
396
+ this._timers[cfg.dp] = setTimeout(() => {
397
+ this.log.info('%s: zone "%s" timer expired, shutting off', this.device.context.name, cfg.name);
398
+ this._queueWrite(cfg.dp, false);
399
+ this._reflectValve(cfg, false);
400
+ this._syncAggregate();
401
+ }, duration * 1000);
402
+ } else {
403
+ // Indefinite run — no auto-shutoff, no countdown.
404
+ this._endTimes[cfg.dp] = null;
405
+ remainingChar.updateValue(0);
406
+ }
407
+ } else {
408
+ this._endTimes[cfg.dp] = null;
409
+ if (activeChar.value !== Characteristic.Active.INACTIVE) activeChar.updateValue(Characteristic.Active.INACTIVE);
410
+ if (inUseChar.value !== Characteristic.InUse.NOT_IN_USE) inUseChar.updateValue(Characteristic.InUse.NOT_IN_USE);
411
+ remainingChar.updateValue(0);
412
+ }
413
+ }
414
+
415
+ _clearTimer(dp) {
416
+ if (this._timers[dp]) {
417
+ clearTimeout(this._timers[dp]);
418
+ this._timers[dp] = null;
419
+ }
420
+ }
421
+
422
+ /* ------------------------------------------------------------------ *
423
+ * Master (whole-system) on/off
424
+ * ------------------------------------------------------------------ */
425
+
426
+ _setSystemActive(value) {
427
+ const {Characteristic} = this.hap;
428
+ const on = value === Characteristic.Active.ACTIVE || value === true || value === 1;
429
+
430
+ if (on && !this._cascadeOn) return; // master is a passive enable
431
+ if (!on && !this._cascadeOff) return;
432
+
433
+ // Act on what the user sees (the valve's HomeKit Active value) so the
434
+ // cascade self-heals stale optimistic states and doesn't reset the
435
+ // countdown on a zone that is already running. Only zones that actually
436
+ // change are touched; _flushWrites then coalesces them into a single
437
+ // Tuya command and drops any that already match the real device state.
438
+ this._valves.forEach(cfg => {
439
+ const service = this._valveService(cfg);
440
+ const currentlyOn = !!(service && service.getCharacteristic(Characteristic.Active).value);
441
+ if (currentlyOn === on) return;
442
+ this._queueWrite(cfg.dp, on);
443
+ this._reflectValve(cfg, on);
444
+ });
445
+ this._syncAggregate();
446
+ }
447
+
448
+ /* ------------------------------------------------------------------ *
449
+ * Duration helpers
450
+ * ------------------------------------------------------------------ */
451
+
452
+ _getDuration(cfg) {
453
+ const saved = this.accessory.context.durations[cfg.dp];
454
+ return isFinite(saved) ? saved : cfg.duration;
455
+ }
456
+
457
+ _setDuration(cfg, value) {
458
+ const {Characteristic} = this.hap;
459
+ const seconds = Math.max(0, parseInt(value) || 0);
460
+ this.accessory.context.durations[cfg.dp] = seconds;
461
+ this.log.info('%s: zone "%s" duration set to %s', this.device.context.name, cfg.name, seconds ? (seconds + 's') : 'indefinite');
462
+
463
+ // If the zone is running, re-base its countdown on the new duration.
464
+ const service = this._valveService(cfg);
465
+ if (service && service.getCharacteristic(Characteristic.InUse).value === Characteristic.InUse.IN_USE) {
466
+ this._clearTimer(cfg.dp);
467
+ const remainingChar = service.getCharacteristic(Characteristic.RemainingDuration);
468
+ if (seconds > 0) {
469
+ this._endTimes[cfg.dp] = Date.now() + seconds * 1000;
470
+ remainingChar.updateValue(seconds);
471
+ this._timers[cfg.dp] = setTimeout(() => {
472
+ this._queueWrite(cfg.dp, false);
473
+ this._reflectValve(cfg, false);
474
+ this._syncAggregate();
475
+ }, seconds * 1000);
476
+ } else {
477
+ this._endTimes[cfg.dp] = null;
478
+ remainingChar.updateValue(0);
479
+ }
480
+ this._syncAggregate();
481
+ }
482
+ }
483
+
484
+ _valveRemaining(cfg) {
485
+ const end = this._endTimes[cfg.dp];
486
+ if (!end) return 0;
487
+ const remaining = Math.round((end - Date.now()) / 1000);
488
+ return remaining > 0 ? remaining : 0;
489
+ }
490
+
491
+ _systemRemaining() {
492
+ let max = 0;
493
+ this._valves.forEach(cfg => {
494
+ const r = this._valveRemaining(cfg);
495
+ if (r > max) max = r;
496
+ });
497
+ return max;
498
+ }
499
+
500
+ /* ------------------------------------------------------------------ *
501
+ * Aggregation: roll zone state up to the IrrigationSystem service
502
+ * ------------------------------------------------------------------ */
503
+
504
+ _anyValveOn() {
505
+ return this._valves.some(cfg => !!this.device.state[cfg.dp]);
506
+ }
507
+
508
+ _syncAggregate() {
509
+ const {Characteristic} = this.hap;
510
+ const anyOn = this._valves.some(cfg => {
511
+ const service = this._valveService(cfg);
512
+ return service && service.getCharacteristic(Characteristic.InUse).value === Characteristic.InUse.IN_USE;
513
+ });
514
+
515
+ if (this._systemInUseChar) {
516
+ const v = anyOn ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE;
517
+ if (this._systemInUseChar.value !== v) this._systemInUseChar.updateValue(v);
518
+ }
519
+ if (this._systemActiveChar) {
520
+ const v = anyOn ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE;
521
+ if (this._systemActiveChar.value !== v) this._systemActiveChar.updateValue(v);
522
+ }
523
+ if (this._systemRemainingChar) {
524
+ this._systemRemainingChar.updateValue(this._systemRemaining());
525
+ }
526
+ }
527
+
528
+ /* ------------------------------------------------------------------ *
529
+ * Battery + rain mapping
530
+ * ------------------------------------------------------------------ */
531
+
532
+ _batteryLevel(value) {
533
+ const n = parseInt(value);
534
+ if (!isFinite(n)) return 0;
535
+ return Math.max(0, Math.min(100, n));
536
+ }
537
+
538
+ _lowBattery(level) {
539
+ const {Characteristic} = this.hap;
540
+ return level <= this._lowBatteryThreshold
541
+ ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
542
+ : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL;
543
+ }
544
+
545
+ // CHARGING / NOT_CHARGING from the device's boolean charging data-point.
546
+ // Devices that don't report charging (no such data-point) → NOT_CHARGEABLE.
547
+ _chargingState(value) {
548
+ const {Characteristic} = this.hap;
549
+ if (typeof value !== 'boolean') return Characteristic.ChargingState.NOT_CHARGEABLE;
550
+ return value
551
+ ? Characteristic.ChargingState.CHARGING
552
+ : Characteristic.ChargingState.NOT_CHARGING;
553
+ }
554
+
555
+ // True when the device reports rain.
556
+ _rainDetected(value) {
557
+ let raining;
558
+ if (typeof value === 'boolean') raining = value;
559
+ else raining = ('' + value).trim().toLowerCase() === this._rainOnValue.toLowerCase();
560
+ return this._rainInverted ? !raining : raining;
561
+ }
562
+
563
+ _contactState(value) {
564
+ const {Characteristic} = this.hap;
565
+ // "Rain" is treated as the triggered/abnormal state → CONTACT_NOT_DETECTED.
566
+ return this._rainDetected(value)
567
+ ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
568
+ : Characteristic.ContactSensorState.CONTACT_DETECTED;
569
+ }
570
+
571
+ /* ------------------------------------------------------------------ *
572
+ * Batched writes — coalesce DP updates into a single Tuya command
573
+ * ------------------------------------------------------------------ */
574
+
575
+ _queueWrite(dp, value) {
576
+ if (!this._pendingWrite) this._pendingWrite = {dps: {}};
577
+ this._pendingWrite.dps[String(dp)] = value;
578
+
579
+ if (this._pendingWrite.timer) clearTimeout(this._pendingWrite.timer);
580
+ this._pendingWrite.timer = setTimeout(() => this._flushWrites(), this._debounce);
581
+ }
582
+
583
+ _flushWrites() {
584
+ const pending = this._pendingWrite;
585
+ this._pendingWrite = null;
586
+ if (!pending) return;
587
+
588
+ if (!this.device.connected) {
589
+ this.log.debug('%s: skipping write, device not connected', this.device.context.name);
590
+ return;
591
+ }
592
+
593
+ const dps = {};
594
+ let count = 0;
595
+ Object.keys(pending.dps).forEach(dp => {
596
+ if (pending.dps[dp] !== this.device.state[dp]) {
597
+ dps[dp] = pending.dps[dp];
598
+ count++;
599
+ }
600
+ });
601
+
602
+ if (count) this.device.update(dps);
603
+ }
604
+
605
+ _valveService(cfg) {
606
+ const {Service} = this.hap;
607
+ return this.accessory.getServiceById(Service.Valve, 'valve-' + cfg.dp);
608
+ }
609
+ }
610
+
611
+ module.exports = IrrigationSystemAccessory;
@@ -2,7 +2,7 @@ const BaseAccessory = require('./BaseAccessory');
2
2
 
3
3
  class OilDiffuserAccessory extends BaseAccessory {
4
4
  static getCategory(Categories) {
5
- return Categories.DEHUMIDIFIER;
5
+ return Categories.AIR_DEHUMIDIFIER;
6
6
  }
7
7
 
8
8
  constructor(...props) {