homebridge-tuya-plus 3.14.0-dev.11 → 3.14.0-dev.13
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 +3 -1
- package/Readme.MD +1 -1
- package/config.schema.json +0 -45
- package/lib/IrrigationSystemAccessory.js +51 -77
- package/package.json +1 -1
- package/test/IrrigationSystemAccessory.test.js +36 -41
- package/test/support/mocks.js +2 -0
- package/wiki/Supported-Device-Types.md +4 -17
- package/wiki/Tuya-Cloud-Setup.md +2 -4
package/Changelog.md
CHANGED
|
@@ -7,10 +7,12 @@ 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
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
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.
|
|
15
|
+
* [*] **IrrigationSystem: add the HAP Service Label service for multi-valve controllers** — an accessory that exposes a collection of same-type services (more than one `Valve`) must include a `ServiceLabel` service to anchor each valve's `ServiceLabelIndex`. It was missing, so stricter Home app clients (notably iOS) scattered the zones as separate tiles instead of nesting them under the single irrigation tile. The service is added automatically (with the Arabic-numerals namespace) whenever there is more than one valve; user-set zone names still take precedence.
|
|
14
16
|
|
|
15
17
|
## 2.0.1 (2021-03-25)
|
|
16
18
|
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
|
*
|
|
@@ -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
|
|
@@ -101,14 +102,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
101
102
|
return !this._coerceBoolean(this.device.context.noBattery, false);
|
|
102
103
|
}
|
|
103
104
|
|
|
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
105
|
/* ------------------------------------------------------------------ *
|
|
113
106
|
* Service registration / cache reconciliation
|
|
114
107
|
* ------------------------------------------------------------------ */
|
|
@@ -149,6 +142,25 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
149
142
|
irrigation.addLinkedService(valve);
|
|
150
143
|
});
|
|
151
144
|
|
|
145
|
+
// --- Service Label (required when the accessory exposes a *collection*
|
|
146
|
+
// of same-type services, i.e. more than one Valve) ---
|
|
147
|
+
// HAP's Service Label profile says an accessory holding several services
|
|
148
|
+
// of the same type must carry a Service Label service, with each member
|
|
149
|
+
// service tagged by a ServiceLabelIndex. We already set the indices; the
|
|
150
|
+
// anchoring Service Label service was missing. Without it the stricter
|
|
151
|
+
// Home app clients (notably iOS) scatter the zones as separate tiles
|
|
152
|
+
// rather than nesting them under the irrigation system — macOS happens to
|
|
153
|
+
// render leniently either way. The namespace is set in
|
|
154
|
+
// _registerCharacteristics. A single-valve system is not a collection, so
|
|
155
|
+
// it needs no label.
|
|
156
|
+
const multiValve = valveConfigs.length > 1;
|
|
157
|
+
const existingLabel = this.accessory.getService(Service.ServiceLabel);
|
|
158
|
+
if (multiValve && !existingLabel) {
|
|
159
|
+
this.accessory.addService(Service.ServiceLabel, this.device.context.name);
|
|
160
|
+
} else if (!multiValve && existingLabel) {
|
|
161
|
+
this.accessory.removeService(existingLabel);
|
|
162
|
+
}
|
|
163
|
+
|
|
152
164
|
// --- Battery (optional) ---
|
|
153
165
|
if (this._hasBattery()) {
|
|
154
166
|
if (!this.accessory.getService(Service.Battery)) {
|
|
@@ -156,18 +168,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
156
168
|
}
|
|
157
169
|
}
|
|
158
170
|
|
|
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
171
|
// --- Remove services that no longer belong (config changed) ---
|
|
172
172
|
this.accessory.services
|
|
173
173
|
.filter(service => service.UUID === Service.Valve.UUID && !validValveSubtypes.includes(service.subtype))
|
|
@@ -180,12 +180,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
180
180
|
const battery = this.accessory.getService(Service.Battery);
|
|
181
181
|
if (battery) this.accessory.removeService(battery);
|
|
182
182
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
|
|
184
|
+
// Rain/leak sensors are no longer supported: they never reported
|
|
185
|
+
// reliably on these devices, and bundling a sensor in the same accessory
|
|
186
|
+
// forced the Home app to fragment the sprinkler into "sub-accessories"
|
|
187
|
+
// (blocking control from the main tile). Drop any left over from an older
|
|
188
|
+
// version so the accessory stays a clean, single-category sprinkler tile.
|
|
189
|
+
[Service.ContactSensor, Service.LeakSensor].forEach(S => {
|
|
190
|
+
const svc = this.accessory.getService(S);
|
|
191
|
+
if (svc) {
|
|
192
|
+
this.log.info('Removing rain sensor service %s (no longer supported)', svc.displayName);
|
|
193
|
+
this.accessory.removeService(svc);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
189
196
|
}
|
|
190
197
|
|
|
191
198
|
/* ------------------------------------------------------------------ *
|
|
@@ -212,9 +219,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
212
219
|
// differ per transport (cloud uses the standard Tuya codes).
|
|
213
220
|
this.dpBattery = this._resolveDP(this.device.context.dpBattery, 'battery_percentage', '46');
|
|
214
221
|
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
222
|
|
|
219
223
|
// Per-zone runtime state
|
|
220
224
|
this._valves = this._getValveConfigs();
|
|
@@ -290,6 +294,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
290
294
|
if (on) this._reflectValve(cfg, true);
|
|
291
295
|
});
|
|
292
296
|
|
|
297
|
+
// --- Service Label namespace ---
|
|
298
|
+
// Anchors the per-valve ServiceLabelIndex so the Home app groups the
|
|
299
|
+
// zones under the irrigation tile (see _verifyCachedPlatformAccessory).
|
|
300
|
+
// Arabic numerals: zones are labelled 1, 2, 3… — the user-set zone names
|
|
301
|
+
// (ConfiguredName) still take precedence in the Home app.
|
|
302
|
+
if (this._valves.length > 1) {
|
|
303
|
+
const serviceLabel = this.accessory.getService(Service.ServiceLabel);
|
|
304
|
+
if (serviceLabel) {
|
|
305
|
+
serviceLabel.getCharacteristic(Characteristic.ServiceLabelNamespace)
|
|
306
|
+
.updateValue(Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
293
310
|
// --- Battery ---
|
|
294
311
|
if (this._hasBattery()) {
|
|
295
312
|
const battery = this.accessory.getService(Service.Battery);
|
|
@@ -308,25 +325,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
308
325
|
.onGet(() => this._chargingState(this.getStateAsync(this.dpCharging)));
|
|
309
326
|
}
|
|
310
327
|
|
|
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
328
|
this._syncAggregate();
|
|
327
329
|
|
|
328
330
|
// --- React to device-side changes (physical buttons, our own writes
|
|
329
|
-
// being confirmed, battery
|
|
331
|
+
// being confirmed, battery telemetry) ---
|
|
330
332
|
this.device.on('change', (changes) => this._onDeviceChange(changes));
|
|
331
333
|
}
|
|
332
334
|
|
|
@@ -359,18 +361,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
359
361
|
.updateValue(this._chargingState(changes[this.dpCharging]));
|
|
360
362
|
}
|
|
361
363
|
|
|
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
364
|
if (valveChanged) this._syncAggregate();
|
|
375
365
|
}
|
|
376
366
|
|
|
@@ -550,7 +540,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
550
540
|
}
|
|
551
541
|
|
|
552
542
|
/* ------------------------------------------------------------------ *
|
|
553
|
-
* Battery
|
|
543
|
+
* Battery mapping
|
|
554
544
|
* ------------------------------------------------------------------ */
|
|
555
545
|
|
|
556
546
|
_batteryLevel(value) {
|
|
@@ -576,22 +566,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
576
566
|
: Characteristic.ChargingState.NOT_CHARGING;
|
|
577
567
|
}
|
|
578
568
|
|
|
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
569
|
/* ------------------------------------------------------------------ *
|
|
596
570
|
* Batched writes — coalesce DP updates into a single Tuya command
|
|
597
571
|
* ------------------------------------------------------------------ */
|
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.13",
|
|
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
|
|
|
@@ -154,6 +154,19 @@ describe('IrrigationSystemAccessory — service topology', () => {
|
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
test('adds a ServiceLabel (ARABIC_NUMERALS) so the multi-valve zones group under the system', () => {
|
|
158
|
+
const { accessory } = makeHarness({ '1': false, '2': false, '3': false, '4': false });
|
|
159
|
+
const label = accessory.getService(Service.ServiceLabel);
|
|
160
|
+
expect(label).toBeDefined();
|
|
161
|
+
expect(label.getCharacteristic(Characteristic.ServiceLabelNamespace).value)
|
|
162
|
+
.toBe(Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a single-valve system omits the ServiceLabel (no collection to label)', () => {
|
|
166
|
+
const { accessory } = makeHarness({ '1': false }, { valveCount: 1 });
|
|
167
|
+
expect(accessory.getService(Service.ServiceLabel)).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
157
170
|
test('IrrigationSystem advertises the required characteristics', () => {
|
|
158
171
|
const { accessory } = makeHarness({ '1': false });
|
|
159
172
|
const irr = irrigation(accessory);
|
|
@@ -162,17 +175,23 @@ describe('IrrigationSystemAccessory — service topology', () => {
|
|
|
162
175
|
expect(irr.getCharacteristic(Characteristic.InUse).value).toBe(Characteristic.InUse.NOT_IN_USE);
|
|
163
176
|
});
|
|
164
177
|
|
|
165
|
-
test('noBattery
|
|
166
|
-
const { accessory } = makeHarness({ '1': false }, { noBattery: true
|
|
178
|
+
test('noBattery omits the battery service', () => {
|
|
179
|
+
const { accessory } = makeHarness({ '1': false }, { noBattery: true });
|
|
167
180
|
expect(accessory.getService(Service.Battery)).toBeUndefined();
|
|
168
|
-
expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
|
|
169
|
-
expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
|
|
170
181
|
});
|
|
171
182
|
|
|
172
|
-
test('
|
|
173
|
-
const { accessory } = makeHarness({ '
|
|
174
|
-
|
|
183
|
+
test('removes a stale rain sensor service left over from an older version', () => {
|
|
184
|
+
const { instance, accessory } = makeHarness({ '1': false });
|
|
185
|
+
// An accessory cached by an older plugin version may still carry the
|
|
186
|
+
// ContactSensor/LeakSensor; reconciliation must drop it so the sprinkler
|
|
187
|
+
// stays a clean, single-category tile.
|
|
188
|
+
accessory.addService(Service.ContactSensor, 'Old Rain');
|
|
189
|
+
accessory.addService(Service.LeakSensor, 'Old Leak');
|
|
190
|
+
|
|
191
|
+
instance._verifyCachedPlatformAccessory();
|
|
192
|
+
|
|
175
193
|
expect(accessory.getService(Service.ContactSensor)).toBeUndefined();
|
|
194
|
+
expect(accessory.getService(Service.LeakSensor)).toBeUndefined();
|
|
176
195
|
});
|
|
177
196
|
});
|
|
178
197
|
|
|
@@ -361,16 +380,13 @@ describe('IrrigationSystemAccessory — device-side change reflection', () => {
|
|
|
361
380
|
expect(v.getCharacteristic(Characteristic.RemainingDuration).value).toBe(600);
|
|
362
381
|
});
|
|
363
382
|
|
|
364
|
-
test('battery
|
|
365
|
-
const { accessory, device } = makeHarness({ '46': 80
|
|
366
|
-
device.emitChange({ '46': 10
|
|
383
|
+
test('battery telemetry updates the corresponding characteristics', () => {
|
|
384
|
+
const { accessory, device } = makeHarness({ '46': 80 });
|
|
385
|
+
device.emitChange({ '46': 10 });
|
|
367
386
|
|
|
368
387
|
const battery = accessory.getService(Service.Battery);
|
|
369
388
|
expect(battery.getCharacteristic(Characteristic.BatteryLevel).value).toBe(10);
|
|
370
389
|
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
390
|
});
|
|
375
391
|
});
|
|
376
392
|
|
|
@@ -425,32 +441,11 @@ describe('IrrigationSystemAccessory — charging state', () => {
|
|
|
425
441
|
});
|
|
426
442
|
});
|
|
427
443
|
|
|
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
444
|
describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)', () => {
|
|
449
445
|
// Mirrors a real Tuya "sfkzq" watering controller reached over the cloud:
|
|
450
|
-
// valves are switch_1..4
|
|
451
|
-
// rain sensor.
|
|
446
|
+
// valves are switch_1..4 and battery is battery_percentage.
|
|
452
447
|
const cloudState = () => ({ switch_1: false, switch_2: false, switch_3: false, switch_4: false, battery_percentage: 99 });
|
|
453
|
-
const cloudCtx = { cloud: true
|
|
448
|
+
const cloudCtx = { cloud: true };
|
|
454
449
|
|
|
455
450
|
beforeEach(() => jest.useFakeTimers());
|
|
456
451
|
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
@@ -485,7 +480,7 @@ describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)',
|
|
|
485
480
|
test('an explicit valve list may use custom codes', () => {
|
|
486
481
|
const { accessory, device } = makeHarness(
|
|
487
482
|
{ zone_a: false, zone_b: false, battery_percentage: 50 },
|
|
488
|
-
{ cloud: true,
|
|
483
|
+
{ cloud: true, valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }] }
|
|
489
484
|
);
|
|
490
485
|
expect(valve(accessory, 'zone_a')).toBeTruthy();
|
|
491
486
|
valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
|
package/test/support/mocks.js
CHANGED
|
@@ -34,6 +34,7 @@ const HAP = {
|
|
|
34
34
|
ProgramMode: { UUID: 'D1', NO_PROGRAM_SCHEDULED: 0, PROGRAM_SCHEDULED: 1, PROGRAM_SCHEDULED_MANUAL_MODE: 2 },
|
|
35
35
|
IsConfigured: { UUID: 'D6', NOT_CONFIGURED: 0, CONFIGURED: 1 },
|
|
36
36
|
ServiceLabelIndex: { UUID: 'CB' },
|
|
37
|
+
ServiceLabelNamespace: { UUID: 'CD', DOTS: 0, ARABIC_NUMERALS: 1 },
|
|
37
38
|
ConfiguredName: { UUID: 'E3' },
|
|
38
39
|
BatteryLevel: { UUID: '68' },
|
|
39
40
|
StatusLowBattery: { UUID: '79', BATTERY_LEVEL_NORMAL: 0, BATTERY_LEVEL_LOW: 1 },
|
|
@@ -65,6 +66,7 @@ const HAP = {
|
|
|
65
66
|
LockMechanism: { UUID: '45' },
|
|
66
67
|
Valve: { UUID: 'D0' },
|
|
67
68
|
IrrigationSystem: { UUID: 'CF' },
|
|
69
|
+
ServiceLabel: { UUID: 'CC' },
|
|
68
70
|
Battery: { UUID: '96' },
|
|
69
71
|
ContactSensor: { UUID: '80' },
|
|
70
72
|
LeakSensor: { UUID: '83' },
|
|
@@ -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.).
|