homebridge-tuya-plus 3.14.0-dev.3 → 3.14.0-dev.30

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 (47) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +120 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +21 -0
  5. package/Readme.MD +8 -24
  6. package/config.schema.json +122 -68
  7. package/index.js +498 -269
  8. package/lib/BaseAccessory.js +52 -15
  9. package/lib/ConvectorAccessory.js +1 -1
  10. package/lib/CustomMultiOutletAccessory.js +2 -1
  11. package/lib/IrrigationSystemAccessory.js +98 -95
  12. package/lib/MultiOutletAccessory.js +2 -1
  13. package/lib/OilDiffuserAccessory.js +10 -8
  14. package/lib/RGBTWLightAccessory.js +13 -11
  15. package/lib/RGBTWOutletAccessory.js +11 -9
  16. package/lib/SimpleBlindsAccessory.js +5 -5
  17. package/lib/SimpleDimmer2Accessory.js +1 -1
  18. package/lib/SimpleDimmerAccessory.js +10 -6
  19. package/lib/SimpleGarageDoorAccessory.js +78 -24
  20. package/lib/SimpleHeaterAccessory.js +3 -3
  21. package/lib/SwitchAccessory.js +2 -1
  22. package/lib/TWLightAccessory.js +2 -2
  23. package/lib/TuyaAccessory.js +0 -1
  24. package/lib/TuyaCloudApi.js +320 -0
  25. package/lib/TuyaCloudDevice.js +327 -0
  26. package/lib/TuyaCloudMessaging.js +219 -0
  27. package/lib/TuyaDevice.js +266 -0
  28. package/lib/ValveAccessory.js +16 -12
  29. package/lib/VerticalBlindsWithTilt.js +5 -3
  30. package/lib/WledDimmerAccessory.js +46 -81
  31. package/package.json +5 -3
  32. package/scripts/cleanup-pr-tags.js +122 -0
  33. package/test/BaseAccessory.test.js +94 -15
  34. package/test/IrrigationSystemAccessory.test.js +134 -31
  35. package/test/MultiOutletAccessory.test.js +18 -3
  36. package/test/RGBTWLightAccessory.test.js +3 -3
  37. package/test/SimpleFanLightAccessory.test.js +3 -3
  38. package/test/SimpleGarageDoorAccessory.test.js +63 -9
  39. package/test/TuyaCloudApi.test.js +221 -0
  40. package/test/TuyaCloudDevice.test.js +304 -0
  41. package/test/TuyaCloudMessaging.test.js +113 -0
  42. package/test/TuyaDevice.test.js +252 -0
  43. package/test/getCategory.test.js +1 -0
  44. package/test/index.test.js +183 -0
  45. package/test/support/mocks.js +13 -0
  46. package/wiki/Supported-Device-Types.md +60 -28
  47. package/wiki/Tuya-Cloud-Setup.md +71 -0
@@ -65,8 +65,18 @@ class BaseAccessory {
65
65
  return typeof b === 'boolean' ? b : (typeof b === 'string' ? b.toLowerCase().trim() === 'true' : (typeof b === 'number' ? b !== 0 : df));
66
66
  }
67
67
 
68
+ // A HapStatusError to throw from a get/set handler when the value can't be
69
+ // served (device unreachable, or an unusable reading). HomeKit shows the
70
+ // accessory as "No Response". Preferred over a plain `throw new Error(...)`,
71
+ // which newer Homebridge logs a characteristic warning for before falling
72
+ // back to this same status code (SERVICE_COMMUNICATION_FAILURE, -70402).
73
+ _commError() {
74
+ const {HapStatusError, HAPStatus} = this.hap;
75
+ return new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE);
76
+ }
77
+
68
78
  getState(dp, callback) {
69
- if (!this.device.connected) return callback(true);
79
+ if (!this.device.connected) return callback(this._commError());
70
80
  const _callback = () => {
71
81
  if (Array.isArray(dp)) {
72
82
  const ret = {};
@@ -91,36 +101,37 @@ class BaseAccessory {
91
101
  //For devices like the DETA Smart Fan Controller Switch that by default set the speed as 3 the new code in the setMultiState function causes issues.
92
102
  if (!this.device.connected) {
93
103
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
94
- return callback && callback();
104
+ return callback && callback(this._commError());
95
105
  }
96
106
  const ret = this.device.update(dps);
97
- callback && callback(!ret);
107
+ callback && callback(ret === false ? this._commError() : null);
98
108
  }
99
109
 
100
110
  setMultiState(dps, callback) {
101
111
  if (!this.device.connected) {
102
112
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
103
- return callback && callback();
113
+ return callback && callback(this._commError());
104
114
  }
115
+ let failed = false;
105
116
  for (const dp in dps) {
106
117
  if (dps.hasOwnProperty(dp) && dps[dp] !== this.device.state[dp]){
107
- this.__ret = this.device.update({[dp.toString()] : dps[dp]});
118
+ if (this.device.update({[dp.toString()] : dps[dp]}) === false) failed = true;
108
119
  }
109
120
  }
110
- callback && callback(!this.__ret);
121
+ callback && callback(failed ? this._commError() : null);
111
122
  }
112
123
 
113
124
  getDividedState(dp, divisor, callback) {
114
125
  this.getState(dp, (err, data) => {
115
126
  if (err) return callback(err);
116
- if (!isFinite(data)) return callback(true);
127
+ if (!isFinite(data)) return callback(this._commError());
117
128
 
118
129
  callback(null, this._getDividedState(data, divisor));
119
130
  });
120
131
  }
121
132
 
122
133
  getStateAsync(dp) {
123
- if (!this.device.connected) throw new Error('Not connected');
134
+ if (!this.device.connected) throw this._commError();
124
135
  if (Array.isArray(dp)) {
125
136
  const ret = {};
126
137
  dp.forEach(p => { ret[p] = this.device.state[p]; });
@@ -133,29 +144,55 @@ class BaseAccessory {
133
144
  return this.setMultiStateAsync({[dp.toString()]: value});
134
145
  }
135
146
 
136
- setMultiStateAsync(dps) {
147
+ // Throws a "No Response" HapStatusError when the device is unreachable or the
148
+ // write is rejected, so a get/set handler that returns this promise surfaces
149
+ // the failure to HomeKit instead of silently pretending the write succeeded.
150
+ // `device.update` returns a boolean for LAN devices and an awaitable boolean
151
+ // for cloud devices (false == the command did not go through).
152
+ async setMultiStateAsync(dps) {
137
153
  if (!this.device.connected) {
138
154
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
139
- return;
155
+ throw this._commError();
140
156
  }
157
+ const writes = [];
141
158
  for (const dp in dps) {
142
159
  if (dps.hasOwnProperty(dp) && dps[dp] !== this.device.state[dp]) {
143
- this.device.update({[dp.toString()]: dps[dp]});
160
+ writes.push(this.device.update({[dp.toString()]: dps[dp]}));
144
161
  }
145
162
  }
163
+ const results = await Promise.all(writes);
164
+ if (results.some(ok => ok === false)) throw this._commError();
146
165
  }
147
166
 
148
- setMultiStateLegacyAsync(dps) {
167
+ async setMultiStateLegacyAsync(dps) {
149
168
  if (!this.device.connected) {
150
169
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
151
- return;
170
+ throw this._commError();
152
171
  }
153
- this.device.update(dps);
172
+ if (await this.device.update(dps) === false) throw this._commError();
173
+ }
174
+
175
+ // Non-throwing write helpers for callers that are NOT inside a HomeKit
176
+ // get/set handler — setTimeout callbacks, multi-step command sequences, etc.
177
+ // The async helpers above reject on a disconnected/failed write so HomeKit
178
+ // can show "No Response"; in a detached/background context there is no
179
+ // HomeKit request to fail and an unhandled rejection would crash Homebridge,
180
+ // so these swallow the error (the helper has already logged it).
181
+ setStateInBackground(dp, value) {
182
+ this.setStateAsync(dp, value).catch(() => {});
183
+ }
184
+
185
+ setMultiStateInBackground(dps) {
186
+ this.setMultiStateAsync(dps).catch(() => {});
187
+ }
188
+
189
+ setMultiStateLegacyInBackground(dps) {
190
+ this.setMultiStateLegacyAsync(dps).catch(() => {});
154
191
  }
155
192
 
156
193
  getDividedStateAsync(dp, divisor) {
157
194
  const data = this.getStateAsync(dp);
158
- if (!isFinite(data)) throw new Error('Invalid state');
195
+ if (!isFinite(data)) throw this._commError();
159
196
  return this._getDividedState(data, divisor);
160
197
  }
161
198
 
@@ -55,7 +55,7 @@ class ConvectorAccessory extends BaseAccessory {
55
55
  service.getCharacteristic(Characteristic.TargetHeaterCoolerState)
56
56
  .setProps({ minValue: 1, maxValue: 1, validValues: [Characteristic.TargetHeaterCoolerState.HEAT] })
57
57
  .updateValue(this._getTargetHeaterCoolerState())
58
- .onGet(() => this._getTargetHeaterCoolerState())
58
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return this._getTargetHeaterCoolerState(); })
59
59
  .onSet(() => this.setStateAsync(this.dpActive, true));
60
60
 
61
61
  const characteristicCurrentTemperature = service.getCharacteristic(Characteristic.CurrentTemperature)
@@ -74,6 +74,7 @@ class CustomMultiOutletAccessory extends BaseAccessory {
74
74
  }
75
75
 
76
76
  setPower(dp, value) {
77
+ if (!this.device.connected) throw this._commError();
77
78
  if (!this._pendingPower) {
78
79
  this._pendingPower = {props: {}, resolvers: []};
79
80
  }
@@ -87,7 +88,7 @@ class CustomMultiOutletAccessory extends BaseAccessory {
87
88
  this._pendingPower.timer = setTimeout(() => {
88
89
  const {props, resolvers} = this._pendingPower;
89
90
  this._pendingPower = null;
90
- this.setMultiStateAsync(props);
91
+ this.setMultiStateInBackground(props);
91
92
  resolvers.forEach(r => r());
92
93
  }, 500);
93
94
  });
@@ -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
@@ -49,11 +50,15 @@ class IrrigationSystemAccessory extends BaseAccessory {
49
50
 
50
51
  if (Array.isArray(this.device.context.valves) && this.device.context.valves.length) {
51
52
  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)}`);
53
+ // A data-point may be a numeric id (1, 2, ) or a Tuya code
54
+ // (e.g. "switch_1"); both resolve over either transport. Only an
55
+ // empty value is invalid.
56
+ const dp = (valve && valve.dp !== undefined && valve.dp !== null) ? ('' + valve.dp).trim() : '';
57
+ if (!dp) {
58
+ throw new Error(`The valve definition #${i + 1} is missing a 'dp': ${JSON.stringify(valve)}`);
54
59
  }
55
60
  return {
56
- dp: String(parseInt(valve.dp)),
61
+ dp,
57
62
  name: (('' + (valve.name || '')).trim()) || ('Zone ' + (i + 1)),
58
63
  index: i + 1,
59
64
  duration: isFinite(valve.defaultDuration) ? parseInt(valve.defaultDuration) : defaultDuration
@@ -75,16 +80,15 @@ class IrrigationSystemAccessory extends BaseAccessory {
75
80
  return configs;
76
81
  }
77
82
 
78
- _hasBattery() {
79
- return !this._coerceBoolean(this.device.context.noBattery, false);
83
+ // Resolve a configurable data-point (a numeric id or a Tuya code), trimming and
84
+ // falling back to a default when it isn't set.
85
+ _resolveDP(value, fallback) {
86
+ const v = (value === undefined || value === null) ? '' : ('' + value).trim();
87
+ return v !== '' ? v : fallback;
80
88
  }
81
89
 
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';
90
+ _hasBattery() {
91
+ return !this._coerceBoolean(this.device.context.noBattery, false);
88
92
  }
89
93
 
90
94
  /* ------------------------------------------------------------------ *
@@ -127,6 +131,25 @@ class IrrigationSystemAccessory extends BaseAccessory {
127
131
  irrigation.addLinkedService(valve);
128
132
  });
129
133
 
134
+ // --- Service Label (required when the accessory exposes a *collection*
135
+ // of same-type services, i.e. more than one Valve) ---
136
+ // HAP's Service Label profile says an accessory holding several services
137
+ // of the same type must carry a Service Label service, with each member
138
+ // service tagged by a ServiceLabelIndex. We already set the indices; the
139
+ // anchoring Service Label service was missing. Without it the stricter
140
+ // Home app clients (notably iOS) scatter the zones as separate tiles
141
+ // rather than nesting them under the irrigation system — macOS happens to
142
+ // render leniently either way. The namespace is set in
143
+ // _registerCharacteristics. A single-valve system is not a collection, so
144
+ // it needs no label.
145
+ const multiValve = valveConfigs.length > 1;
146
+ const existingLabel = this.accessory.getService(Service.ServiceLabel);
147
+ if (multiValve && !existingLabel) {
148
+ this.accessory.addService(Service.ServiceLabel, this.device.context.name);
149
+ } else if (!multiValve && existingLabel) {
150
+ this.accessory.removeService(existingLabel);
151
+ }
152
+
130
153
  // --- Battery (optional) ---
131
154
  if (this._hasBattery()) {
132
155
  if (!this.accessory.getService(Service.Battery)) {
@@ -134,18 +157,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
134
157
  }
135
158
  }
136
159
 
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
160
  // --- Remove services that no longer belong (config changed) ---
150
161
  this.accessory.services
151
162
  .filter(service => service.UUID === Service.Valve.UUID && !validValveSubtypes.includes(service.subtype))
@@ -158,12 +169,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
158
169
  const battery = this.accessory.getService(Service.Battery);
159
170
  if (battery) this.accessory.removeService(battery);
160
171
  }
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
- }
172
+
173
+ // Rain/leak sensors are no longer supported: they never reported
174
+ // reliably on these devices, and bundling a sensor in the same accessory
175
+ // forced the Home app to fragment the sprinkler into "sub-accessories"
176
+ // (blocking control from the main tile). Drop any left over from an older
177
+ // version so the accessory stays a clean, single-category sprinkler tile.
178
+ [Service.ContactSensor, Service.LeakSensor].forEach(S => {
179
+ const svc = this.accessory.getService(S);
180
+ if (svc) {
181
+ this.log.info('Removing rain sensor service %s (no longer supported)', svc.displayName);
182
+ this.accessory.removeService(svc);
183
+ }
184
+ });
167
185
  }
168
186
 
169
187
  /* ------------------------------------------------------------------ *
@@ -186,11 +204,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
186
204
  this._cascadeOn = this._coerceBoolean(this.device.context.masterTurnsOnAllZones, true);
187
205
  this._cascadeOff = this._coerceBoolean(this.device.context.masterTurnsOffAllZones, true);
188
206
 
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);
207
+ // Data-points default to this device's numeric ids; a Tuya code may be
208
+ // configured instead and works over either transport.
209
+ this.dpBattery = this._resolveDP(this.device.context.dpBattery, '46');
210
+ this.dpCharging = this._resolveDP(this.device.context.dpCharging, '101');
194
211
 
195
212
  // Per-zone runtime state
196
213
  this._valves = this._getValveConfigs();
@@ -210,7 +227,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
210
227
 
211
228
  this._systemActiveChar = irrigation.getCharacteristic(Characteristic.Active)
212
229
  .updateValue(this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
213
- .onGet(() => this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
230
+ .onGet(() => this._reportCached(this._systemActiveChar))
214
231
  .onSet(value => this._setSystemActive(value));
215
232
 
216
233
  this._systemInUseChar = irrigation.getCharacteristic(Characteristic.InUse)
@@ -253,10 +270,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
253
270
  .updateValue(0)
254
271
  .onGet(() => this._valveRemaining(cfg));
255
272
 
256
- valve.getCharacteristic(Characteristic.Active)
273
+ const activeChar = valve.getCharacteristic(Characteristic.Active)
257
274
  .updateValue(on ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
258
- .onGet(() => (this.getStateAsync(cfg.dp) ? 1 : 0))
259
275
  .onSet(value => this._setValveActive(cfg, value));
276
+ activeChar.onGet(() => this._reportCached(activeChar));
260
277
 
261
278
  valve.getCharacteristic(Characteristic.InUse)
262
279
  .updateValue(on ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
@@ -266,6 +283,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
266
283
  if (on) this._reflectValve(cfg, true);
267
284
  });
268
285
 
286
+ // --- Service Label namespace ---
287
+ // Anchors the per-valve ServiceLabelIndex so the Home app groups the
288
+ // zones under the irrigation tile (see _verifyCachedPlatformAccessory).
289
+ // Arabic numerals: zones are labelled 1, 2, 3… — the user-set zone names
290
+ // (ConfiguredName) still take precedence in the Home app.
291
+ if (this._valves.length > 1) {
292
+ const serviceLabel = this.accessory.getService(Service.ServiceLabel);
293
+ if (serviceLabel) {
294
+ serviceLabel.getCharacteristic(Characteristic.ServiceLabelNamespace)
295
+ .updateValue(Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS);
296
+ }
297
+ }
298
+
269
299
  // --- Battery ---
270
300
  if (this._hasBattery()) {
271
301
  const battery = this.accessory.getService(Service.Battery);
@@ -284,25 +314,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
284
314
  .onGet(() => this._chargingState(this.getStateAsync(this.dpCharging)));
285
315
  }
286
316
 
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
317
  this._syncAggregate();
303
318
 
304
319
  // --- React to device-side changes (physical buttons, our own writes
305
- // being confirmed, battery/rain telemetry) ---
320
+ // being confirmed, battery telemetry) ---
306
321
  this.device.on('change', (changes) => this._onDeviceChange(changes));
307
322
  }
308
323
 
@@ -335,21 +350,22 @@ class IrrigationSystemAccessory extends BaseAccessory {
335
350
  .updateValue(this._chargingState(changes[this.dpCharging]));
336
351
  }
337
352
 
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
353
  if (valveChanged) this._syncAggregate();
351
354
  }
352
355
 
356
+ // onGet handler for the system / valve Active characteristics. Returns the
357
+ // value HomeKit already shows — the optimistic value written on press and
358
+ // then confirmed (or corrected) by device-side change events — rather than
359
+ // the raw `device.state`. Cloud devices don't advance `device.state` until
360
+ // the realtime stream echoes the write back, so reading it here returned the
361
+ // pre-press value for a moment and made the Home app toggle visibly flicker
362
+ // back to the old state before settling. Throwing while disconnected keeps
363
+ // the HomeKit "No Response" behaviour.
364
+ _reportCached(char) {
365
+ if (!this.device.connected) throw this._commError();
366
+ return char.value;
367
+ }
368
+
353
369
  /* ------------------------------------------------------------------ *
354
370
  * Valve activation + timer logic
355
371
  * ------------------------------------------------------------------ */
@@ -526,7 +542,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
526
542
  }
527
543
 
528
544
  /* ------------------------------------------------------------------ *
529
- * Battery + rain mapping
545
+ * Battery mapping
530
546
  * ------------------------------------------------------------------ */
531
547
 
532
548
  _batteryLevel(value) {
@@ -552,22 +568,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
552
568
  : Characteristic.ChargingState.NOT_CHARGING;
553
569
  }
554
570
 
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
571
  /* ------------------------------------------------------------------ *
572
572
  * Batched writes — coalesce DP updates into a single Tuya command
573
573
  * ------------------------------------------------------------------ */
@@ -590,16 +590,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
590
590
  return;
591
591
  }
592
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);
593
+ // Send exactly what was queued. We deliberately do NOT drop data-points
594
+ // that appear to already match `device.state`: cloud devices never
595
+ // optimistically advance `state` (it only moves when the realtime/refresh
596
+ // stream confirms the device), so comparing against it would silently
597
+ // swallow a genuine command. The most visible symptom was a valve that
598
+ // could be turned on but never off — the "off" matched the stale "off"
599
+ // still in `state` (the "on" hadn't been echoed back yet) and was
600
+ // discarded, so HomeKit showed the zone closed while it kept running.
601
+ // Callers (_setValveActive, _setSystemActive, the auto-shutoff timers)
602
+ // already queue only real changes, judged against the HomeKit state the
603
+ // user sees, so there is nothing left to de-dupe here.
604
+ const dps = pending.dps;
605
+ if (Object.keys(dps).length) this.device.update(dps);
603
606
  }
604
607
 
605
608
  _valveService(cfg) {
@@ -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);
@@ -134,8 +134,8 @@ class RGBTWLightAccessory extends BaseAccessory {
134
134
  }
135
135
 
136
136
  getBrightness() {
137
- if (this.device.state[this.dpMode] === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness]);
138
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).b;
137
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.getStateAsync(this.dpBrightness));
138
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).b;
139
139
  }
140
140
 
141
141
  setBrightness(value) {
@@ -145,8 +145,8 @@ class RGBTWLightAccessory extends BaseAccessory {
145
145
 
146
146
  getColorTemperature() {
147
147
  this.log.debug(`getColorTemperature`);
148
- if (this.device.state[this.dpMode] !== this.cmdWhite) return this.minWhiteColor;
149
- return this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature]);
148
+ if (this.getStateAsync(this.dpMode) !== this.cmdWhite) return this.minWhiteColor;
149
+ return this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature));
150
150
  }
151
151
 
152
152
  setColorTemperature(value) {
@@ -165,10 +165,10 @@ class RGBTWLightAccessory extends BaseAccessory {
165
165
  }
166
166
 
167
167
  getHue() {
168
- if (this.device.state[this.dpMode] === this.cmdWhite) {
169
- return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature])).h;
168
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) {
169
+ return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature))).h;
170
170
  }
171
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).h;
171
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).h;
172
172
  }
173
173
 
174
174
  setHue(value) {
@@ -176,10 +176,10 @@ class RGBTWLightAccessory extends BaseAccessory {
176
176
  }
177
177
 
178
178
  getSaturation() {
179
- if (this.device.state[this.dpMode] === this.cmdWhite) {
180
- return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature])).s;
179
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) {
180
+ return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature))).s;
181
181
  }
182
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).s;
182
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).s;
183
183
  }
184
184
 
185
185
  setSaturation(value) {
@@ -187,6 +187,8 @@ class RGBTWLightAccessory extends BaseAccessory {
187
187
  }
188
188
 
189
189
  _setHueSaturation(prop) {
190
+ if (!this.device.connected) throw this._commError();
191
+
190
192
  if (!this._pendingHueSaturation) {
191
193
  this._pendingHueSaturation = {props: {}, resolvers: []};
192
194
  }
@@ -208,7 +210,7 @@ class RGBTWLightAccessory extends BaseAccessory {
208
210
  const newValue = this.convertColorFromHomeKitToTuya(props);
209
211
 
210
212
  if (!(this.device.state[this.dpMode] === this.cmdWhite && isSham)) {
211
- this.setMultiStateAsync({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
213
+ this.setMultiStateInBackground({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
212
214
  }
213
215
  this.characteristicColorTemperature.updateValue(this.minWhiteColor);
214
216