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 +4 -1
- package/Readme.MD +1 -1
- package/config.schema.json +0 -45
- package/lib/IrrigationSystemAccessory.js +30 -87
- package/lib/TuyaCloudMessaging.js +16 -11
- package/package.json +1 -1
- package/test/IrrigationSystemAccessory.test.js +55 -41
- package/test/TuyaCloudMessaging.test.js +24 -0
- package/wiki/Supported-Device-Types.md +4 -17
- package/wiki/Tuya-Cloud-Setup.md +2 -4
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.
|
|
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,
|
|
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>
|
package/config.schema.json
CHANGED
|
@@ -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
|
|
8
|
-
* `battery_percentage`
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
188
|
-
//
|
|
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.
|
|
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.
|
|
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
|
|
129
|
-
const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false, '46': 80
|
|
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
|
|
141
|
+
// Battery present; no rain/leak sensor is ever created.
|
|
142
142
|
expect(accessory.getService(Service.Battery)).toBeDefined();
|
|
143
|
-
expect(accessory.getService(Service.ContactSensor)).
|
|
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
|
|
166
|
-
const { accessory } = makeHarness({ '1': false }, { noBattery: 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('
|
|
173
|
-
const { accessory } = makeHarness({ '
|
|
174
|
-
|
|
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
|
|
365
|
-
const { accessory, device } = makeHarness({ '46': 80
|
|
366
|
-
device.emitChange({ '46': 10
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
```
|
package/wiki/Tuya-Cloud-Setup.md
CHANGED
|
@@ -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.).
|