homebridge-tuya-plus 3.13.0 → 3.13.1-dev.1
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/CODEOWNERS +5 -0
- package/.github/workflows/publish-dev.yml +91 -0
- package/Readme.MD +33 -0
- package/config.schema.json +231 -12
- package/index.js +21 -5
- package/lib/BaseAccessory.js +7 -0
- package/lib/DehumidifierAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +611 -0
- package/lib/OilDiffuserAccessory.js +1 -1
- package/lib/SimpleFanLightAccessory.js +54 -12
- package/lib/SimpleGarageDoorAccessory.js +178 -262
- package/lib/TuyaAccessory.js +56 -27
- package/lib/TuyaDiscovery.js +52 -19
- package/lib/WledDimmerAccessory.js +703 -0
- package/package.json +1 -1
- package/test/IrrigationSystemAccessory.test.js +446 -0
- package/test/SimpleFanLightAccessory.test.js +299 -0
- package/test/SimpleGarageDoorAccessory.test.js +258 -538
- package/test/TuyaAccessory.protocol.test.js +528 -0
- package/test/getCategory.test.js +65 -0
- package/test/support/mocks.js +30 -2
- package/wiki/Supported-Device-Types.md +167 -22
- package/wiki/User-documented-device-config.md +57 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
const BaseAccessory = require('./BaseAccessory');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IrrigationSystemAccessory
|
|
5
|
+
*
|
|
6
|
+
* A fully-fledged HomeKit irrigation controller for Tuya multi-valve devices
|
|
7
|
+
* (e.g. the 4-zone faucet/valve timers that expose `switch_1..switch_n`, a
|
|
8
|
+
* `battery_percentage` and a `rain_sensor_state`).
|
|
9
|
+
*
|
|
10
|
+
* HomeKit modelling (one bridged accessory, category SPRINKLER):
|
|
11
|
+
*
|
|
12
|
+
* IrrigationSystem (primary service — the "system" tile + master on/off)
|
|
13
|
+
* ├─ Valve "Zone A" (linked, ValveType=IRRIGATION, ServiceLabelIndex 1)
|
|
14
|
+
* ├─ Valve "Zone B" (linked, ServiceLabelIndex 2)
|
|
15
|
+
* ├─ ...
|
|
16
|
+
* Battery (BatteryLevel + StatusLowBattery)
|
|
17
|
+
* ContactSensor (rain — or LeakSensor, configurable)
|
|
18
|
+
*
|
|
19
|
+
* Each Valve carries its own SetDuration/RemainingDuration so the Home app shows
|
|
20
|
+
* the familiar per-zone "Duration" picker and a live countdown. A zone whose
|
|
21
|
+
* duration is 0 runs indefinitely (until it is turned off again) — handy for
|
|
22
|
+
* long manual watering tasks. All writes are coalesced into a single Tuya
|
|
23
|
+
* command (the device is a laggy, battery-powered Wi-Fi unit), so turning the
|
|
24
|
+
* whole system on/off — or running a scene that toggles several zones at once —
|
|
25
|
+
* results in one network round-trip rather than a burst of them.
|
|
26
|
+
*/
|
|
27
|
+
class IrrigationSystemAccessory extends BaseAccessory {
|
|
28
|
+
static getCategory(Categories) {
|
|
29
|
+
return Categories.SPRINKLER;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
constructor(...props) {
|
|
33
|
+
super(...props);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* ------------------------------------------------------------------ *
|
|
37
|
+
* Configuration helpers
|
|
38
|
+
* ------------------------------------------------------------------ */
|
|
39
|
+
|
|
40
|
+
// Resolve the list of valves/zones from the device config. Defaults to the
|
|
41
|
+
// 4-zone A/B/C/D layout of the reference device (switch_1..switch_4 on
|
|
42
|
+
// DP 1..4). A `valves` array lets non-sequential / custom devices map their
|
|
43
|
+
// own data-points and names.
|
|
44
|
+
_getValveConfigs() {
|
|
45
|
+
// Self-contained (no reliance on instance state) so it is safe to call
|
|
46
|
+
// both during early service reconciliation and later when wiring
|
|
47
|
+
// characteristics.
|
|
48
|
+
const defaultDuration = isFinite(this.device.context.defaultDuration) ? parseInt(this.device.context.defaultDuration) : 600;
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(this.device.context.valves) && this.device.context.valves.length) {
|
|
51
|
+
return this.device.context.valves.map((valve, i) => {
|
|
52
|
+
if (!valve || !isFinite(valve.dp) || parseInt(valve.dp) <= 0) {
|
|
53
|
+
throw new Error(`The valve definition #${i + 1} is missing a valid 'dp': ${JSON.stringify(valve)}`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
dp: String(parseInt(valve.dp)),
|
|
57
|
+
name: (('' + (valve.name || '')).trim()) || ('Zone ' + (i + 1)),
|
|
58
|
+
index: i + 1,
|
|
59
|
+
duration: isFinite(valve.defaultDuration) ? parseInt(valve.defaultDuration) : defaultDuration
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const count = Math.max(1, parseInt(this.device.context.valveCount) || 4);
|
|
65
|
+
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
66
|
+
const configs = [];
|
|
67
|
+
for (let i = 0; i < count; i++) {
|
|
68
|
+
configs.push({
|
|
69
|
+
dp: String(i + 1),
|
|
70
|
+
name: 'Valve ' + (letters[i] || (i + 1)),
|
|
71
|
+
index: i + 1,
|
|
72
|
+
duration: defaultDuration
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return configs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_hasBattery() {
|
|
79
|
+
return !this._coerceBoolean(this.device.context.noBattery, false);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_hasRainSensor() {
|
|
83
|
+
return !this._coerceBoolean(this.device.context.noRainSensor, false);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_usesLeakSensor() {
|
|
87
|
+
return ('' + (this.device.context.rainSensorType || 'contact')).trim().toLowerCase() === 'leak';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ------------------------------------------------------------------ *
|
|
91
|
+
* Service registration / cache reconciliation
|
|
92
|
+
* ------------------------------------------------------------------ */
|
|
93
|
+
|
|
94
|
+
_registerPlatformAccessory() {
|
|
95
|
+
this._verifyCachedPlatformAccessory();
|
|
96
|
+
this._justRegistered = true;
|
|
97
|
+
|
|
98
|
+
super._registerPlatformAccessory();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_verifyCachedPlatformAccessory() {
|
|
102
|
+
if (this._justRegistered) return;
|
|
103
|
+
|
|
104
|
+
const {Service, Characteristic} = this.hap;
|
|
105
|
+
|
|
106
|
+
// --- IrrigationSystem (primary) ---
|
|
107
|
+
let irrigation = this.accessory.getService(Service.IrrigationSystem);
|
|
108
|
+
if (irrigation) this._checkServiceName(irrigation, this.device.context.name);
|
|
109
|
+
else irrigation = this.accessory.addService(Service.IrrigationSystem, this.device.context.name);
|
|
110
|
+
irrigation.setPrimaryService(true);
|
|
111
|
+
|
|
112
|
+
// --- Valves (one per zone), linked to the irrigation system ---
|
|
113
|
+
const valveConfigs = this._getValveConfigs();
|
|
114
|
+
const validValveSubtypes = [];
|
|
115
|
+
valveConfigs.forEach(cfg => {
|
|
116
|
+
const subtype = 'valve-' + cfg.dp;
|
|
117
|
+
validValveSubtypes.push(subtype);
|
|
118
|
+
|
|
119
|
+
let valve = this.accessory.getServiceById(Service.Valve, subtype);
|
|
120
|
+
if (valve) this._checkServiceName(valve, cfg.name);
|
|
121
|
+
else valve = this.accessory.addService(Service.Valve, cfg.name, subtype);
|
|
122
|
+
|
|
123
|
+
// Linking is what makes the Home app nest the zones under the single
|
|
124
|
+
// irrigation tile instead of scattering them as separate tiles.
|
|
125
|
+
// addLinkedService is idempotent, so it is safe to call on every
|
|
126
|
+
// cache reconcile.
|
|
127
|
+
irrigation.addLinkedService(valve);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// --- Battery (optional) ---
|
|
131
|
+
if (this._hasBattery()) {
|
|
132
|
+
if (!this.accessory.getService(Service.Battery)) {
|
|
133
|
+
this.accessory.addService(Service.Battery, this.device.context.name + ' Battery');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Rain sensor (optional): Contact (default) or Leak ---
|
|
138
|
+
if (this._hasRainSensor()) {
|
|
139
|
+
const WantedSensor = this._usesLeakSensor() ? Service.LeakSensor : Service.ContactSensor;
|
|
140
|
+
const UnwantedSensor = this._usesLeakSensor() ? Service.ContactSensor : Service.LeakSensor;
|
|
141
|
+
// Drop the other sensor type if the user switched `rainSensorType`.
|
|
142
|
+
const stale = this.accessory.getService(UnwantedSensor);
|
|
143
|
+
if (stale) this.accessory.removeService(stale);
|
|
144
|
+
if (!this.accessory.getService(WantedSensor)) {
|
|
145
|
+
this.accessory.addService(WantedSensor, this.device.context.name + ' Rain');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- Remove services that no longer belong (config changed) ---
|
|
150
|
+
this.accessory.services
|
|
151
|
+
.filter(service => service.UUID === Service.Valve.UUID && !validValveSubtypes.includes(service.subtype))
|
|
152
|
+
.forEach(service => {
|
|
153
|
+
this.log.info('Removing stale valve service %s', service.displayName);
|
|
154
|
+
this.accessory.removeService(service);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!this._hasBattery()) {
|
|
158
|
+
const battery = this.accessory.getService(Service.Battery);
|
|
159
|
+
if (battery) this.accessory.removeService(battery);
|
|
160
|
+
}
|
|
161
|
+
if (!this._hasRainSensor()) {
|
|
162
|
+
[Service.ContactSensor, Service.LeakSensor].forEach(S => {
|
|
163
|
+
const svc = this.accessory.getService(S);
|
|
164
|
+
if (svc) this.accessory.removeService(svc);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ------------------------------------------------------------------ *
|
|
170
|
+
* Characteristic wiring
|
|
171
|
+
* ------------------------------------------------------------------ */
|
|
172
|
+
|
|
173
|
+
_registerCharacteristics(dps) {
|
|
174
|
+
this._verifyCachedPlatformAccessory();
|
|
175
|
+
|
|
176
|
+
const {Service, Characteristic} = this.hap;
|
|
177
|
+
|
|
178
|
+
// Tunables
|
|
179
|
+
this._defaultDuration = isFinite(this.device.context.defaultDuration) ? parseInt(this.device.context.defaultDuration) : 600;
|
|
180
|
+
// Max selectable duration. HAP's default max for SetDuration/
|
|
181
|
+
// RemainingDuration is 3600s (1h); raise it so longer runs aren't
|
|
182
|
+
// silently clamped. Apple's Home app honours the advertised maxValue.
|
|
183
|
+
this._maxDuration = isFinite(this.device.context.maxDuration) ? parseInt(this.device.context.maxDuration) : 7200;
|
|
184
|
+
this._lowBatteryThreshold = isFinite(this.device.context.lowBatteryThreshold) ? parseInt(this.device.context.lowBatteryThreshold) : 20;
|
|
185
|
+
this._debounce = isFinite(this.device.context.commandDebounce) ? parseInt(this.device.context.commandDebounce) : 500;
|
|
186
|
+
this._cascadeOn = this._coerceBoolean(this.device.context.masterTurnsOnAllZones, true);
|
|
187
|
+
this._cascadeOff = this._coerceBoolean(this.device.context.masterTurnsOffAllZones, true);
|
|
188
|
+
|
|
189
|
+
this.dpBattery = this._getCustomDP(this.device.context.dpBattery) || '46';
|
|
190
|
+
this.dpCharging = this._getCustomDP(this.device.context.dpCharging) || '101';
|
|
191
|
+
this.dpRain = this._getCustomDP(this.device.context.dpRain) || '49';
|
|
192
|
+
this._rainOnValue = ('' + (this.device.context.rainOnValue || 'rain')).trim();
|
|
193
|
+
this._rainInverted = this._coerceBoolean(this.device.context.rainInverted, false);
|
|
194
|
+
|
|
195
|
+
// Per-zone runtime state
|
|
196
|
+
this._valves = this._getValveConfigs();
|
|
197
|
+
this._timers = {}; // dp -> setTimeout handle for auto-shutoff
|
|
198
|
+
this._endTimes = {}; // dp -> ms timestamp when the run ends
|
|
199
|
+
this._pendingWrite = null;
|
|
200
|
+
|
|
201
|
+
// Persisted, user-editable per-zone durations and names.
|
|
202
|
+
this.accessory.context.durations = this.accessory.context.durations || {};
|
|
203
|
+
this.accessory.context.zoneNames = this.accessory.context.zoneNames || {};
|
|
204
|
+
|
|
205
|
+
const irrigation = this.accessory.getService(Service.IrrigationSystem);
|
|
206
|
+
|
|
207
|
+
// --- IrrigationSystem characteristics ---
|
|
208
|
+
irrigation.getCharacteristic(Characteristic.ProgramMode)
|
|
209
|
+
.updateValue(Characteristic.ProgramMode.NO_PROGRAM_SCHEDULED);
|
|
210
|
+
|
|
211
|
+
this._systemActiveChar = irrigation.getCharacteristic(Characteristic.Active)
|
|
212
|
+
.updateValue(this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
|
|
213
|
+
.onGet(() => this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
|
|
214
|
+
.onSet(value => this._setSystemActive(value));
|
|
215
|
+
|
|
216
|
+
this._systemInUseChar = irrigation.getCharacteristic(Characteristic.InUse)
|
|
217
|
+
.updateValue(this._anyValveOn() ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
|
|
218
|
+
|
|
219
|
+
this._systemRemainingChar = irrigation.getCharacteristic(Characteristic.RemainingDuration)
|
|
220
|
+
.setProps({maxValue: this._maxDuration})
|
|
221
|
+
.updateValue(0)
|
|
222
|
+
.onGet(() => this._systemRemaining());
|
|
223
|
+
|
|
224
|
+
// --- Valve characteristics ---
|
|
225
|
+
this._valves.forEach(cfg => {
|
|
226
|
+
const valve = this.accessory.getServiceById(Service.Valve, 'valve-' + cfg.dp);
|
|
227
|
+
const on = !!dps[cfg.dp];
|
|
228
|
+
|
|
229
|
+
valve.getCharacteristic(Characteristic.ValveType)
|
|
230
|
+
.updateValue(Characteristic.ValveType.IRRIGATION);
|
|
231
|
+
valve.getCharacteristic(Characteristic.IsConfigured)
|
|
232
|
+
.updateValue(Characteristic.IsConfigured.CONFIGURED);
|
|
233
|
+
valve.getCharacteristic(Characteristic.ServiceLabelIndex)
|
|
234
|
+
.updateValue(cfg.index);
|
|
235
|
+
|
|
236
|
+
// ConfiguredName so each zone shows its own name in the Home app
|
|
237
|
+
// (otherwise every zone inherits the accessory/system name).
|
|
238
|
+
if (Characteristic.ConfiguredName) {
|
|
239
|
+
const savedName = this.accessory.context.zoneNames[cfg.dp] || cfg.name;
|
|
240
|
+
valve.getCharacteristic(Characteristic.ConfiguredName)
|
|
241
|
+
.updateValue(savedName)
|
|
242
|
+
.onSet(value => { this.accessory.context.zoneNames[cfg.dp] = value; });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
valve.getCharacteristic(Characteristic.SetDuration)
|
|
246
|
+
.setProps({maxValue: this._maxDuration})
|
|
247
|
+
.updateValue(this._getDuration(cfg))
|
|
248
|
+
.onGet(() => this._getDuration(cfg))
|
|
249
|
+
.onSet(value => this._setDuration(cfg, value));
|
|
250
|
+
|
|
251
|
+
valve.getCharacteristic(Characteristic.RemainingDuration)
|
|
252
|
+
.setProps({maxValue: this._maxDuration})
|
|
253
|
+
.updateValue(0)
|
|
254
|
+
.onGet(() => this._valveRemaining(cfg));
|
|
255
|
+
|
|
256
|
+
valve.getCharacteristic(Characteristic.Active)
|
|
257
|
+
.updateValue(on ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
|
|
258
|
+
.onGet(() => (this.getStateAsync(cfg.dp) ? 1 : 0))
|
|
259
|
+
.onSet(value => this._setValveActive(cfg, value));
|
|
260
|
+
|
|
261
|
+
valve.getCharacteristic(Characteristic.InUse)
|
|
262
|
+
.updateValue(on ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
|
|
263
|
+
|
|
264
|
+
// Reflect any zone already running at startup (e.g. turned on at the
|
|
265
|
+
// device, or Homebridge restarted mid-run) and (re)arm its timer.
|
|
266
|
+
if (on) this._reflectValve(cfg, true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// --- Battery ---
|
|
270
|
+
if (this._hasBattery()) {
|
|
271
|
+
const battery = this.accessory.getService(Service.Battery);
|
|
272
|
+
const level = this._batteryLevel(dps[this.dpBattery]);
|
|
273
|
+
battery.getCharacteristic(Characteristic.BatteryLevel)
|
|
274
|
+
.updateValue(level)
|
|
275
|
+
.onGet(() => this._batteryLevel(this.getStateAsync(this.dpBattery)));
|
|
276
|
+
battery.getCharacteristic(Characteristic.StatusLowBattery)
|
|
277
|
+
.updateValue(this._lowBattery(level))
|
|
278
|
+
.onGet(() => this._lowBattery(this._batteryLevel(this.getStateAsync(this.dpBattery))));
|
|
279
|
+
// ChargingState follows the device's charging data-point when it
|
|
280
|
+
// reports one (solar / USB-C rechargeable units); devices that
|
|
281
|
+
// don't report charging fall back to NOT_CHARGEABLE.
|
|
282
|
+
battery.getCharacteristic(Characteristic.ChargingState)
|
|
283
|
+
.updateValue(this._chargingState(dps[this.dpCharging]))
|
|
284
|
+
.onGet(() => this._chargingState(this.getStateAsync(this.dpCharging)));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- Rain sensor ---
|
|
288
|
+
if (this._hasRainSensor()) {
|
|
289
|
+
if (this._usesLeakSensor()) {
|
|
290
|
+
const leak = this.accessory.getService(Service.LeakSensor);
|
|
291
|
+
leak.getCharacteristic(Characteristic.LeakDetected)
|
|
292
|
+
.updateValue(this._rainDetected(dps[this.dpRain]) ? Characteristic.LeakDetected.LEAK_DETECTED : Characteristic.LeakDetected.LEAK_NOT_DETECTED)
|
|
293
|
+
.onGet(() => this._rainDetected(this.getStateAsync(this.dpRain)) ? 1 : 0);
|
|
294
|
+
} else {
|
|
295
|
+
const contact = this.accessory.getService(Service.ContactSensor);
|
|
296
|
+
contact.getCharacteristic(Characteristic.ContactSensorState)
|
|
297
|
+
.updateValue(this._contactState(dps[this.dpRain]))
|
|
298
|
+
.onGet(() => this._contactState(this.getStateAsync(this.dpRain)));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this._syncAggregate();
|
|
303
|
+
|
|
304
|
+
// --- React to device-side changes (physical buttons, our own writes
|
|
305
|
+
// being confirmed, battery/rain telemetry) ---
|
|
306
|
+
this.device.on('change', (changes) => this._onDeviceChange(changes));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* ------------------------------------------------------------------ *
|
|
310
|
+
* Device change handling
|
|
311
|
+
* ------------------------------------------------------------------ */
|
|
312
|
+
|
|
313
|
+
_onDeviceChange(changes) {
|
|
314
|
+
const {Service, Characteristic} = this.hap;
|
|
315
|
+
|
|
316
|
+
let valveChanged = false;
|
|
317
|
+
this._valves.forEach(cfg => {
|
|
318
|
+
if (!changes.hasOwnProperty(cfg.dp)) return;
|
|
319
|
+
valveChanged = true;
|
|
320
|
+
this._reflectValve(cfg, !!changes[cfg.dp]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
if (changes.hasOwnProperty(this.dpBattery) && this._hasBattery()) {
|
|
324
|
+
const battery = this.accessory.getService(Service.Battery);
|
|
325
|
+
if (battery) {
|
|
326
|
+
const level = this._batteryLevel(changes[this.dpBattery]);
|
|
327
|
+
battery.getCharacteristic(Characteristic.BatteryLevel).updateValue(level);
|
|
328
|
+
battery.getCharacteristic(Characteristic.StatusLowBattery).updateValue(this._lowBattery(level));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (changes.hasOwnProperty(this.dpCharging) && this._hasBattery()) {
|
|
333
|
+
const battery = this.accessory.getService(Service.Battery);
|
|
334
|
+
if (battery) battery.getCharacteristic(Characteristic.ChargingState)
|
|
335
|
+
.updateValue(this._chargingState(changes[this.dpCharging]));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (changes.hasOwnProperty(this.dpRain) && this._hasRainSensor()) {
|
|
339
|
+
if (this._usesLeakSensor()) {
|
|
340
|
+
const leak = this.accessory.getService(Service.LeakSensor);
|
|
341
|
+
if (leak) leak.getCharacteristic(Characteristic.LeakDetected)
|
|
342
|
+
.updateValue(this._rainDetected(changes[this.dpRain]) ? 1 : 0);
|
|
343
|
+
} else {
|
|
344
|
+
const contact = this.accessory.getService(Service.ContactSensor);
|
|
345
|
+
if (contact) contact.getCharacteristic(Characteristic.ContactSensorState)
|
|
346
|
+
.updateValue(this._contactState(changes[this.dpRain]));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (valveChanged) this._syncAggregate();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* ------------------------------------------------------------------ *
|
|
354
|
+
* Valve activation + timer logic
|
|
355
|
+
* ------------------------------------------------------------------ */
|
|
356
|
+
|
|
357
|
+
// Called from HomeKit (Valve.Active onSet). Writes to the device and
|
|
358
|
+
// mirrors the state locally.
|
|
359
|
+
_setValveActive(cfg, value) {
|
|
360
|
+
const {Characteristic} = this.hap;
|
|
361
|
+
const on = value === Characteristic.Active.ACTIVE || value === true || value === 1;
|
|
362
|
+
|
|
363
|
+
const service = this._valveService(cfg);
|
|
364
|
+
// Guard against iOS firing duplicate onSet callbacks.
|
|
365
|
+
if (service && service.getCharacteristic(Characteristic.Active).value === (on ? 1 : 0) &&
|
|
366
|
+
!!this.device.state[cfg.dp] === on) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
this._queueWrite(cfg.dp, on);
|
|
371
|
+
this._reflectValve(cfg, on);
|
|
372
|
+
this._syncAggregate();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Mirror a zone's on/off state into HomeKit and (dis)arm its auto-shutoff
|
|
376
|
+
// timer. Does NOT write to the device — call _queueWrite separately for that.
|
|
377
|
+
_reflectValve(cfg, on) {
|
|
378
|
+
const {Characteristic} = this.hap;
|
|
379
|
+
const service = this._valveService(cfg);
|
|
380
|
+
if (!service) return;
|
|
381
|
+
|
|
382
|
+
const activeChar = service.getCharacteristic(Characteristic.Active);
|
|
383
|
+
const inUseChar = service.getCharacteristic(Characteristic.InUse);
|
|
384
|
+
const remainingChar = service.getCharacteristic(Characteristic.RemainingDuration);
|
|
385
|
+
|
|
386
|
+
this._clearTimer(cfg.dp);
|
|
387
|
+
|
|
388
|
+
if (on) {
|
|
389
|
+
const duration = this._getDuration(cfg);
|
|
390
|
+
if (activeChar.value !== Characteristic.Active.ACTIVE) activeChar.updateValue(Characteristic.Active.ACTIVE);
|
|
391
|
+
if (inUseChar.value !== Characteristic.InUse.IN_USE) inUseChar.updateValue(Characteristic.InUse.IN_USE);
|
|
392
|
+
|
|
393
|
+
if (duration > 0) {
|
|
394
|
+
this._endTimes[cfg.dp] = Date.now() + duration * 1000;
|
|
395
|
+
remainingChar.updateValue(duration);
|
|
396
|
+
this._timers[cfg.dp] = setTimeout(() => {
|
|
397
|
+
this.log.info('%s: zone "%s" timer expired, shutting off', this.device.context.name, cfg.name);
|
|
398
|
+
this._queueWrite(cfg.dp, false);
|
|
399
|
+
this._reflectValve(cfg, false);
|
|
400
|
+
this._syncAggregate();
|
|
401
|
+
}, duration * 1000);
|
|
402
|
+
} else {
|
|
403
|
+
// Indefinite run — no auto-shutoff, no countdown.
|
|
404
|
+
this._endTimes[cfg.dp] = null;
|
|
405
|
+
remainingChar.updateValue(0);
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
this._endTimes[cfg.dp] = null;
|
|
409
|
+
if (activeChar.value !== Characteristic.Active.INACTIVE) activeChar.updateValue(Characteristic.Active.INACTIVE);
|
|
410
|
+
if (inUseChar.value !== Characteristic.InUse.NOT_IN_USE) inUseChar.updateValue(Characteristic.InUse.NOT_IN_USE);
|
|
411
|
+
remainingChar.updateValue(0);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
_clearTimer(dp) {
|
|
416
|
+
if (this._timers[dp]) {
|
|
417
|
+
clearTimeout(this._timers[dp]);
|
|
418
|
+
this._timers[dp] = null;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/* ------------------------------------------------------------------ *
|
|
423
|
+
* Master (whole-system) on/off
|
|
424
|
+
* ------------------------------------------------------------------ */
|
|
425
|
+
|
|
426
|
+
_setSystemActive(value) {
|
|
427
|
+
const {Characteristic} = this.hap;
|
|
428
|
+
const on = value === Characteristic.Active.ACTIVE || value === true || value === 1;
|
|
429
|
+
|
|
430
|
+
if (on && !this._cascadeOn) return; // master is a passive enable
|
|
431
|
+
if (!on && !this._cascadeOff) return;
|
|
432
|
+
|
|
433
|
+
// Act on what the user sees (the valve's HomeKit Active value) so the
|
|
434
|
+
// cascade self-heals stale optimistic states and doesn't reset the
|
|
435
|
+
// countdown on a zone that is already running. Only zones that actually
|
|
436
|
+
// change are touched; _flushWrites then coalesces them into a single
|
|
437
|
+
// Tuya command and drops any that already match the real device state.
|
|
438
|
+
this._valves.forEach(cfg => {
|
|
439
|
+
const service = this._valveService(cfg);
|
|
440
|
+
const currentlyOn = !!(service && service.getCharacteristic(Characteristic.Active).value);
|
|
441
|
+
if (currentlyOn === on) return;
|
|
442
|
+
this._queueWrite(cfg.dp, on);
|
|
443
|
+
this._reflectValve(cfg, on);
|
|
444
|
+
});
|
|
445
|
+
this._syncAggregate();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/* ------------------------------------------------------------------ *
|
|
449
|
+
* Duration helpers
|
|
450
|
+
* ------------------------------------------------------------------ */
|
|
451
|
+
|
|
452
|
+
_getDuration(cfg) {
|
|
453
|
+
const saved = this.accessory.context.durations[cfg.dp];
|
|
454
|
+
return isFinite(saved) ? saved : cfg.duration;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
_setDuration(cfg, value) {
|
|
458
|
+
const {Characteristic} = this.hap;
|
|
459
|
+
const seconds = Math.max(0, parseInt(value) || 0);
|
|
460
|
+
this.accessory.context.durations[cfg.dp] = seconds;
|
|
461
|
+
this.log.info('%s: zone "%s" duration set to %s', this.device.context.name, cfg.name, seconds ? (seconds + 's') : 'indefinite');
|
|
462
|
+
|
|
463
|
+
// If the zone is running, re-base its countdown on the new duration.
|
|
464
|
+
const service = this._valveService(cfg);
|
|
465
|
+
if (service && service.getCharacteristic(Characteristic.InUse).value === Characteristic.InUse.IN_USE) {
|
|
466
|
+
this._clearTimer(cfg.dp);
|
|
467
|
+
const remainingChar = service.getCharacteristic(Characteristic.RemainingDuration);
|
|
468
|
+
if (seconds > 0) {
|
|
469
|
+
this._endTimes[cfg.dp] = Date.now() + seconds * 1000;
|
|
470
|
+
remainingChar.updateValue(seconds);
|
|
471
|
+
this._timers[cfg.dp] = setTimeout(() => {
|
|
472
|
+
this._queueWrite(cfg.dp, false);
|
|
473
|
+
this._reflectValve(cfg, false);
|
|
474
|
+
this._syncAggregate();
|
|
475
|
+
}, seconds * 1000);
|
|
476
|
+
} else {
|
|
477
|
+
this._endTimes[cfg.dp] = null;
|
|
478
|
+
remainingChar.updateValue(0);
|
|
479
|
+
}
|
|
480
|
+
this._syncAggregate();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
_valveRemaining(cfg) {
|
|
485
|
+
const end = this._endTimes[cfg.dp];
|
|
486
|
+
if (!end) return 0;
|
|
487
|
+
const remaining = Math.round((end - Date.now()) / 1000);
|
|
488
|
+
return remaining > 0 ? remaining : 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
_systemRemaining() {
|
|
492
|
+
let max = 0;
|
|
493
|
+
this._valves.forEach(cfg => {
|
|
494
|
+
const r = this._valveRemaining(cfg);
|
|
495
|
+
if (r > max) max = r;
|
|
496
|
+
});
|
|
497
|
+
return max;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* ------------------------------------------------------------------ *
|
|
501
|
+
* Aggregation: roll zone state up to the IrrigationSystem service
|
|
502
|
+
* ------------------------------------------------------------------ */
|
|
503
|
+
|
|
504
|
+
_anyValveOn() {
|
|
505
|
+
return this._valves.some(cfg => !!this.device.state[cfg.dp]);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
_syncAggregate() {
|
|
509
|
+
const {Characteristic} = this.hap;
|
|
510
|
+
const anyOn = this._valves.some(cfg => {
|
|
511
|
+
const service = this._valveService(cfg);
|
|
512
|
+
return service && service.getCharacteristic(Characteristic.InUse).value === Characteristic.InUse.IN_USE;
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
if (this._systemInUseChar) {
|
|
516
|
+
const v = anyOn ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE;
|
|
517
|
+
if (this._systemInUseChar.value !== v) this._systemInUseChar.updateValue(v);
|
|
518
|
+
}
|
|
519
|
+
if (this._systemActiveChar) {
|
|
520
|
+
const v = anyOn ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE;
|
|
521
|
+
if (this._systemActiveChar.value !== v) this._systemActiveChar.updateValue(v);
|
|
522
|
+
}
|
|
523
|
+
if (this._systemRemainingChar) {
|
|
524
|
+
this._systemRemainingChar.updateValue(this._systemRemaining());
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/* ------------------------------------------------------------------ *
|
|
529
|
+
* Battery + rain mapping
|
|
530
|
+
* ------------------------------------------------------------------ */
|
|
531
|
+
|
|
532
|
+
_batteryLevel(value) {
|
|
533
|
+
const n = parseInt(value);
|
|
534
|
+
if (!isFinite(n)) return 0;
|
|
535
|
+
return Math.max(0, Math.min(100, n));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
_lowBattery(level) {
|
|
539
|
+
const {Characteristic} = this.hap;
|
|
540
|
+
return level <= this._lowBatteryThreshold
|
|
541
|
+
? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
|
|
542
|
+
: Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// CHARGING / NOT_CHARGING from the device's boolean charging data-point.
|
|
546
|
+
// Devices that don't report charging (no such data-point) → NOT_CHARGEABLE.
|
|
547
|
+
_chargingState(value) {
|
|
548
|
+
const {Characteristic} = this.hap;
|
|
549
|
+
if (typeof value !== 'boolean') return Characteristic.ChargingState.NOT_CHARGEABLE;
|
|
550
|
+
return value
|
|
551
|
+
? Characteristic.ChargingState.CHARGING
|
|
552
|
+
: Characteristic.ChargingState.NOT_CHARGING;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// True when the device reports rain.
|
|
556
|
+
_rainDetected(value) {
|
|
557
|
+
let raining;
|
|
558
|
+
if (typeof value === 'boolean') raining = value;
|
|
559
|
+
else raining = ('' + value).trim().toLowerCase() === this._rainOnValue.toLowerCase();
|
|
560
|
+
return this._rainInverted ? !raining : raining;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
_contactState(value) {
|
|
564
|
+
const {Characteristic} = this.hap;
|
|
565
|
+
// "Rain" is treated as the triggered/abnormal state → CONTACT_NOT_DETECTED.
|
|
566
|
+
return this._rainDetected(value)
|
|
567
|
+
? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
|
|
568
|
+
: Characteristic.ContactSensorState.CONTACT_DETECTED;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/* ------------------------------------------------------------------ *
|
|
572
|
+
* Batched writes — coalesce DP updates into a single Tuya command
|
|
573
|
+
* ------------------------------------------------------------------ */
|
|
574
|
+
|
|
575
|
+
_queueWrite(dp, value) {
|
|
576
|
+
if (!this._pendingWrite) this._pendingWrite = {dps: {}};
|
|
577
|
+
this._pendingWrite.dps[String(dp)] = value;
|
|
578
|
+
|
|
579
|
+
if (this._pendingWrite.timer) clearTimeout(this._pendingWrite.timer);
|
|
580
|
+
this._pendingWrite.timer = setTimeout(() => this._flushWrites(), this._debounce);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
_flushWrites() {
|
|
584
|
+
const pending = this._pendingWrite;
|
|
585
|
+
this._pendingWrite = null;
|
|
586
|
+
if (!pending) return;
|
|
587
|
+
|
|
588
|
+
if (!this.device.connected) {
|
|
589
|
+
this.log.debug('%s: skipping write, device not connected', this.device.context.name);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const dps = {};
|
|
594
|
+
let count = 0;
|
|
595
|
+
Object.keys(pending.dps).forEach(dp => {
|
|
596
|
+
if (pending.dps[dp] !== this.device.state[dp]) {
|
|
597
|
+
dps[dp] = pending.dps[dp];
|
|
598
|
+
count++;
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
if (count) this.device.update(dps);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
_valveService(cfg) {
|
|
606
|
+
const {Service} = this.hap;
|
|
607
|
+
return this.accessory.getServiceById(Service.Valve, 'valve-' + cfg.dp);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
module.exports = IrrigationSystemAccessory;
|