homebridge-tuya-plus 3.14.0-dev.18 → 3.14.0-dev.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Changelog.md +4 -3
- package/Readme.MD +4 -6
- package/config.schema.json +5 -14
- package/index.js +349 -358
- package/lib/IrrigationSystemAccessory.js +12 -23
- package/lib/SimpleDimmerAccessory.js +9 -5
- package/lib/SimpleGarageDoorAccessory.js +9 -9
- package/lib/TuyaCloudApi.js +21 -0
- package/lib/TuyaCloudDevice.js +86 -15
- package/lib/TuyaDevice.js +266 -0
- package/lib/ValveAccessory.js +7 -7
- package/lib/WledDimmerAccessory.js +43 -80
- package/package.json +2 -2
- package/test/IrrigationSystemAccessory.test.js +44 -49
- package/test/TuyaCloudApi.test.js +17 -0
- package/test/TuyaCloudDevice.test.js +49 -0
- package/test/TuyaDevice.test.js +252 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +141 -0
- package/wiki/Supported-Device-Types.md +8 -6
- package/wiki/Tuya-Cloud-Setup.md +15 -29
package/lib/ValveAccessory.js
CHANGED
|
@@ -55,7 +55,7 @@ class ValveAccessory extends BaseAccessory {
|
|
|
55
55
|
service.getCharacteristic(Characteristic.SetDuration)
|
|
56
56
|
.onGet(() => { if (!this.device.connected) throw this._commError(); return this.setDuration; })
|
|
57
57
|
.on('change', (data) => {
|
|
58
|
-
this.log.
|
|
58
|
+
this.log.debug('Water Valve Time Duration Set to: ' + data.newValue / 60 + ' Minutes');
|
|
59
59
|
this.setDuration = data.newValue;
|
|
60
60
|
|
|
61
61
|
if (service.getCharacteristic(Characteristic.InUse).value) {
|
|
@@ -65,7 +65,7 @@ class ValveAccessory extends BaseAccessory {
|
|
|
65
65
|
|
|
66
66
|
clearTimeout(this.timer);
|
|
67
67
|
this.timer = setTimeout(() => {
|
|
68
|
-
this.log.
|
|
68
|
+
this.log.debug('Water Valve Timer Expired. Shutting OFF Valve');
|
|
69
69
|
this.setStateInBackground(this.dpPower, 0);
|
|
70
70
|
service.getCharacteristic(Characteristic.Active).updateValue(0);
|
|
71
71
|
service.getCharacteristic(Characteristic.InUse).updateValue(0);
|
|
@@ -89,16 +89,16 @@ class ValveAccessory extends BaseAccessory {
|
|
|
89
89
|
service.getCharacteristic(Characteristic.RemainingDuration).updateValue(0);
|
|
90
90
|
service.getCharacteristic(Characteristic.Active).updateValue(0);
|
|
91
91
|
clearTimeout(this.timer);
|
|
92
|
-
this.log.
|
|
92
|
+
this.log.debug('Water Valve is OFF!');
|
|
93
93
|
break;
|
|
94
94
|
case 1:
|
|
95
95
|
this.lastActivationTime = (new Date()).getTime();
|
|
96
96
|
service.getCharacteristic(Characteristic.RemainingDuration).updateValue(this.setDuration);
|
|
97
97
|
service.getCharacteristic(Characteristic.Active).updateValue(1);
|
|
98
|
-
this.log.
|
|
98
|
+
this.log.debug('Water Valve Turning ON with Timer Set to: ' + this.setDuration / 60 + ' Minutes');
|
|
99
99
|
clearTimeout(this.timer);
|
|
100
100
|
this.timer = setTimeout(() => {
|
|
101
|
-
this.log.
|
|
101
|
+
this.log.debug('Water Valve Timer Expired. Shutting OFF Valve');
|
|
102
102
|
this.setStateInBackground(this.dpPower, 0);
|
|
103
103
|
service.getCharacteristic(Characteristic.Active).updateValue(0);
|
|
104
104
|
service.getCharacteristic(Characteristic.InUse).updateValue(0);
|
|
@@ -113,10 +113,10 @@ class ValveAccessory extends BaseAccessory {
|
|
|
113
113
|
service.getCharacteristic(Characteristic.RemainingDuration).updateValue(this.setDuration);
|
|
114
114
|
service.getCharacteristic(Characteristic.Active).updateValue(1);
|
|
115
115
|
service.getCharacteristic(Characteristic.InUse).updateValue(1);
|
|
116
|
-
this.log.
|
|
116
|
+
this.log.debug('Water Valve is ON After Restart. Setting Timer to: ' + this.setDuration / 60 + ' Minutes');
|
|
117
117
|
clearTimeout(this.timer);
|
|
118
118
|
this.timer = setTimeout(() => {
|
|
119
|
-
this.log.
|
|
119
|
+
this.log.debug('Water Valve Timer Expired. Shutting OFF Valve');
|
|
120
120
|
this.setStateInBackground(this.dpPower, 0);
|
|
121
121
|
service.getCharacteristic(Characteristic.Active).updateValue(0);
|
|
122
122
|
this.lastActivationTime = null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const
|
|
1
|
+
const SimpleDimmerAccessory = require('./SimpleDimmerAccessory');
|
|
2
2
|
const http = require('http');
|
|
3
3
|
const maxWledBrightness = 255;
|
|
4
4
|
|
|
@@ -6,30 +6,14 @@ const maxWledBrightness = 255;
|
|
|
6
6
|
const wledDebounceMs = 50;
|
|
7
7
|
const wledWarmupMs = 8000;
|
|
8
8
|
|
|
9
|
-
class WledDimmerAccessory extends
|
|
10
|
-
static getCategory(Categories) {
|
|
11
|
-
return Categories.LIGHTBULB;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
constructor(...props) {
|
|
15
|
-
super(...props);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
_registerPlatformAccessory() {
|
|
19
|
-
const {Service} = this.hap;
|
|
20
|
-
|
|
21
|
-
this.accessory.addService(Service.Lightbulb, this.device.context.name);
|
|
22
|
-
|
|
23
|
-
super._registerPlatformAccessory();
|
|
24
|
-
}
|
|
25
|
-
|
|
9
|
+
class WledDimmerAccessory extends SimpleDimmerAccessory {
|
|
26
10
|
_registerCharacteristics(dps) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
11
|
+
// The Lightbulb service, On/Brightness characteristics, default DPs and
|
|
12
|
+
// the device 'change' listener are all set up by SimpleDimmerAccessory.
|
|
13
|
+
// WLED only layers optional brightness sync and preset-effect switches
|
|
14
|
+
// on top, so without syncBrightnessToWled configured this behaves
|
|
15
|
+
// exactly like a plain SimpleDimmer.
|
|
16
|
+
super._registerCharacteristics(dps);
|
|
33
17
|
|
|
34
18
|
// State for debounced / delayed WLED updates
|
|
35
19
|
this._wledPendingBri = null;
|
|
@@ -47,32 +31,20 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
47
31
|
null;
|
|
48
32
|
this.syncBrightnessToWled = (syncCfg && ('' + syncCfg).trim()) || null;
|
|
49
33
|
|
|
50
|
-
this.log.
|
|
34
|
+
this.log.debug(
|
|
51
35
|
'[WLED Sync] %s: syncBrightnessToWled=%s',
|
|
52
36
|
this.device.context.name,
|
|
53
37
|
this.syncBrightnessToWled || 'disabled'
|
|
54
38
|
);
|
|
55
39
|
|
|
56
|
-
const characteristicOn = service.getCharacteristic(Characteristic.On)
|
|
57
|
-
.updateValue(dps[this.dpPower])
|
|
58
|
-
.onGet(() => this.getStateAsync(this.dpPower))
|
|
59
|
-
.onSet(value => this.setStateAsync(this.dpPower, value));
|
|
60
|
-
|
|
61
|
-
const characteristicBrightness = service.getCharacteristic(Characteristic.Brightness);
|
|
62
|
-
// Keep a reference so we can update brightness from background WLED calls
|
|
63
|
-
this._characteristicBrightness = characteristicBrightness;
|
|
64
|
-
|
|
65
40
|
if (this.syncBrightnessToWled) {
|
|
66
41
|
// Start with 100% locally while we fetch the actual WLED brightness
|
|
67
|
-
|
|
68
|
-
.updateValue(100)
|
|
69
|
-
.onGet(() => this.getBrightness())
|
|
70
|
-
.onSet(value => this.setBrightness(value));
|
|
42
|
+
this._characteristicBrightness.updateValue(100);
|
|
71
43
|
|
|
72
44
|
// Force Tuya dimmer brightness to 100% on startup
|
|
73
45
|
const maxTuyaBrightness = this.convertBrightnessFromHomeKitToTuya(100);
|
|
74
46
|
if (dps[this.dpBrightness] !== maxTuyaBrightness) {
|
|
75
|
-
this.log.
|
|
47
|
+
this.log.debug(
|
|
76
48
|
'[WLED Sync] %s: setting Tuya brightness DP%s to max (%s)',
|
|
77
49
|
this.device.context.name,
|
|
78
50
|
this.dpBrightness,
|
|
@@ -80,19 +52,6 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
80
52
|
);
|
|
81
53
|
this.setState(this.dpBrightness, maxTuyaBrightness, () => {});
|
|
82
54
|
}
|
|
83
|
-
} else {
|
|
84
|
-
const initial = this.convertBrightnessFromTuyaToHomeKit(dps[this.dpBrightness]);
|
|
85
|
-
this.log.debug(
|
|
86
|
-
'%s: initial Tuya brightness DP%s=%s -> HomeKit %s%%',
|
|
87
|
-
this.device.context.name,
|
|
88
|
-
this.dpBrightness,
|
|
89
|
-
dps[this.dpBrightness],
|
|
90
|
-
initial
|
|
91
|
-
);
|
|
92
|
-
characteristicBrightness
|
|
93
|
-
.updateValue(initial)
|
|
94
|
-
.onGet(() => this.getBrightness())
|
|
95
|
-
.onSet(value => this.setBrightness(value));
|
|
96
55
|
}
|
|
97
56
|
|
|
98
57
|
// Optional: create one HomeKit Switch per configured WLED preset effect.
|
|
@@ -148,7 +107,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
148
107
|
return callback();
|
|
149
108
|
}
|
|
150
109
|
|
|
151
|
-
this.log.
|
|
110
|
+
this.log.debug(
|
|
152
111
|
'[WLED Sync] %s: setting preset effect "%s" (id=%s, index=%s)',
|
|
153
112
|
this.device.context.name,
|
|
154
113
|
effectName,
|
|
@@ -212,15 +171,17 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
212
171
|
.filter(service => service.UUID === HapService.Switch.UUID && service.subtype && service.subtype.startsWith('wledEffect '))
|
|
213
172
|
.forEach(service => {
|
|
214
173
|
if (!validEffectServices.includes(service)) {
|
|
215
|
-
this.log.
|
|
174
|
+
this.log.debug('Removing', service.displayName);
|
|
216
175
|
this.accessory.removeService(service);
|
|
217
176
|
}
|
|
218
177
|
});
|
|
219
178
|
}
|
|
179
|
+
}
|
|
220
180
|
|
|
181
|
+
_registerChangeListener() {
|
|
221
182
|
this.device.on('change', changes => {
|
|
222
183
|
// Log full DPS changes so we can see exactly what Tuya reported
|
|
223
|
-
this.log.
|
|
184
|
+
this.log.debug(
|
|
224
185
|
'%s DPS changes: %s %s was:',
|
|
225
186
|
this.device.context.name,
|
|
226
187
|
JSON.stringify(changes),
|
|
@@ -229,10 +190,10 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
229
190
|
);
|
|
230
191
|
|
|
231
192
|
if (changes.hasOwnProperty(this.dpPower)) {
|
|
232
|
-
const oldPower = !!
|
|
193
|
+
const oldPower = !!this._characteristicOn.value;
|
|
233
194
|
const newPower = !!changes[this.dpPower];
|
|
234
195
|
|
|
235
|
-
this.log.
|
|
196
|
+
this.log.debug(
|
|
236
197
|
'%s power change detected on DP%s: %s -> %s',
|
|
237
198
|
this.device.context.name,
|
|
238
199
|
this.dpPower,
|
|
@@ -241,13 +202,13 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
241
202
|
);
|
|
242
203
|
|
|
243
204
|
if (oldPower !== newPower) {
|
|
244
|
-
|
|
205
|
+
this._characteristicOn.updateValue(newPower);
|
|
245
206
|
}
|
|
246
207
|
|
|
247
208
|
// Track warmup window for WLED after power ON
|
|
248
209
|
if (this.syncBrightnessToWled && newPower) {
|
|
249
210
|
this._wledReadyAt = Date.now() + wledWarmupMs;
|
|
250
|
-
this.log.
|
|
211
|
+
this.log.debug(
|
|
251
212
|
'[WLED Sync] %s: power ON -> delaying WLED API calls for %sms',
|
|
252
213
|
this.device.context.name,
|
|
253
214
|
wledWarmupMs
|
|
@@ -271,7 +232,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
271
232
|
|
|
272
233
|
// Ignore the "forced back to 100%" update to avoid feedback loops
|
|
273
234
|
if (tuyaValue === maxTuyaBrightness) {
|
|
274
|
-
this.log.
|
|
235
|
+
this.log.debug(
|
|
275
236
|
'[WLED Sync] %s: Tuya DP%s reported max brightness (%s), ignoring',
|
|
276
237
|
this.device.context.name,
|
|
277
238
|
this.dpBrightness,
|
|
@@ -283,7 +244,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
283
244
|
const percent = this.convertBrightnessFromTuyaToHomeKit(tuyaValue);
|
|
284
245
|
const bri = Math.max(0, Math.min(maxWledBrightness, Math.round((percent / 100) * maxWledBrightness)));
|
|
285
246
|
|
|
286
|
-
this.log.
|
|
247
|
+
this.log.debug(
|
|
287
248
|
'[WLED Sync] %s: Tuya DP%s changed to %s -> %s%% -> WLED bri=%s (debounced)',
|
|
288
249
|
this.device.context.name,
|
|
289
250
|
this.dpBrightness,
|
|
@@ -304,14 +265,14 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
304
265
|
}
|
|
305
266
|
|
|
306
267
|
// Reflect that brightness back into HomeKit
|
|
307
|
-
|
|
268
|
+
this._characteristicBrightness.updateValue(percent);
|
|
308
269
|
|
|
309
270
|
// After a short delay, force Tuya brightness back to 100% so it stops dimming the strip
|
|
310
271
|
if (this._wledForceMaxTimeout) {
|
|
311
272
|
clearTimeout(this._wledForceMaxTimeout);
|
|
312
273
|
}
|
|
313
274
|
this._wledForceMaxTimeout = setTimeout(() => {
|
|
314
|
-
this.log.
|
|
275
|
+
this.log.debug(
|
|
315
276
|
'[WLED Sync] %s: forcing Tuya DP%s back to max (%s) after Tuya app change',
|
|
316
277
|
this.device.context.name,
|
|
317
278
|
this.dpBrightness,
|
|
@@ -320,8 +281,8 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
320
281
|
this.setState(this.dpBrightness, maxTuyaBrightness, () => {});
|
|
321
282
|
}, 5000);
|
|
322
283
|
});
|
|
323
|
-
} else if (!this.syncBrightnessToWled && changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(
|
|
324
|
-
|
|
284
|
+
} else if (!this.syncBrightnessToWled && changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(this._characteristicBrightness.value) !== changes[this.dpBrightness]) {
|
|
285
|
+
this._characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness]));
|
|
325
286
|
}
|
|
326
287
|
});
|
|
327
288
|
}
|
|
@@ -331,7 +292,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
331
292
|
if (this.syncBrightnessToWled) {
|
|
332
293
|
// If we already have a cached WLED brightness, return it immediately.
|
|
333
294
|
if (this._lastWledPercent != null) {
|
|
334
|
-
this.log.
|
|
295
|
+
this.log.debug(
|
|
335
296
|
'[WLED Sync] %s: getBrightness() -> using cached %s%%',
|
|
336
297
|
this.device.context.name,
|
|
337
298
|
this._lastWledPercent
|
|
@@ -340,13 +301,14 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
340
301
|
}
|
|
341
302
|
return 50;
|
|
342
303
|
} else {
|
|
343
|
-
|
|
304
|
+
// No WLED sync: behave as a plain SimpleDimmer.
|
|
305
|
+
return super.getBrightness();
|
|
344
306
|
}
|
|
345
307
|
}
|
|
346
308
|
|
|
347
309
|
setBrightness(value) {
|
|
348
310
|
if (this.syncBrightnessToWled) {
|
|
349
|
-
this.log.
|
|
311
|
+
this.log.debug(
|
|
350
312
|
'[WLED Sync] %s: setBrightness(%s%%)',
|
|
351
313
|
this.device.context.name,
|
|
352
314
|
value
|
|
@@ -358,7 +320,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
358
320
|
// Ensure Tuya stays at 100% so it doesn't interfere with WLED.
|
|
359
321
|
const maxTuyaBrightness = this.convertBrightnessFromHomeKitToTuya(100);
|
|
360
322
|
if (this.device.state[this.dpBrightness] !== maxTuyaBrightness) {
|
|
361
|
-
this.log.
|
|
323
|
+
this.log.debug(
|
|
362
324
|
'[WLED Sync] %s: ensuring Tuya DP%s=%s (max)',
|
|
363
325
|
this.device.context.name,
|
|
364
326
|
this.dpBrightness,
|
|
@@ -370,7 +332,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
370
332
|
|
|
371
333
|
// Compute desired WLED brightness once.
|
|
372
334
|
const bri = Math.max(0, Math.min(maxWledBrightness, Math.round((value / 100) * maxWledBrightness)));
|
|
373
|
-
this.log.
|
|
335
|
+
this.log.debug(
|
|
374
336
|
'[WLED Sync] %s: mapped HomeKit %s%% -> WLED bri=%s (debounced background)',
|
|
375
337
|
this.device.context.name,
|
|
376
338
|
value,
|
|
@@ -383,7 +345,8 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
383
345
|
// Return to HomeKit immediately; don't wait for network I/O.
|
|
384
346
|
return null;
|
|
385
347
|
} else {
|
|
386
|
-
|
|
348
|
+
// No WLED sync: behave as a plain SimpleDimmer.
|
|
349
|
+
return super.setBrightness(value);
|
|
387
350
|
}
|
|
388
351
|
}
|
|
389
352
|
|
|
@@ -392,7 +355,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
392
355
|
const parts = String(this.syncBrightnessToWled).split(':');
|
|
393
356
|
const host = parts[0];
|
|
394
357
|
const port = parts[1] ? parseInt(parts[1], 10) || 80 : 80;
|
|
395
|
-
this.log.
|
|
358
|
+
this.log.debug(
|
|
396
359
|
'[WLED Sync] %s: target host=%s port=%s',
|
|
397
360
|
this.device.context.name,
|
|
398
361
|
host,
|
|
@@ -426,7 +389,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
426
389
|
timeout: 1000
|
|
427
390
|
};
|
|
428
391
|
|
|
429
|
-
this.log.
|
|
392
|
+
this.log.debug(
|
|
430
393
|
'[WLED Sync] %s: GET http://%s:%s/json/state',
|
|
431
394
|
this.device.context.name,
|
|
432
395
|
target.host,
|
|
@@ -449,7 +412,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
449
412
|
);
|
|
450
413
|
return done(true);
|
|
451
414
|
}
|
|
452
|
-
this.log.
|
|
415
|
+
this.log.debug(
|
|
453
416
|
'[WLED Sync] %s: /json/state -> bri=%s',
|
|
454
417
|
this.device.context.name,
|
|
455
418
|
bri
|
|
@@ -508,7 +471,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
508
471
|
timeout: 1000
|
|
509
472
|
};
|
|
510
473
|
|
|
511
|
-
this.log.
|
|
474
|
+
this.log.debug(
|
|
512
475
|
'[WLED Sync] %s: POST http://%s:%s/json/state body=%s',
|
|
513
476
|
this.device.context.name,
|
|
514
477
|
target.host,
|
|
@@ -520,7 +483,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
520
483
|
// Consume response and ignore body
|
|
521
484
|
res.on('data', () => {});
|
|
522
485
|
res.on('end', () => {
|
|
523
|
-
this.log.
|
|
486
|
+
this.log.debug(
|
|
524
487
|
'[WLED Sync] %s: WLED brightness set, HTTP %s',
|
|
525
488
|
this.device.context.name,
|
|
526
489
|
res.statusCode
|
|
@@ -583,7 +546,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
583
546
|
timeout: 1000
|
|
584
547
|
};
|
|
585
548
|
|
|
586
|
-
this.log.
|
|
549
|
+
this.log.debug(
|
|
587
550
|
'[WLED Sync] %s: POST http://%s:%s/json/state body=%s (preset effect)',
|
|
588
551
|
this.device.context.name,
|
|
589
552
|
target.host,
|
|
@@ -595,7 +558,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
595
558
|
// Consume response and ignore body
|
|
596
559
|
res.on('data', () => {});
|
|
597
560
|
res.on('end', () => {
|
|
598
|
-
this.log.
|
|
561
|
+
this.log.debug(
|
|
599
562
|
'[WLED Sync] %s: WLED effect set to %s, HTTP %s',
|
|
600
563
|
this.device.context.name,
|
|
601
564
|
effectId,
|
|
@@ -659,7 +622,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
659
622
|
const warmupDelay = this._wledReadyAt && now < this._wledReadyAt ? (this._wledReadyAt - now) : 0;
|
|
660
623
|
const delay = warmupDelay + wledDebounceMs;
|
|
661
624
|
|
|
662
|
-
this.log.
|
|
625
|
+
this.log.debug(
|
|
663
626
|
'[WLED Sync] %s: scheduling WLED bri=%s in %sms (%s)',
|
|
664
627
|
this.device.context.name,
|
|
665
628
|
brightness,
|
|
@@ -672,7 +635,7 @@ class WledDimmerAccessory extends BaseAccessory {
|
|
|
672
635
|
|
|
673
636
|
// If the light is now off, skip the call.
|
|
674
637
|
if (!this.device.state[this.dpPower]) {
|
|
675
|
-
this.log.
|
|
638
|
+
this.log.debug(
|
|
676
639
|
'[WLED Sync] %s: skipping scheduled WLED bri=%s because power is OFF',
|
|
677
640
|
this.device.context.name,
|
|
678
641
|
this._wledPendingBri
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.14.0-dev.
|
|
4
|
-
"description": "A community-maintained Homebridge plugin for controlling Tuya devices
|
|
3
|
+
"version": "3.14.0-dev.20",
|
|
4
|
+
"description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "jest",
|
|
@@ -473,46 +473,19 @@ describe('IrrigationSystemAccessory — charging state', () => {
|
|
|
473
473
|
});
|
|
474
474
|
});
|
|
475
475
|
|
|
476
|
-
describe('IrrigationSystemAccessory —
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
476
|
+
describe('IrrigationSystemAccessory — data-points addressed by code', () => {
|
|
477
|
+
// The accessory is transport-agnostic: a data-point may be a numeric id or a
|
|
478
|
+
// Tuya "code". The LAN+cloud TuyaDevice keeps state dual-keyed and translates
|
|
479
|
+
// writes, so irrigation has no cloud-specific logic — it just uses the dp
|
|
480
|
+
// strings it's given. A device reached over the cloud commonly addresses its
|
|
481
|
+
// zones as switch_1.. and battery as battery_percentage.
|
|
482
482
|
beforeEach(() => jest.useFakeTimers());
|
|
483
483
|
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
484
484
|
|
|
485
|
-
test('
|
|
486
|
-
const { accessory } = makeHarness(cloudState(), cloudCtx);
|
|
487
|
-
['switch_1', 'switch_2', 'switch_3', 'switch_4'].forEach(code => {
|
|
488
|
-
expect(valve(accessory, code)).toBeTruthy();
|
|
489
|
-
});
|
|
490
|
-
expect(valve(accessory, '1')).toBeFalsy();
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
test('turning a zone on writes the code/value to the device', () => {
|
|
494
|
-
const { accessory, device } = makeHarness(cloudState(), cloudCtx);
|
|
495
|
-
valve(accessory, 'switch_1').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
496
|
-
jest.advanceTimersByTime(500);
|
|
497
|
-
expect(device.update).toHaveBeenCalledWith({ switch_1: true });
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
test('battery is read from the battery_percentage code', () => {
|
|
501
|
-
const { accessory } = makeHarness(cloudState(), cloudCtx);
|
|
502
|
-
const battery = accessory.getService(Service.Battery);
|
|
503
|
-
expect(battery.getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
test('a realtime change keyed by code is reflected in HomeKit', () => {
|
|
507
|
-
const { accessory, device } = makeHarness(cloudState(), cloudCtx);
|
|
508
|
-
device.emitChange({ switch_2: true });
|
|
509
|
-
expect(valve(accessory, 'switch_2').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
test('an explicit valve list may use custom codes', () => {
|
|
485
|
+
test('an explicit valve list may use string codes', () => {
|
|
513
486
|
const { accessory, device } = makeHarness(
|
|
514
487
|
{ zone_a: false, zone_b: false, battery_percentage: 50 },
|
|
515
|
-
{
|
|
488
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], dpBattery: 'battery_percentage' }
|
|
516
489
|
);
|
|
517
490
|
expect(valve(accessory, 'zone_a')).toBeTruthy();
|
|
518
491
|
valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
@@ -520,35 +493,57 @@ describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)',
|
|
|
520
493
|
expect(device.update).toHaveBeenCalledWith({ zone_b: true });
|
|
521
494
|
});
|
|
522
495
|
|
|
523
|
-
test('
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
496
|
+
test('battery can be read from a code data-point', () => {
|
|
497
|
+
const { accessory } = makeHarness(
|
|
498
|
+
{ zone_a: false, battery_percentage: 99 },
|
|
499
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }], dpBattery: 'battery_percentage' }
|
|
500
|
+
);
|
|
501
|
+
expect(accessory.getService(Service.Battery).getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('a device-side change keyed by code is reflected in HomeKit', () => {
|
|
505
|
+
const { accessory, device } = makeHarness(
|
|
506
|
+
{ zone_a: false, zone_b: false },
|
|
507
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true }
|
|
508
|
+
);
|
|
509
|
+
device.emitChange({ zone_b: true });
|
|
510
|
+
expect(valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('turning a zone off still writes false when the device never echoed the "on"', () => {
|
|
514
|
+
// A device (notably over the cloud) may not optimistically advance `state`;
|
|
515
|
+
// it only moves when the device confirms. A follow-up "off" must STILL be
|
|
516
|
+
// sent — otherwise the valve stays open while HomeKit shows it closed (the
|
|
528
517
|
// exact "can turn on but not off" report).
|
|
529
|
-
const { accessory, device } = makeHarness(
|
|
518
|
+
const { accessory, device } = makeHarness(
|
|
519
|
+
{ zone_a: false },
|
|
520
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }], noBattery: true, defaultDuration: 0 }
|
|
521
|
+
);
|
|
530
522
|
device.update.mockImplementation(() => true); // writes never touch state
|
|
531
|
-
const v = valve(accessory, '
|
|
523
|
+
const v = valve(accessory, 'zone_a');
|
|
532
524
|
|
|
533
525
|
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
534
526
|
jest.advanceTimersByTime(500);
|
|
535
|
-
expect(device.update).toHaveBeenLastCalledWith({
|
|
527
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: true });
|
|
536
528
|
|
|
537
529
|
v.getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
538
530
|
jest.advanceTimersByTime(500);
|
|
539
|
-
expect(device.update).toHaveBeenLastCalledWith({
|
|
531
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: false });
|
|
540
532
|
});
|
|
541
533
|
|
|
542
|
-
test('master OFF closes zones the
|
|
543
|
-
const { accessory, device } = makeHarness(
|
|
534
|
+
test('master OFF closes zones the device has not echoed as open', () => {
|
|
535
|
+
const { accessory, device } = makeHarness(
|
|
536
|
+
{ zone_a: false, zone_b: false },
|
|
537
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true, defaultDuration: 0 }
|
|
538
|
+
);
|
|
544
539
|
device.update.mockImplementation(() => true); // writes never touch state
|
|
545
540
|
|
|
546
|
-
valve(accessory, '
|
|
547
|
-
valve(accessory, '
|
|
541
|
+
valve(accessory, 'zone_a').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
542
|
+
valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
548
543
|
jest.advanceTimersByTime(500);
|
|
549
544
|
|
|
550
545
|
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
551
546
|
jest.advanceTimersByTime(500);
|
|
552
|
-
expect(device.update).toHaveBeenLastCalledWith({
|
|
547
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: false, zone_b: false });
|
|
553
548
|
});
|
|
554
549
|
});
|
|
@@ -138,6 +138,23 @@ describe('TuyaCloudApi — endpoints', () => {
|
|
|
138
138
|
await expect(api.getStatus('dev')).resolves.toEqual([{code: 'switch_1', value: true}]);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test('getShadowProperties returns the properties array (code + dp_id)', async () => {
|
|
142
|
+
const api = ready();
|
|
143
|
+
let path;
|
|
144
|
+
api._httpsRequest = async (method, p) => {
|
|
145
|
+
path = p;
|
|
146
|
+
return {success: true, result: {properties: [{code: 'switch_led', dp_id: 20, value: true}]}};
|
|
147
|
+
};
|
|
148
|
+
await expect(api.getShadowProperties('dev')).resolves.toEqual([{code: 'switch_led', dp_id: 20, value: true}]);
|
|
149
|
+
expect(path).toBe('/v2.0/cloud/thing/dev/shadow/properties');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('getShadowProperties returns null (never throws) when the shadow API is unavailable', async () => {
|
|
153
|
+
const api = ready();
|
|
154
|
+
api._httpsRequest = async () => ({success: false, code: 1106, msg: 'permission deny'});
|
|
155
|
+
await expect(api.getShadowProperties('dev')).resolves.toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
141
158
|
test('getDeviceInfo returns the device record (with online status)', async () => {
|
|
142
159
|
const api = ready();
|
|
143
160
|
let path;
|
|
@@ -127,6 +127,55 @@ describe('TuyaCloudDevice', () => {
|
|
|
127
127
|
expect(dev.connected).toBe(false);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test('builds the code<->dp_id map and dual-keys state from the shadow', async () => {
|
|
131
|
+
const props = [{code: 'switch_1', dp_id: 1, value: false}, {code: 'battery_percentage', dp_id: 46, value: 99}];
|
|
132
|
+
const api = makeApi();
|
|
133
|
+
api.getShadowProperties = jest.fn().mockResolvedValue(props);
|
|
134
|
+
const dev = makeDevice(api);
|
|
135
|
+
await dev._connect();
|
|
136
|
+
|
|
137
|
+
expect(api.getShadowProperties).toHaveBeenCalledWith('dev1');
|
|
138
|
+
expect(dev.codeByDpId).toEqual({'1': 'switch_1', '46': 'battery_percentage'});
|
|
139
|
+
expect(dev.dpIdByCode).toEqual({'switch_1': '1', 'battery_percentage': '46'});
|
|
140
|
+
// state is addressable by BOTH the cloud code and the numeric LAN dp id
|
|
141
|
+
expect(dev.state).toEqual({switch_1: false, '1': false, battery_percentage: 99, '46': 99});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('update translates a numeric dp id to its cloud code', async () => {
|
|
145
|
+
const api = makeApi();
|
|
146
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
147
|
+
const dev = makeDevice(api);
|
|
148
|
+
await dev._connect();
|
|
149
|
+
|
|
150
|
+
dev.update({'1': true}); // a LAN-style numeric write
|
|
151
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('realtime code-only deltas are mirrored to numeric ids via the learned map', async () => {
|
|
155
|
+
const api = makeApi();
|
|
156
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
157
|
+
const dev = makeDevice(api);
|
|
158
|
+
await dev._connect();
|
|
159
|
+
|
|
160
|
+
const changes = [];
|
|
161
|
+
dev.on('change', c => changes.push(c));
|
|
162
|
+
dev._applyStatus([{code: 'switch_1', value: true}]); // code-only (as MQTT delivers)
|
|
163
|
+
expect(dev.state.switch_1).toBe(true);
|
|
164
|
+
expect(dev.state['1']).toBe(true);
|
|
165
|
+
expect(changes[0]).toEqual({switch_1: true, '1': true});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('falls back to /status (code-only) when the shadow is unavailable', async () => {
|
|
169
|
+
const api = makeApi();
|
|
170
|
+
api.getShadowProperties = jest.fn().mockResolvedValue(null);
|
|
171
|
+
const dev = makeDevice(api);
|
|
172
|
+
await dev._connect();
|
|
173
|
+
|
|
174
|
+
expect(dev.codeByDpId).toEqual({});
|
|
175
|
+
expect(dev.state).toEqual({switch_1: false, battery_percentage: 99}); // code-only, no numeric mirror
|
|
176
|
+
expect(api.getStatus).toHaveBeenCalledWith('dev1');
|
|
177
|
+
});
|
|
178
|
+
|
|
130
179
|
test('subscribes to realtime and re-reads state when the stream (re)connects', async () => {
|
|
131
180
|
const api = makeApi();
|
|
132
181
|
const messaging = Object.assign(new EventEmitter(), {
|