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