homebridge-tuya-plus 3.14.0-dev.10 → 3.14.0-dev.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Changelog.md CHANGED
@@ -7,8 +7,11 @@ All notable changes to this project will be documented in this file. This projec
7
7
  * [+] **Tuya Cloud support for devices that can't be reached over the LAN** — most notably battery-powered "sleepy" irrigation/sprinkler timers, which sleep almost all the time and only ever talk to Tuya's cloud, so the local protocol can never reach them. The plugin stays LAN-first: cloud is strictly opt-in. Add a top-level `cloud` credentials block (or a per-device `cloud` object) and set `"cloud": true` on the device.
8
8
  * Realtime updates arrive over Tuya's **MQTT** message service (via the optional `mqtt` dependency, installed automatically); initial state and control use the Tuya OpenAPI. There is no polling.
9
9
  * Works with both **Custom** and **Smart Home** Cloud projects (the latter via app-account login).
10
- * The existing **IrrigationSystem** accessory works unchanged over the cloud — its data-points are simply addressed by Tuya "code" (e.g. `switch_1`, `battery_percentage`) instead of a numeric id; the device logs its codes on startup. Battery-only controllers with no rain sensor: set `"noRainSensor": true`.
10
+ * The existing **IrrigationSystem** accessory works unchanged over the cloud — its data-points are simply addressed by Tuya "code" (e.g. `switch_1`, `battery_percentage`) instead of a numeric id; the device logs its codes on startup.
11
11
  * See the wiki: **[Tuya Cloud Setup](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Tuya-Cloud-Setup.md)**.
12
+ * [*] **Fix realtime (MQTT) cloud updates being silently dropped** — external changes (physical buttons, the Tuya app, the device's own timers) now show up in HomeKit within a second or two. The decryptor was verifying the AES-GCM auth tag (`decipher.final()`), but Tuya's real status frames don't carry a tag that verifies against the documented AAD, so every realtime message was being thrown away. Now decrypts with `update()` only, matching the official `tuya/tuya-homebridge` and `0x5e/homebridge-tuya-platform` implementations.
13
+ * [*] **Fix cloud irrigation valves that could be turned on but not off** — the per-zone write coalescer was dropping any command that matched the last-known `device.state`. Cloud devices never optimistically advance `state` (it only moves once the realtime stream confirms the device), so an "off" issued before the "on" was echoed matched the stale "off" and was discarded — HomeKit showed the zone closed while it kept running. Queued commands are now sent as-is (callers already queue only genuine changes).
14
+ * [*] **IrrigationSystem: remove the rain sensor.** It never reported reliably on these devices, and bundling a sensor (a different HomeKit category) in the same accessory forced the Home app to fragment the sprinkler into "sub-accessories" — blocking control from the main tile and hiding the system master on/off. The accessory is now a single, clean sprinkler tile (IrrigationSystem + valves + optional battery); any leftover Contact/Leak sensor service from a previous build is removed automatically on restart. The `noRainSensor`, `rainSensorType`, `rainInverted`, `dpRain` and `rainOnValue` options are gone.
12
15
 
13
16
  ## 2.0.1 (2021-03-25)
14
17
  This update includes the following changes:
package/Readme.MD CHANGED
@@ -38,7 +38,7 @@ A community-maintained Homebridge plugin for controlling Tuya devices locally ov
38
38
  * Fan v2<sup>[7](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md)</sup>
39
39
  * Garages<sup>[8](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#garage-doors)</sup>
40
40
  * Heaters<sup>[9](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md)</sup>
41
- * Irrigation Systems / Sprinklers<sup>[17](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#irrigation-systems--sprinklers)</sup> (multi-valve, per-zone timers, rain sensor, battery)
41
+ * Irrigation Systems / Sprinklers<sup>[17](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#irrigation-systems--sprinklers)</sup> (multi-valve, per-zone timers, battery)
42
42
  * Lights
43
43
  * On/Off<sup>[10](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md)</sup>
44
44
  * Brightness<sup>[11](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#tunable-white-light-bulbs)</sup>
@@ -850,51 +850,6 @@
850
850
  "condition": {
851
851
  "functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noBattery;"
852
852
  }
853
- },
854
- "noRainSensor": {
855
- "type": "boolean",
856
- "title": "No rain sensor",
857
- "description": "Disable the rain sensor (use if your controller doesn't report 'rain_sensor_state').",
858
- "condition": {
859
- "functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
860
- }
861
- },
862
- "rainSensorType": {
863
- "type": "string",
864
- "title": "Rain sensor type",
865
- "default": "contact",
866
- "oneOf": [
867
- { "title": "Contact sensor (Open / Closed)", "enum": ["contact"] },
868
- { "title": "Leak sensor (Leak Detected / Dry — note: fires critical alerts)", "enum": ["leak"] }
869
- ],
870
- "condition": {
871
- "functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noRainSensor;"
872
- }
873
- },
874
- "rainInverted": {
875
- "type": "boolean",
876
- "title": "Invert rain sensor",
877
- "description": "Flip the reported state if 'raining' and 'dry' appear reversed in HomeKit.",
878
- "condition": {
879
- "functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noRainSensor;"
880
- }
881
- },
882
- "dpRain": {
883
- "type": ["integer", "string"],
884
- "placeholder": "49 or rain_sensor_state",
885
- "title": "Rain sensor data-point (numeric id, or cloud code)",
886
- "condition": {
887
- "functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noRainSensor;"
888
- }
889
- },
890
- "rainOnValue": {
891
- "type": "string",
892
- "placeholder": "rain",
893
- "title": "Rain sensor 'raining' value",
894
- "description": "The enum value the device reports when it is raining (default 'rain').",
895
- "condition": {
896
- "functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noRainSensor;"
897
- }
898
853
  }
899
854
  }
900
855
  }
@@ -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
  *
@@ -14,7 +14,6 @@ const BaseAccessory = require('./BaseAccessory');
14
14
  * ├─ Valve "Zone B" (linked, ServiceLabelIndex 2)
15
15
  * ├─ ...
16
16
  * Battery (BatteryLevel + StatusLowBattery)
17
- * ContactSensor (rain — or LeakSensor, configurable)
18
17
  *
19
18
  * Each Valve carries its own SetDuration/RemainingDuration so the Home app shows
20
19
  * the familiar per-zone "Duration" picker and a live countdown. A zone whose
@@ -101,14 +100,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
101
100
  return !this._coerceBoolean(this.device.context.noBattery, false);
102
101
  }
103
102
 
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
103
  /* ------------------------------------------------------------------ *
113
104
  * Service registration / cache reconciliation
114
105
  * ------------------------------------------------------------------ */
@@ -156,18 +147,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
156
147
  }
157
148
  }
158
149
 
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
150
  // --- Remove services that no longer belong (config changed) ---
172
151
  this.accessory.services
173
152
  .filter(service => service.UUID === Service.Valve.UUID && !validValveSubtypes.includes(service.subtype))
@@ -180,12 +159,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
180
159
  const battery = this.accessory.getService(Service.Battery);
181
160
  if (battery) this.accessory.removeService(battery);
182
161
  }
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
- }
162
+
163
+ // Rain/leak sensors are no longer supported: they never reported
164
+ // reliably on these devices, and bundling a sensor in the same accessory
165
+ // forced the Home app to fragment the sprinkler into "sub-accessories"
166
+ // (blocking control from the main tile). Drop any left over from an older
167
+ // version so the accessory stays a clean, single-category sprinkler tile.
168
+ [Service.ContactSensor, Service.LeakSensor].forEach(S => {
169
+ const svc = this.accessory.getService(S);
170
+ if (svc) {
171
+ this.log.info('Removing rain sensor service %s (no longer supported)', svc.displayName);
172
+ this.accessory.removeService(svc);
173
+ }
174
+ });
189
175
  }
190
176
 
191
177
  /* ------------------------------------------------------------------ *
@@ -212,9 +198,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
212
198
  // differ per transport (cloud uses the standard Tuya codes).
213
199
  this.dpBattery = this._resolveDP(this.device.context.dpBattery, 'battery_percentage', '46');
214
200
  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);
218
201
 
219
202
  // Per-zone runtime state
220
203
  this._valves = this._getValveConfigs();
@@ -308,25 +291,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
308
291
  .onGet(() => this._chargingState(this.getStateAsync(this.dpCharging)));
309
292
  }
310
293
 
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
294
  this._syncAggregate();
327
295
 
328
296
  // --- React to device-side changes (physical buttons, our own writes
329
- // being confirmed, battery/rain telemetry) ---
297
+ // being confirmed, battery telemetry) ---
330
298
  this.device.on('change', (changes) => this._onDeviceChange(changes));
331
299
  }
332
300
 
@@ -359,18 +327,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
359
327
  .updateValue(this._chargingState(changes[this.dpCharging]));
360
328
  }
361
329
 
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
330
  if (valveChanged) this._syncAggregate();
375
331
  }
376
332
 
@@ -550,7 +506,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
550
506
  }
551
507
 
552
508
  /* ------------------------------------------------------------------ *
553
- * Battery + rain mapping
509
+ * Battery mapping
554
510
  * ------------------------------------------------------------------ */
555
511
 
556
512
  _batteryLevel(value) {
@@ -576,22 +532,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
576
532
  : Characteristic.ChargingState.NOT_CHARGING;
577
533
  }
578
534
 
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
535
  /* ------------------------------------------------------------------ *
596
536
  * Batched writes — coalesce DP updates into a single Tuya command
597
537
  * ------------------------------------------------------------------ */
@@ -614,16 +554,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
614
554
  return;
615
555
  }
616
556
 
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);
557
+ // Send exactly what was queued. We deliberately do NOT drop data-points
558
+ // that appear to already match `device.state`: cloud devices never
559
+ // optimistically advance `state` (it only moves when the realtime/refresh
560
+ // stream confirms the device), so comparing against it would silently
561
+ // swallow a genuine command. The most visible symptom was a valve that
562
+ // could be turned on but never off — the "off" matched the stale "off"
563
+ // still in `state` (the "on" hadn't been echoed back yet) and was
564
+ // discarded, so HomeKit showed the zone closed while it kept running.
565
+ // Callers (_setValveActive, _setSystemActive, the auto-shutoff timers)
566
+ // already queue only real changes, judged against the HomeKit state the
567
+ // user sees, so there is nothing left to de-dupe here.
568
+ const dps = pending.dps;
569
+ if (Object.keys(dps).length) this.device.update(dps);
627
570
  }
628
571
 
629
572
  _valveService(cfg) {
@@ -145,12 +145,12 @@ class TuyaCloudMessaging extends EventEmitter {
145
145
  envelope = JSON.parse(payload.toString());
146
146
  } catch (_) { return; }
147
147
 
148
- const {protocol, data, t} = envelope;
148
+ const {protocol, data} = envelope;
149
149
  if (!data) return;
150
150
 
151
151
  let plaintext;
152
152
  try {
153
- plaintext = this._decrypt(data, this.config && this.config.password, t);
153
+ plaintext = this._decrypt(data, this.config && this.config.password);
154
154
  } catch (ex) {
155
155
  this.log.debug(`Tuya Cloud realtime decrypt failed: ${ex.message}`);
156
156
  return;
@@ -179,26 +179,31 @@ class TuyaCloudMessaging extends EventEmitter {
179
179
  // Decrypt the MQTT `data` field. msg_encrypted_version 2.0 → AES-128-GCM,
180
180
  // 1.0 → AES-128-ECB. The AES key is the middle 16 chars of the broker
181
181
  // password (NOT the project Access Secret).
182
- _decrypt(data, password, t) {
182
+ //
183
+ // The GCM auth tag is intentionally NOT verified: Tuya's real status frames
184
+ // do not carry a tag that verifies against the documented AAD, so calling
185
+ // `decipher.final()` would throw on every genuine message and silently drop
186
+ // all realtime updates. AES-GCM is a stream cipher, so `update()` alone
187
+ // recovers the plaintext correctly regardless of the tag; a wrong key just
188
+ // yields garbage that the subsequent JSON.parse rejects. Both the official
189
+ // `tuya/tuya-homebridge` and `0x5e/homebridge-tuya-platform` decrypt with
190
+ // `update()` only, for exactly this reason — `final()` here was the bug that
191
+ // stopped external changes (physical buttons, the Tuya app) from showing up.
192
+ _decrypt(data, password) {
183
193
  const key = Buffer.from(('' + (password || '')).substring(8, 24), 'utf8');
184
194
  const buf = Buffer.from(data, 'base64');
185
195
 
186
196
  // GCM (v2.0) layout: [ivLen(4) BE][iv(ivLen)][ciphertext][tag(16)].
187
- // Try it first; if the header doesn't look like a sane IV length, or
188
- // auth fails, fall back to ECB (v1.0).
197
+ // Try it first; if the header doesn't look like a sane IV length, fall
198
+ // back to ECB (v1.0).
189
199
  if (buf.length > 4 + GCM_TAG_LENGTH) {
190
200
  const ivLen = buf.readUIntBE(0, 4);
191
201
  if (ivLen >= 12 && ivLen <= 16 && (4 + ivLen + GCM_TAG_LENGTH) <= buf.length) {
192
202
  try {
193
203
  const iv = buf.slice(4, 4 + ivLen);
194
204
  const ciphertext = buf.slice(4 + ivLen, buf.length - GCM_TAG_LENGTH);
195
- const tag = buf.slice(buf.length - GCM_TAG_LENGTH);
196
205
  const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv);
197
- decipher.setAuthTag(tag);
198
- const aad = Buffer.allocUnsafe(6);
199
- aad.writeUIntBE(Number(t) || 0, 0, 6);
200
- decipher.setAAD(aad);
201
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
206
+ return decipher.update(ciphertext).toString('utf8');
202
207
  } catch (gcmEx) {
203
208
  // fall through to ECB
204
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.10",
3
+ "version": "3.14.0-dev.12",
4
4
  "description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Includes new features, fixes, and updated device support.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -125,8 +125,8 @@ describe('IrrigationSystemAccessory — category', () => {
125
125
  });
126
126
 
127
127
  describe('IrrigationSystemAccessory — service topology', () => {
128
- test('builds an IrrigationSystem (primary) + 4 linked valves + battery + contact sensor by default', () => {
129
- const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false, '46': 80, '49': 'no_rain' });
128
+ test('builds an IrrigationSystem (primary) + 4 linked valves + battery by default', () => {
129
+ const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false, '46': 80 });
130
130
 
131
131
  const irr = irrigation(accessory);
132
132
  expect(irr).toBeDefined();
@@ -138,9 +138,9 @@ describe('IrrigationSystemAccessory — service topology', () => {
138
138
  // All four valves are linked to the irrigation system.
139
139
  expect(irr.linked).toHaveLength(4);
140
140
 
141
- // Battery + ContactSensor present; LeakSensor absent.
141
+ // Battery present; no rain/leak sensor is ever created.
142
142
  expect(accessory.getService(Service.Battery)).toBeDefined();
143
- expect(accessory.getService(Service.ContactSensor)).toBeDefined();
143
+ expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
144
144
  expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
145
145
  });
146
146
 
@@ -162,17 +162,23 @@ describe('IrrigationSystemAccessory — service topology', () => {
162
162
  expect(irr.getCharacteristic(Characteristic.InUse).value).toBe(Characteristic.InUse.NOT_IN_USE);
163
163
  });
164
164
 
165
- test('noBattery / noRainSensor omit those services', () => {
166
- const { accessory } = makeHarness({ '1': false }, { noBattery: true, noRainSensor: true });
165
+ test('noBattery omits the battery service', () => {
166
+ const { accessory } = makeHarness({ '1': false }, { noBattery: true });
167
167
  expect(accessory.getService(Service.Battery)).toBeUndefined();
168
- expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
169
- expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
170
168
  });
171
169
 
172
- test('rainSensorType "leak" uses a LeakSensor instead of a ContactSensor', () => {
173
- const { accessory } = makeHarness({ '49': 'rain' }, { rainSensorType: 'leak' });
174
- expect(accessory.getService(Service.LeakSensor)).toBeDefined();
170
+ test('removes a stale rain sensor service left over from an older version', () => {
171
+ const { instance, accessory } = makeHarness({ '1': false });
172
+ // An accessory cached by an older plugin version may still carry the
173
+ // ContactSensor/LeakSensor; reconciliation must drop it so the sprinkler
174
+ // stays a clean, single-category tile.
175
+ accessory.addService(Service.ContactSensor, 'Old Rain');
176
+ accessory.addService(Service.LeakSensor, 'Old Leak');
177
+
178
+ instance._verifyCachedPlatformAccessory();
179
+
175
180
  expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
181
+ expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
176
182
  });
177
183
  });
178
184
 
@@ -361,16 +367,13 @@ describe('IrrigationSystemAccessory — device-side change reflection', () => {
361
367
  expect(v.getCharacteristic(Characteristic.RemainingDuration).value).toBe(600);
362
368
  });
363
369
 
364
- test('battery + rain telemetry updates the corresponding characteristics', () => {
365
- const { accessory, device } = makeHarness({ '46': 80, '49': 'no_rain' });
366
- device.emitChange({ '46': 10, '49': 'rain' });
370
+ test('battery telemetry updates the corresponding characteristics', () => {
371
+ const { accessory, device } = makeHarness({ '46': 80 });
372
+ device.emitChange({ '46': 10 });
367
373
 
368
374
  const battery = accessory.getService(Service.Battery);
369
375
  expect(battery.getCharacteristic(Characteristic.BatteryLevel).value).toBe(10);
370
376
  expect(battery.getCharacteristic(Characteristic.StatusLowBattery).value).toBe(Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW);
371
-
372
- const contact = accessory.getService(Service.ContactSensor);
373
- expect(contact.getCharacteristic(Characteristic.ContactSensorState).value).toBe(Characteristic.ContactSensorState.CONTACT_NOT_DETECTED);
374
377
  });
375
378
  });
376
379
 
@@ -425,32 +428,11 @@ describe('IrrigationSystemAccessory — charging state', () => {
425
428
  });
426
429
  });
427
430
 
428
- describe('IrrigationSystemAccessory — rain mapping', () => {
429
- test('contact sensor: rain → NOT_DETECTED, no_rain → DETECTED', () => {
430
- const { instance } = makeHarness();
431
- expect(instance._contactState('rain')).toBe(Characteristic.ContactSensorState.CONTACT_NOT_DETECTED);
432
- expect(instance._contactState('no_rain')).toBe(Characteristic.ContactSensorState.CONTACT_DETECTED);
433
- });
434
-
435
- test('rainInverted flips the polarity', () => {
436
- const { instance } = makeHarness({}, { rainInverted: true });
437
- expect(instance._contactState('rain')).toBe(Characteristic.ContactSensorState.CONTACT_DETECTED);
438
- expect(instance._contactState('no_rain')).toBe(Characteristic.ContactSensorState.CONTACT_NOT_DETECTED);
439
- });
440
-
441
- test('leak sensor maps rain → detected', () => {
442
- const { instance } = makeHarness({}, { rainSensorType: 'leak' });
443
- expect(instance._rainDetected('rain')).toBe(true);
444
- expect(instance._rainDetected('no_rain')).toBe(false);
445
- });
446
- });
447
-
448
431
  describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)', () => {
449
432
  // Mirrors a real Tuya "sfkzq" watering controller reached over the cloud:
450
- // valves are switch_1..4, battery is battery_percentage, and there is no
451
- // rain sensor.
433
+ // valves are switch_1..4 and battery is battery_percentage.
452
434
  const cloudState = () => ({ switch_1: false, switch_2: false, switch_3: false, switch_4: false, battery_percentage: 99 });
453
- const cloudCtx = { cloud: true, noRainSensor: true };
435
+ const cloudCtx = { cloud: true };
454
436
 
455
437
  beforeEach(() => jest.useFakeTimers());
456
438
  afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
@@ -485,11 +467,43 @@ describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)',
485
467
  test('an explicit valve list may use custom codes', () => {
486
468
  const { accessory, device } = makeHarness(
487
469
  { zone_a: false, zone_b: false, battery_percentage: 50 },
488
- { cloud: true, noRainSensor: true, valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }] }
470
+ { cloud: true, valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }] }
489
471
  );
490
472
  expect(valve(accessory, 'zone_a')).toBeTruthy();
491
473
  valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
492
474
  jest.advanceTimersByTime(500);
493
475
  expect(device.update).toHaveBeenCalledWith({ zone_b: true });
494
476
  });
477
+
478
+ test('turning a zone off still writes false when the cloud never echoed the "on"', () => {
479
+ // The real cloud device never optimistically advances `state`; it only
480
+ // moves when the realtime stream confirms the device. Emulate that by
481
+ // making update() a no-op on state. A follow-up "off" must STILL be sent
482
+ // — otherwise the valve stays open while HomeKit shows it closed (the
483
+ // exact "can turn on but not off" report).
484
+ const { accessory, device } = makeHarness(cloudState(), { ...cloudCtx, defaultDuration: 0 });
485
+ device.update.mockImplementation(() => true); // writes never touch state
486
+ const v = valve(accessory, 'switch_1');
487
+
488
+ v.getCharacteristic(Characteristic.Active).triggerSet(1);
489
+ jest.advanceTimersByTime(500);
490
+ expect(device.update).toHaveBeenLastCalledWith({ switch_1: true });
491
+
492
+ v.getCharacteristic(Characteristic.Active).triggerSet(0);
493
+ jest.advanceTimersByTime(500);
494
+ expect(device.update).toHaveBeenLastCalledWith({ switch_1: false });
495
+ });
496
+
497
+ test('master OFF closes zones the cloud has not echoed as open', () => {
498
+ const { accessory, device } = makeHarness(cloudState(), { ...cloudCtx, defaultDuration: 0 });
499
+ device.update.mockImplementation(() => true); // writes never touch state
500
+
501
+ valve(accessory, 'switch_1').getCharacteristic(Characteristic.Active).triggerSet(1);
502
+ valve(accessory, 'switch_2').getCharacteristic(Characteristic.Active).triggerSet(1);
503
+ jest.advanceTimersByTime(500);
504
+
505
+ irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
506
+ jest.advanceTimersByTime(500);
507
+ expect(device.update).toHaveBeenLastCalledWith({ switch_1: false, switch_2: false });
508
+ });
495
509
  });
@@ -56,6 +56,30 @@ describe('TuyaCloudMessaging — decryption + dispatch', () => {
56
56
  expect(received[0]).toEqual([{code: 'switch_1', value: true, t}, {code: 'battery_percentage', value: 80, t}]);
57
57
  });
58
58
 
59
+ test('decrypts a GCM frame even when the auth tag does not verify (Tuya quirk)', () => {
60
+ // Real Tuya status frames carry a GCM tag that does NOT verify against
61
+ // the documented AAD. We must decrypt with update() only (no final(),
62
+ // no tag check) or every realtime update is silently dropped — which is
63
+ // exactly what stopped external changes from reaching HomeKit. Simulate
64
+ // it by clobbering the trailing 16-byte tag: the plaintext must still
65
+ // come through.
66
+ const mq = makeIdle();
67
+ const received = [];
68
+ mq.subscribeDevice('DEV1', status => received.push(status));
69
+
70
+ const t = Date.now();
71
+ const raw = Buffer.from(
72
+ encryptGCM(JSON.stringify({devId: 'DEV1', status: [{code: 'switch_1', value: true}]}), mq.config.password, t),
73
+ 'base64'
74
+ );
75
+ crypto.randomBytes(16).copy(raw, raw.length - 16); // corrupt the auth tag
76
+
77
+ mq._onMessage('topic', Buffer.from(JSON.stringify({protocol: 4, data: raw.toString('base64'), t})));
78
+
79
+ expect(received).toHaveLength(1);
80
+ expect(received[0]).toEqual([{code: 'switch_1', value: true}]);
81
+ });
82
+
59
83
  test('decrypts a legacy ECB (v1.0) frame too', () => {
60
84
  const mq = makeIdle();
61
85
  const received = [];
@@ -751,12 +751,11 @@ If the light, brightness and turning the fan **off** all work, but turning the f
751
751
 
752
752
  ### Irrigation Systems / Sprinklers
753
753
 
754
- Multi-valve Tuya irrigation/sprinkler controllers (the battery-powered Wi-Fi "faucet timers" that expose several `switch_*` valves, a `battery_percentage` and a `rain_sensor_state`) are exposed as a single, fully-fledged HomeKit **Irrigation System** accessory:
754
+ Multi-valve Tuya irrigation/sprinkler controllers (the battery-powered Wi-Fi "faucet timers" that expose several `switch_*` valves and a `battery_percentage`) are exposed as a single, fully-fledged HomeKit **Irrigation System** accessory:
755
755
 
756
756
  * one **Irrigation System** tile that contains every zone,
757
757
  * one **Valve** per zone (`ValveType = Irrigation`) — each with its own on/off, its own **Duration** picker and a live countdown,
758
- * an optional **Battery** service (level, low-battery warning, and — for solar/USB-C rechargeable units that report it — live charging status),
759
- * an optional **rain sensor** (a Contact sensor by default).
758
+ * an optional **Battery** service (level, low-battery warning, and — for solar/USB-C rechargeable units that report it — live charging status).
760
759
 
761
760
  Because these devices are slow to respond, all zone changes that happen close together — turning the whole system on/off, or running a scene that toggles several zones — are merged into a **single** Tuya command instead of a burst of them.
762
761
 
@@ -769,14 +768,13 @@ Because these devices are slow to respond, all zone changes that happen close to
769
768
  "type": "IrrigationSystem",
770
769
  "id": "bfae6739xxxxxxxxxxxxxx", // the cloud Device ID
771
770
  "cloud": true,
772
- "valveCount": 4,
773
- "noRainSensor": true // many battery timers have no rain sensor
771
+ "valveCount": 4
774
772
  }
775
773
  ```
776
774
 
777
775
  #### Minimal Configuration
778
776
 
779
- The defaults match the common 4-zone layout (valves A–D on data-points `1`–`4`, battery on `46`, rain on `49`):
777
+ The defaults match the common 4-zone layout (valves A–D on data-points `1`–`4`, battery on `46`):
780
778
 
781
779
  ```json5
782
780
  {
@@ -800,10 +798,6 @@ Each zone has its own **Duration**. When a zone is switched on it runs for that
800
798
 
801
799
  Switching the whole Irrigation System tile **off** closes every open zone, and switching it **on** opens every zone — each as one combined command (mirroring the physical "all" button many of these controllers have). Either direction can be disabled with `masterTurnsOffAllZones` / `masterTurnsOnAllZones`.
802
800
 
803
- #### Rain sensor: Contact vs Leak
804
-
805
- By default the rain sensor is a **Contact sensor** (`raining → Open`). You can switch it to a **Leak sensor** (`raining → Leak Detected`) with `"rainSensorType": "leak"` — leak sensors read more naturally for rain and make "when it starts raining" automations obvious, **but HomeKit treats them as safety accessories and raises critical alerts that bypass Do-Not-Disturb on every rainfall**, which many people find too noisy. Use `rainInverted` if the wet/dry states appear reversed.
806
-
807
801
  #### Full Configuration
808
802
 
809
803
  ```json5
@@ -841,12 +835,5 @@ By default the rain sensor is a **Contact sensor** (`raining → Open`). You can
841
835
  "lowBatteryThreshold": 20,
842
836
  "dpCharging": 101, /* boolean charging-status DP (solar / USB-C); omit if not reported */
843
837
  /* "noBattery": true, */
844
-
845
- /* --- Rain sensor (omit / set noRainSensor:true if not present) --- */
846
- "dpRain": 49,
847
- "rainSensorType": "contact", /* or "leak" */
848
- "rainOnValue": "rain", /* enum value reported while raining */
849
- "rainInverted": false
850
- /* "noRainSensor": true, */
851
838
  }
852
839
  ```
@@ -66,8 +66,7 @@ Authenticates as your app account (username/password), so it sees exactly the de
66
66
  "type": "IrrigationSystem",
67
67
  "id": "bfae6739xxxxxxxxxxxxxx", // the cloud Device ID
68
68
  "cloud": true,
69
- "valveCount": 4,
70
- "noRainSensor": true // most battery timers have no rain DP
69
+ "valveCount": 4
71
70
  }
72
71
  ]
73
72
  }
@@ -124,8 +123,7 @@ The `IrrigationSystem` defaults already match the common 4-zone layout (`switch_
124
123
  { "name": "Front Lawn", "dp": "switch_1", "defaultDuration": 900 },
125
124
  { "name": "Back Lawn", "dp": "switch_2", "defaultDuration": 900 }
126
125
  ],
127
- "dpBattery": "battery_percentage",
128
- "noRainSensor": true // or: "dpRain": "rain_sensor_state"
126
+ "dpBattery": "battery_percentage"
129
127
  ```
130
128
 
131
129
  See **[Irrigation Systems / Sprinklers](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#irrigation-systems--sprinklers)** for all options (per-zone durations, master switch, etc.).