homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-dev.52

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.
Files changed (53) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +162 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +16 -3
  5. package/Readme.MD +8 -32
  6. package/config.schema.json +79 -79
  7. package/index.js +599 -358
  8. package/lib/AirPurifierAccessory.js +1 -1
  9. package/lib/BaseAccessory.js +54 -17
  10. package/lib/ConvectorAccessory.js +1 -1
  11. package/lib/CustomMultiOutletAccessory.js +2 -1
  12. package/lib/DoorbellAccessory.js +9 -16
  13. package/lib/GarageDoorAccessory.js +1 -1
  14. package/lib/IrrigationSystemAccessory.js +260 -120
  15. package/lib/MultiOutletAccessory.js +3 -2
  16. package/lib/OilDiffuserAccessory.js +10 -8
  17. package/lib/RGBTWLightAccessory.js +13 -11
  18. package/lib/RGBTWOutletAccessory.js +11 -9
  19. package/lib/SimpleBlindsAccessory.js +5 -5
  20. package/lib/SimpleDimmer2Accessory.js +1 -1
  21. package/lib/SimpleDimmerAccessory.js +10 -6
  22. package/lib/SimpleGarageDoorAccessory.js +122 -26
  23. package/lib/SimpleHeaterAccessory.js +4 -4
  24. package/lib/SimpleLightAccessory.js +1 -1
  25. package/lib/SwitchAccessory.js +3 -2
  26. package/lib/TWLightAccessory.js +2 -2
  27. package/lib/TuyaAccessory.js +75 -42
  28. package/lib/TuyaCloudApi.js +121 -8
  29. package/lib/TuyaCloudDevice.js +434 -31
  30. package/lib/TuyaCloudMessaging.js +34 -41
  31. package/lib/TuyaDevice.js +269 -0
  32. package/lib/TuyaDiscovery.js +25 -9
  33. package/lib/ValveAccessory.js +16 -12
  34. package/lib/VerticalBlindsWithTilt.js +27 -25
  35. package/lib/WledDimmerAccessory.js +46 -81
  36. package/package.json +5 -6
  37. package/scripts/cleanup-pr-tags.js +122 -0
  38. package/test/BaseAccessory.test.js +94 -15
  39. package/test/IrrigationSystemAccessory.test.js +293 -67
  40. package/test/MultiOutletAccessory.test.js +18 -3
  41. package/test/RGBTWLightAccessory.test.js +3 -3
  42. package/test/SimpleFanLightAccessory.test.js +3 -3
  43. package/test/SimpleGarageDoorAccessory.test.js +193 -19
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +110 -1
  46. package/test/TuyaCloudDevice.test.js +564 -2
  47. package/test/TuyaCloudMessaging.test.js +24 -5
  48. package/test/TuyaDevice.test.js +266 -0
  49. package/test/getCategory.test.js +1 -0
  50. package/test/index.test.js +271 -0
  51. package/test/support/mocks.js +13 -0
  52. package/wiki/Supported-Device-Types.md +64 -34
  53. package/wiki/Tuya-Cloud-Setup.md +31 -108
@@ -4,8 +4,8 @@ const BaseAccessory = require('./BaseAccessory');
4
4
  * IrrigationSystemAccessory
5
5
  *
6
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`).
7
+ * (e.g. the 4-zone faucet/valve timers that expose `switch_1..switch_n` and a
8
+ * `battery_percentage`).
9
9
  *
10
10
  * HomeKit modelling (one bridged accessory, category SPRINKLER):
11
11
  *
@@ -13,8 +13,9 @@ const BaseAccessory = require('./BaseAccessory');
13
13
  * ├─ Valve "Zone A" (linked, ValveType=IRRIGATION, ServiceLabelIndex 1)
14
14
  * ├─ Valve "Zone B" (linked, ServiceLabelIndex 2)
15
15
  * ├─ ...
16
+ * ServiceLabel (labels the multi-valve collection so the Home app nests
17
+ * the zones under the system tile; added when >1 valve)
16
18
  * Battery (BatteryLevel + StatusLowBattery)
17
- * ContactSensor (rain — or LeakSensor, configurable)
18
19
  *
19
20
  * Each Valve carries its own SetDuration/RemainingDuration so the Home app shows
20
21
  * the familiar per-zone "Duration" picker and a live countdown. A zone whose
@@ -23,6 +24,30 @@ const BaseAccessory = require('./BaseAccessory');
23
24
  * command (the device is a laggy, battery-powered Wi-Fi unit), so turning the
24
25
  * whole system on/off — or running a scene that toggles several zones at once —
25
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), switching a zone ON also
32
+ * sends the run length (whole minutes, in the SAME command as the switch) to the
33
+ * device's own countdown, so the valve still closes on schedule even if
34
+ * Homebridge or the network drops out mid-run. The two mechanisms are
35
+ * complementary: the software timer drives the UI and the precise (sub-minute)
36
+ * close while connected; the hardware countdown is the offline safety net.
37
+ *
38
+ * IMPORTANT: on these controllers the countdown dp is NOT a passive "duration"
39
+ * setting — writing it STARTS a run and switches the zone ON. It is therefore
40
+ * written only together with a switch-on, or to re-base/repair a zone that is
41
+ * already running; never while a zone is off, on connect, or merely because the
42
+ * Duration was edited. (An earlier version wrote it on connect to "fix" zones
43
+ * reading 0 and opened every valve at once — see the regression tests.)
44
+ *
45
+ * The countdown is (re)written on EVERY switch-on, in the SAME command as the
46
+ * switch — we do not rely on the device persisting it (it appears to reset once a
47
+ * run finishes), so each run carries its own fresh countdown matching the zone's
48
+ * current Duration (0 = indefinite). A zone opened at the device instead
49
+ * (physical button / Tuya app) is caught by the change handler, which gives it a
50
+ * countdown too when it comes up without a usable one.
26
51
  */
27
52
  class IrrigationSystemAccessory extends BaseAccessory {
28
53
  static getCategory(Categories) {
@@ -45,23 +70,25 @@ class IrrigationSystemAccessory extends BaseAccessory {
45
70
  // Self-contained (no reliance on instance state) so it is safe to call
46
71
  // both during early service reconciliation and later when wiring
47
72
  // characteristics.
48
- const cloud = this._isCloud();
49
73
  const defaultDuration = isFinite(this.device.context.defaultDuration) ? parseInt(this.device.context.defaultDuration) : 600;
50
74
 
51
75
  if (Array.isArray(this.device.context.valves) && this.device.context.valves.length) {
52
76
  return this.device.context.valves.map((valve, i) => {
53
- // A data-point may be a numeric LAN id (1, 2, …) or a Tuya Cloud
54
- // code (e.g. "switch_1"). Accept either; only an empty value is
55
- // invalid.
77
+ // A data-point may be a numeric id (1, 2, …) or a Tuya code
78
+ // (e.g. "switch_1"); both resolve over either transport. Only an
79
+ // empty value is invalid.
56
80
  const dp = (valve && valve.dp !== undefined && valve.dp !== null) ? ('' + valve.dp).trim() : '';
57
81
  if (!dp) {
58
82
  throw new Error(`The valve definition #${i + 1} is missing a 'dp': ${JSON.stringify(valve)}`);
59
83
  }
84
+ const dpCountdownExplicit = (valve && valve.dpCountdown !== undefined && valve.dpCountdown !== null) ? ('' + valve.dpCountdown).trim() : '';
60
85
  return {
61
86
  dp,
62
87
  name: (('' + (valve.name || '')).trim()) || ('Zone ' + (i + 1)),
63
88
  index: i + 1,
64
- duration: isFinite(valve.defaultDuration) ? parseInt(valve.defaultDuration) : defaultDuration
89
+ duration: isFinite(valve.defaultDuration) ? parseInt(valve.defaultDuration) : defaultDuration,
90
+ dpCountdown: dpCountdownExplicit || this._defaultCountdownDP(dp),
91
+ countdownConfigured: dpCountdownExplicit !== ''
65
92
  };
66
93
  });
67
94
  }
@@ -70,45 +97,42 @@ class IrrigationSystemAccessory extends BaseAccessory {
70
97
  const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
71
98
  const configs = [];
72
99
  for (let i = 0; i < count; i++) {
100
+ const dp = String(i + 1);
73
101
  configs.push({
74
- // Cloud devices address valves by code (switch_1, switch_2, …);
75
- // LAN devices by numeric data-point id (1, 2, …).
76
- dp: cloud ? ('switch_' + (i + 1)) : String(i + 1),
102
+ dp,
77
103
  name: 'Valve ' + (letters[i] || (i + 1)),
78
104
  index: i + 1,
79
- duration: defaultDuration
105
+ duration: defaultDuration,
106
+ dpCountdown: this._defaultCountdownDP(dp),
107
+ countdownConfigured: false
80
108
  });
81
109
  }
82
110
  return configs;
83
111
  }
84
112
 
85
- // True when this device is reached over the Tuya Cloud (data-points keyed by
86
- // string code) rather than the LAN (numeric data-point ids).
87
- _isCloud() {
88
- return this._coerceBoolean(this.device.context.cloud, false);
113
+ // Default countdown data-point for a zone. On these devices the per-zone
114
+ // countdown timers sit 16 ids above the switches (switch_1=DP 1 → countdown_1
115
+ // =DP 17, … switch_4=DP 4 → countdown_4=DP 20), so derive it from a numeric
116
+ // switch dp. A code-addressed switch (e.g. "switch_1") has no derivable
117
+ // numeric neighbour — the user must set `dpCountdown` explicitly for those —
118
+ // so return '' (native countdown stays off for that zone unless configured).
119
+ _defaultCountdownDP(switchDp) {
120
+ const trimmed = ('' + switchDp).trim();
121
+ const n = parseInt(trimmed, 10);
122
+ return (isFinite(n) && String(n) === trimmed) ? String(n + 16) : '';
89
123
  }
90
124
 
91
- // Resolve a configurable data-point that may be given as a numeric LAN id or
92
- // a Tuya Cloud code, falling back to a sensible default for the active
93
- // transport.
94
- _resolveDP(value, cloudDefault, lanDefault) {
125
+ // Resolve a configurable data-point (a numeric id or a Tuya code), trimming and
126
+ // falling back to a default when it isn't set.
127
+ _resolveDP(value, fallback) {
95
128
  const v = (value === undefined || value === null) ? '' : ('' + value).trim();
96
- if (v !== '') return v;
97
- return this._isCloud() ? cloudDefault : lanDefault;
129
+ return v !== '' ? v : fallback;
98
130
  }
99
131
 
100
132
  _hasBattery() {
101
133
  return !this._coerceBoolean(this.device.context.noBattery, false);
102
134
  }
103
135
 
104
- _hasRainSensor() {
105
- return !this._coerceBoolean(this.device.context.noRainSensor, false);
106
- }
107
-
108
- _usesLeakSensor() {
109
- return ('' + (this.device.context.rainSensorType || 'contact')).trim().toLowerCase() === 'leak';
110
- }
111
-
112
136
  /* ------------------------------------------------------------------ *
113
137
  * Service registration / cache reconciliation
114
138
  * ------------------------------------------------------------------ */
@@ -149,6 +173,25 @@ class IrrigationSystemAccessory extends BaseAccessory {
149
173
  irrigation.addLinkedService(valve);
150
174
  });
151
175
 
176
+ // --- Service Label (required when the accessory exposes a *collection*
177
+ // of same-type services, i.e. more than one Valve) ---
178
+ // HAP's Service Label profile says an accessory holding several services
179
+ // of the same type must carry a Service Label service, with each member
180
+ // service tagged by a ServiceLabelIndex. We already set the indices; the
181
+ // anchoring Service Label service was missing. Without it the stricter
182
+ // Home app clients (notably iOS) scatter the zones as separate tiles
183
+ // rather than nesting them under the irrigation system — macOS happens to
184
+ // render leniently either way. The namespace is set in
185
+ // _registerCharacteristics. A single-valve system is not a collection, so
186
+ // it needs no label.
187
+ const multiValve = valveConfigs.length > 1;
188
+ const existingLabel = this.accessory.getService(Service.ServiceLabel);
189
+ if (multiValve && !existingLabel) {
190
+ this.accessory.addService(Service.ServiceLabel, this.device.context.name);
191
+ } else if (!multiValve && existingLabel) {
192
+ this.accessory.removeService(existingLabel);
193
+ }
194
+
152
195
  // --- Battery (optional) ---
153
196
  if (this._hasBattery()) {
154
197
  if (!this.accessory.getService(Service.Battery)) {
@@ -156,23 +199,11 @@ class IrrigationSystemAccessory extends BaseAccessory {
156
199
  }
157
200
  }
158
201
 
159
- // --- Rain sensor (optional): Contact (default) or Leak ---
160
- if (this._hasRainSensor()) {
161
- const WantedSensor = this._usesLeakSensor() ? Service.LeakSensor : Service.ContactSensor;
162
- const UnwantedSensor = this._usesLeakSensor() ? Service.ContactSensor : Service.LeakSensor;
163
- // Drop the other sensor type if the user switched `rainSensorType`.
164
- const stale = this.accessory.getService(UnwantedSensor);
165
- if (stale) this.accessory.removeService(stale);
166
- if (!this.accessory.getService(WantedSensor)) {
167
- this.accessory.addService(WantedSensor, this.device.context.name + ' Rain');
168
- }
169
- }
170
-
171
202
  // --- Remove services that no longer belong (config changed) ---
172
203
  this.accessory.services
173
204
  .filter(service => service.UUID === Service.Valve.UUID && !validValveSubtypes.includes(service.subtype))
174
205
  .forEach(service => {
175
- this.log.info('Removing stale valve service %s', service.displayName);
206
+ this.log.debug('Removing stale valve service %s', service.displayName);
176
207
  this.accessory.removeService(service);
177
208
  });
178
209
 
@@ -180,12 +211,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
180
211
  const battery = this.accessory.getService(Service.Battery);
181
212
  if (battery) this.accessory.removeService(battery);
182
213
  }
183
- if (!this._hasRainSensor()) {
184
- [Service.ContactSensor, Service.LeakSensor].forEach(S => {
185
- const svc = this.accessory.getService(S);
186
- if (svc) this.accessory.removeService(svc);
187
- });
188
- }
214
+
215
+ // Rain/leak sensors are no longer supported: they never reported
216
+ // reliably on these devices, and bundling a sensor in the same accessory
217
+ // forced the Home app to fragment the sprinkler into "sub-accessories"
218
+ // (blocking control from the main tile). Drop any left over from an older
219
+ // version so the accessory stays a clean, single-category sprinkler tile.
220
+ [Service.ContactSensor, Service.LeakSensor].forEach(S => {
221
+ const svc = this.accessory.getService(S);
222
+ if (svc) {
223
+ this.log.debug('Removing rain sensor service %s (no longer supported)', svc.displayName);
224
+ this.accessory.removeService(svc);
225
+ }
226
+ });
189
227
  }
190
228
 
191
229
  /* ------------------------------------------------------------------ *
@@ -208,13 +246,25 @@ class IrrigationSystemAccessory extends BaseAccessory {
208
246
  this._cascadeOn = this._coerceBoolean(this.device.context.masterTurnsOnAllZones, true);
209
247
  this._cascadeOff = this._coerceBoolean(this.device.context.masterTurnsOffAllZones, true);
210
248
 
211
- // Data-points accept a numeric LAN id or a Tuya Cloud code; defaults
212
- // differ per transport (cloud uses the standard Tuya codes).
213
- this.dpBattery = this._resolveDP(this.device.context.dpBattery, 'battery_percentage', '46');
214
- this.dpCharging = this._resolveDP(this.device.context.dpCharging, 'charge_state', '101');
215
- this.dpRain = this._resolveDP(this.device.context.dpRain, 'rain_sensor_state', '49');
216
- this._rainOnValue = ('' + (this.device.context.rainOnValue || 'rain')).trim();
217
- this._rainInverted = this._coerceBoolean(this.device.context.rainInverted, false);
249
+ // Native (hardware) per-zone countdown. On by default, but only ever used
250
+ // for a zone whose countdown data-point the device actually reports (see
251
+ // _isCountdownSupported); set nativeCountdown:false to force the legacy
252
+ // software-timer-only behaviour even on a device that supports it.
253
+ this._nativeCountdown = this._coerceBoolean(this.device.context.nativeCountdown, true);
254
+ // Tuya's per-zone countdown is an integer number of minutes, hardware-
255
+ // capped at 120 (0 = no limit). The largest countdown we treat as valid is
256
+ // also bounded by what HomeKit's SetDuration can represent (its advertised
257
+ // maxValue, _maxDuration seconds), so the two views never disagree. With
258
+ // the default 7200s (=120min) max this is the full 120; lower maxDuration
259
+ // to e.g. 3600 to have the device's longer/over-cap countdowns corrected
260
+ // down to 60min on connect.
261
+ this._maxCountdownMinutes = 120;
262
+ this._representableCountdownMinutes = Math.max(1, Math.min(this._maxCountdownMinutes, Math.floor(this._maxDuration / 60)));
263
+
264
+ // Data-points default to this device's numeric ids; a Tuya code may be
265
+ // configured instead and works over either transport.
266
+ this.dpBattery = this._resolveDP(this.device.context.dpBattery, '46');
267
+ this.dpCharging = this._resolveDP(this.device.context.dpCharging, '101');
218
268
 
219
269
  // Per-zone runtime state
220
270
  this._valves = this._getValveConfigs();
@@ -234,7 +284,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
234
284
 
235
285
  this._systemActiveChar = irrigation.getCharacteristic(Characteristic.Active)
236
286
  .updateValue(this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
237
- .onGet(() => this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
287
+ .onGet(() => this._reportCached(this._systemActiveChar))
238
288
  .onSet(value => this._setSystemActive(value));
239
289
 
240
290
  this._systemInUseChar = irrigation.getCharacteristic(Characteristic.InUse)
@@ -277,19 +327,42 @@ class IrrigationSystemAccessory extends BaseAccessory {
277
327
  .updateValue(0)
278
328
  .onGet(() => this._valveRemaining(cfg));
279
329
 
280
- valve.getCharacteristic(Characteristic.Active)
330
+ const activeChar = valve.getCharacteristic(Characteristic.Active)
281
331
  .updateValue(on ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
282
- .onGet(() => (this.getStateAsync(cfg.dp) ? 1 : 0))
283
332
  .onSet(value => this._setValveActive(cfg, value));
333
+ activeChar.onGet(() => this._reportCached(activeChar));
284
334
 
285
335
  valve.getCharacteristic(Characteristic.InUse)
286
336
  .updateValue(on ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
287
337
 
338
+ // Whether this zone can use the device's hardware countdown. Detection
339
+ // only gates the write paths; nothing is written on connect — writing
340
+ // the countdown dp starts a run and would open the zone.
341
+ cfg.countdownSupported = this._isCountdownSupported(cfg, dps);
342
+
288
343
  // Reflect any zone already running at startup (e.g. turned on at the
289
- // device, or Homebridge restarted mid-run) and (re)arm its timer.
290
- if (on) this._reflectValve(cfg, true);
344
+ // device, or Homebridge restarted mid-run) and (re)arm its timer. A
345
+ // zone that is genuinely running can safely be given a countdown, so
346
+ // repair an unbounded/invalid one here too — closed zones are skipped.
347
+ if (on) {
348
+ this._reflectValve(cfg, true);
349
+ this._ensureRunningCountdown(cfg);
350
+ }
291
351
  });
292
352
 
353
+ // --- Service Label namespace ---
354
+ // Anchors the per-valve ServiceLabelIndex so the Home app groups the
355
+ // zones under the irrigation tile (see _verifyCachedPlatformAccessory).
356
+ // Arabic numerals: zones are labelled 1, 2, 3… — the user-set zone names
357
+ // (ConfiguredName) still take precedence in the Home app.
358
+ if (this._valves.length > 1) {
359
+ const serviceLabel = this.accessory.getService(Service.ServiceLabel);
360
+ if (serviceLabel) {
361
+ serviceLabel.getCharacteristic(Characteristic.ServiceLabelNamespace)
362
+ .updateValue(Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS);
363
+ }
364
+ }
365
+
293
366
  // --- Battery ---
294
367
  if (this._hasBattery()) {
295
368
  const battery = this.accessory.getService(Service.Battery);
@@ -308,25 +381,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
308
381
  .onGet(() => this._chargingState(this.getStateAsync(this.dpCharging)));
309
382
  }
310
383
 
311
- // --- Rain sensor ---
312
- if (this._hasRainSensor()) {
313
- if (this._usesLeakSensor()) {
314
- const leak = this.accessory.getService(Service.LeakSensor);
315
- leak.getCharacteristic(Characteristic.LeakDetected)
316
- .updateValue(this._rainDetected(dps[this.dpRain]) ? Characteristic.LeakDetected.LEAK_DETECTED : Characteristic.LeakDetected.LEAK_NOT_DETECTED)
317
- .onGet(() => this._rainDetected(this.getStateAsync(this.dpRain)) ? 1 : 0);
318
- } else {
319
- const contact = this.accessory.getService(Service.ContactSensor);
320
- contact.getCharacteristic(Characteristic.ContactSensorState)
321
- .updateValue(this._contactState(dps[this.dpRain]))
322
- .onGet(() => this._contactState(this.getStateAsync(this.dpRain)));
323
- }
324
- }
325
-
326
384
  this._syncAggregate();
327
385
 
328
386
  // --- React to device-side changes (physical buttons, our own writes
329
- // being confirmed, battery/rain telemetry) ---
387
+ // being confirmed, battery telemetry) ---
330
388
  this.device.on('change', (changes) => this._onDeviceChange(changes));
331
389
  }
332
390
 
@@ -339,9 +397,17 @@ class IrrigationSystemAccessory extends BaseAccessory {
339
397
 
340
398
  let valveChanged = false;
341
399
  this._valves.forEach(cfg => {
342
- if (!changes.hasOwnProperty(cfg.dp)) return;
343
- valveChanged = true;
344
- this._reflectValve(cfg, !!changes[cfg.dp]);
400
+ if (changes.hasOwnProperty(cfg.dp)) {
401
+ valveChanged = true;
402
+ this._reflectValve(cfg, !!changes[cfg.dp]);
403
+ }
404
+ // A zone switched on at the device (or already on) that's left without a
405
+ // usable countdown gets one so it still auto-closes, offline included.
406
+ // _ensureRunningCountdown only writes while the zone is on, so this can
407
+ // never open a closed zone.
408
+ if (cfg.countdownSupported && (changes.hasOwnProperty(cfg.dp) || changes.hasOwnProperty(cfg.dpCountdown))) {
409
+ this._ensureRunningCountdown(cfg);
410
+ }
345
411
  });
346
412
 
347
413
  if (changes.hasOwnProperty(this.dpBattery) && this._hasBattery()) {
@@ -359,21 +425,22 @@ class IrrigationSystemAccessory extends BaseAccessory {
359
425
  .updateValue(this._chargingState(changes[this.dpCharging]));
360
426
  }
361
427
 
362
- if (changes.hasOwnProperty(this.dpRain) && this._hasRainSensor()) {
363
- if (this._usesLeakSensor()) {
364
- const leak = this.accessory.getService(Service.LeakSensor);
365
- if (leak) leak.getCharacteristic(Characteristic.LeakDetected)
366
- .updateValue(this._rainDetected(changes[this.dpRain]) ? 1 : 0);
367
- } else {
368
- const contact = this.accessory.getService(Service.ContactSensor);
369
- if (contact) contact.getCharacteristic(Characteristic.ContactSensorState)
370
- .updateValue(this._contactState(changes[this.dpRain]));
371
- }
372
- }
373
-
374
428
  if (valveChanged) this._syncAggregate();
375
429
  }
376
430
 
431
+ // onGet handler for the system / valve Active characteristics. Returns the
432
+ // value HomeKit already shows — the optimistic value written on press and
433
+ // then confirmed (or corrected) by device-side change events — rather than
434
+ // the raw `device.state`. Cloud devices don't advance `device.state` until
435
+ // the realtime stream echoes the write back, so reading it here returned the
436
+ // pre-press value for a moment and made the Home app toggle visibly flicker
437
+ // back to the old state before settling. Throwing while disconnected keeps
438
+ // the HomeKit "No Response" behaviour.
439
+ _reportCached(char) {
440
+ if (!this.device.connected) throw this._commError();
441
+ return char.value;
442
+ }
443
+
377
444
  /* ------------------------------------------------------------------ *
378
445
  * Valve activation + timer logic
379
446
  * ------------------------------------------------------------------ */
@@ -392,6 +459,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
392
459
  }
393
460
 
394
461
  this._queueWrite(cfg.dp, on);
462
+ if (on) this._queueCountdownWrite(cfg);
395
463
  this._reflectValve(cfg, on);
396
464
  this._syncAggregate();
397
465
  }
@@ -464,6 +532,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
464
532
  const currentlyOn = !!(service && service.getCharacteristic(Characteristic.Active).value);
465
533
  if (currentlyOn === on) return;
466
534
  this._queueWrite(cfg.dp, on);
535
+ if (on) this._queueCountdownWrite(cfg);
467
536
  this._reflectValve(cfg, on);
468
537
  });
469
538
  this._syncAggregate();
@@ -484,10 +553,16 @@ class IrrigationSystemAccessory extends BaseAccessory {
484
553
  this.accessory.context.durations[cfg.dp] = seconds;
485
554
  this.log.info('%s: zone "%s" duration set to %s', this.device.context.name, cfg.name, seconds ? (seconds + 's') : 'indefinite');
486
555
 
487
- // If the zone is running, re-base its countdown on the new duration.
556
+ // If the zone is running, re-base its countdown on the new duration — both
557
+ // the software timer and (safely, since the zone is already on) the device's
558
+ // hardware countdown. We deliberately do NOT write the hardware countdown
559
+ // while the zone is off: writing it starts a run and would open the valve.
560
+ // An off zone's new duration is applied the next time it is switched on
561
+ // (and the device persists it from there).
488
562
  const service = this._valveService(cfg);
489
563
  if (service && service.getCharacteristic(Characteristic.InUse).value === Characteristic.InUse.IN_USE) {
490
564
  this._clearTimer(cfg.dp);
565
+ this._queueCountdownWrite(cfg);
491
566
  const remainingChar = service.getCharacteristic(Characteristic.RemainingDuration);
492
567
  if (seconds > 0) {
493
568
  this._endTimes[cfg.dp] = Date.now() + seconds * 1000;
@@ -521,6 +596,84 @@ class IrrigationSystemAccessory extends BaseAccessory {
521
596
  return max;
522
597
  }
523
598
 
599
+ /* ------------------------------------------------------------------ *
600
+ * Native (hardware) countdown
601
+ *
602
+ * Lets the device close a zone by itself after N minutes, so a run still
603
+ * ends even if Homebridge or the network drops out. The catch: writing the
604
+ * countdown dp STARTS a run and switches the zone ON, so it is only ever
605
+ * written as part of a switch-on, or to a zone that is already on — never
606
+ * while a zone is off and never on connect. It is re-sent on every switch-on
607
+ * (the device doesn't reliably keep it between runs), and the software timer
608
+ * is the second line of defence if the hardware countdown ever misbehaves.
609
+ * ------------------------------------------------------------------ */
610
+
611
+ // A zone can use the hardware countdown when the feature isn't disabled, the
612
+ // zone has a countdown data-point, and either the user mapped that dp
613
+ // explicitly or the device actually reports a plausible value there — so we
614
+ // never write a countdown to a device (or a dp) that isn't really one.
615
+ _isCountdownSupported(cfg, state) {
616
+ if (!this._nativeCountdown || !cfg.dpCountdown) return false;
617
+ if (cfg.countdownConfigured) return true;
618
+ return this._looksLikeCountdown(state[cfg.dpCountdown]);
619
+ }
620
+
621
+ _looksLikeCountdown(value) {
622
+ const n = this._parseCountdown(value);
623
+ return n !== null && n >= 0 && n <= this._maxCountdownMinutes;
624
+ }
625
+
626
+ _parseCountdown(value) {
627
+ if (value === undefined || value === null || value === '') return null;
628
+ const n = parseInt(value, 10);
629
+ return isFinite(n) ? n : null;
630
+ }
631
+
632
+ // SetDuration (seconds) -> hardware countdown (whole minutes). 0 stays 0 (both
633
+ // sides mean "no limit"); any other value is forced to at least 1 minute so a
634
+ // sub-minute duration can never round down to 0 and silently make the run
635
+ // unbounded, and is capped at what both the hardware and HomeKit allow.
636
+ _durationToCountdownMinutes(seconds) {
637
+ const s = Math.max(0, parseInt(seconds) || 0);
638
+ if (s === 0) return 0;
639
+ return Math.min(this._representableCountdownMinutes, Math.max(1, Math.round(s / 60)));
640
+ }
641
+
642
+ // Queue this zone's current HomeKit duration to its hardware countdown dp,
643
+ // coalesced with whatever else is in flight (the switch-ON it accompanies, so
644
+ // both data-points are written in one command). MUST only be used as part of
645
+ // switching the zone on, or for a zone already on — writing the countdown
646
+ // starts a run. Sent on every switch-on (we don't rely on the device
647
+ // persisting it) so each run carries the current Duration; 0 = indefinite.
648
+ _queueCountdownWrite(cfg) {
649
+ if (!cfg.countdownSupported) return;
650
+ this._queueWrite(cfg.dpCountdown, this._durationToCountdownMinutes(this._getDuration(cfg)));
651
+ }
652
+
653
+ // For a zone that is RUNNING, make sure the device has a usable countdown so it
654
+ // still auto-closes (offline included). Used when a zone is switched on at the
655
+ // device, or was already on at startup. Only ever writes while the zone is on,
656
+ // so it can never open a closed one. A countdown the device already holds at a
657
+ // valid value (e.g. a duration the device or Tuya app set for this run) is
658
+ // respected; only an unbounded (0) or out-of-range one is replaced with the
659
+ // zone's HomeKit duration. Does nothing for an indefinite (0) HomeKit
660
+ // duration — there's no finite run to enforce.
661
+ _ensureRunningCountdown(cfg) {
662
+ if (!cfg.countdownSupported || !this._valveInUse(cfg)) return;
663
+ const minutes = this._durationToCountdownMinutes(this._getDuration(cfg));
664
+ if (minutes <= 0) return;
665
+ const cd = this._parseCountdown(this.device.state[cfg.dpCountdown]);
666
+ if (cd === null || (cd >= 1 && cd <= this._representableCountdownMinutes)) return;
667
+ this.log.debug('%s: zone "%s" is running with countdown %s; setting it to %s min', this.device.context.name, cfg.name, cd === 0 ? 'unbounded' : cd, minutes);
668
+ this._queueWrite(cfg.dpCountdown, minutes);
669
+ }
670
+
671
+ _valveInUse(cfg) {
672
+ const {Characteristic} = this.hap;
673
+ const service = this._valveService(cfg);
674
+ return !!(service && service.getCharacteristic(Characteristic.InUse).value === Characteristic.InUse.IN_USE);
675
+ }
676
+
524
677
  /* ------------------------------------------------------------------ *
525
678
  * Aggregation: roll zone state up to the IrrigationSystem service
526
679
  * ------------------------------------------------------------------ */
@@ -550,7 +703,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
550
703
  }
551
704
 
552
705
  /* ------------------------------------------------------------------ *
553
- * Battery + rain mapping
706
+ * Battery mapping
554
707
  * ------------------------------------------------------------------ */
555
708
 
556
709
  _batteryLevel(value) {
@@ -576,22 +729,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
576
729
  : Characteristic.ChargingState.NOT_CHARGING;
577
730
  }
578
731
 
579
- // True when the device reports rain.
580
- _rainDetected(value) {
581
- let raining;
582
- if (typeof value === 'boolean') raining = value;
583
- else raining = ('' + value).trim().toLowerCase() === this._rainOnValue.toLowerCase();
584
- return this._rainInverted ? !raining : raining;
585
- }
586
-
587
- _contactState(value) {
588
- const {Characteristic} = this.hap;
589
- // "Rain" is treated as the triggered/abnormal state → CONTACT_NOT_DETECTED.
590
- return this._rainDetected(value)
591
- ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
592
- : Characteristic.ContactSensorState.CONTACT_DETECTED;
593
- }
594
-
595
732
  /* ------------------------------------------------------------------ *
596
733
  * Batched writes — coalesce DP updates into a single Tuya command
597
734
  * ------------------------------------------------------------------ */
@@ -614,16 +751,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
614
751
  return;
615
752
  }
616
753
 
617
- const dps = {};
618
- let count = 0;
619
- Object.keys(pending.dps).forEach(dp => {
620
- if (pending.dps[dp] !== this.device.state[dp]) {
621
- dps[dp] = pending.dps[dp];
622
- count++;
623
- }
624
- });
625
-
626
- if (count) this.device.update(dps);
754
+ // Send exactly what was queued. We deliberately do NOT drop data-points
755
+ // that appear to already match `device.state`: cloud devices never
756
+ // optimistically advance `state` (it only moves when the realtime/refresh
757
+ // stream confirms the device), so comparing against it would silently
758
+ // swallow a genuine command. The most visible symptom was a valve that
759
+ // could be turned on but never off — the "off" matched the stale "off"
760
+ // still in `state` (the "on" hadn't been echoed back yet) and was
761
+ // discarded, so HomeKit showed the zone closed while it kept running.
762
+ // Callers (_setValveActive, _setSystemActive, the auto-shutoff timers)
763
+ // already queue only real changes, judged against the HomeKit state the
764
+ // user sees, so there is nothing left to de-dupe here.
765
+ const dps = pending.dps;
766
+ if (Object.keys(dps).length) this.device.update(dps);
627
767
  }
628
768
 
629
769
  _valveService(cfg) {
@@ -34,7 +34,7 @@ class MultiOutletAccessory extends BaseAccessory {
34
34
  this.accessory.services
35
35
  .filter(service => service.UUID === Service.Outlet.UUID && !_validServices.includes(service))
36
36
  .forEach(service => {
37
- this.log.info('Removing', service.displayName);
37
+ this.log.debug('Removing', service.displayName);
38
38
  this.accessory.removeService(service);
39
39
  });
40
40
  }
@@ -69,6 +69,7 @@ class MultiOutletAccessory extends BaseAccessory {
69
69
  }
70
70
 
71
71
  setPower(dp, value) {
72
+ if (!this.device.connected) throw this._commError();
72
73
  if (!this._pendingPower) {
73
74
  this._pendingPower = {props: {}, resolvers: []};
74
75
  }
@@ -82,7 +83,7 @@ class MultiOutletAccessory extends BaseAccessory {
82
83
  this._pendingPower.timer = setTimeout(() => {
83
84
  const {props, resolvers} = this._pendingPower;
84
85
  this._pendingPower = null;
85
- this.setMultiStateAsync(props);
86
+ this.setMultiStateInBackground(props);
86
87
  resolvers.forEach(r => r());
87
88
  }, 500);
88
89
  });
@@ -203,8 +203,8 @@ class OilDiffuserAccessory extends BaseAccessory {
203
203
  }
204
204
 
205
205
  getBrightness() {
206
- if (this.device.state[this.dpMode] === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpColor]).b;
207
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).b;
206
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).b;
207
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).b;
208
208
  }
209
209
 
210
210
  setBrightness(value) {
@@ -218,8 +218,8 @@ class OilDiffuserAccessory extends BaseAccessory {
218
218
  }
219
219
 
220
220
  getColorTemperature() {
221
- if (this.device.state[this.dpMode] !== this.cmdWhite) return 0;
222
- return this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature]);
221
+ if (this.getStateAsync(this.dpMode) !== this.cmdWhite) return 0;
222
+ return this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature));
223
223
  }
224
224
 
225
225
  setColorTemperature(value) {
@@ -234,8 +234,8 @@ class OilDiffuserAccessory extends BaseAccessory {
234
234
  }
235
235
 
236
236
  getHue() {
237
- if (this.device.state[this.dpMode] === this.cmdWhite) return 0;
238
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).h;
237
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) return 0;
238
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).h;
239
239
  }
240
240
 
241
241
  setHue(value) {
@@ -243,7 +243,7 @@ class OilDiffuserAccessory extends BaseAccessory {
243
243
  }
244
244
 
245
245
  getSaturation() {
246
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).s;
246
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).s;
247
247
  }
248
248
 
249
249
  setSaturation(value) {
@@ -251,6 +251,8 @@ class OilDiffuserAccessory extends BaseAccessory {
251
251
  }
252
252
 
253
253
  _setHueSaturation(prop) {
254
+ if (!this.device.connected) throw this._commError();
255
+
254
256
  if (!this._pendingHueSaturation) {
255
257
  this._pendingHueSaturation = {props: {}, resolvers: []};
256
258
  }
@@ -266,7 +268,7 @@ class OilDiffuserAccessory extends BaseAccessory {
266
268
  this._pendingHueSaturation = null;
267
269
 
268
270
  const newValue = this.convertColorFromHomeKitToTuya(props);
269
- this.setMultiStateAsync({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
271
+ this.setMultiStateInBackground({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
270
272
 
271
273
  resolvers.forEach(r => r());
272
274
  }, 500);