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
package/lib/TuyaAccessory.js
CHANGED
|
@@ -13,7 +13,13 @@ class TuyaAccessory extends EventEmitter {
|
|
|
13
13
|
constructor(props) {
|
|
14
14
|
super();
|
|
15
15
|
|
|
16
|
-
if (!(props.id && props.key && props.ip) && !props.fake)
|
|
16
|
+
if (!(props.id && props.key && props.ip) && !props.fake) {
|
|
17
|
+
// `this.log` isn't assigned yet, and `props` carries the device's
|
|
18
|
+
// local key — log via props.log and name only the id (never dump the
|
|
19
|
+
// whole config, which leaks the key into logs/bug reports).
|
|
20
|
+
if (props.log) props.log.warn('Insufficient details to initialize device %s (need id, key and ip).', props.id || '(unknown id)');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
this.log = props.log;
|
|
19
25
|
|
|
@@ -42,6 +48,21 @@ class TuyaAccessory extends EventEmitter {
|
|
|
42
48
|
this.log.info(`${this.context.name} reports protocol ${this.context.version}, newer than the newest known Tuya LAN protocol (3.5); using the 3.5 (GCM) stack with ${this.context.version} headers.`);
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
// A Tuya local key is the AES-128 key used for every LAN frame and must be
|
|
52
|
+
// exactly 16 bytes. A wrong-length key (mistyped, truncated, or pasted with
|
|
53
|
+
// stray characters) makes crypto.createCipheriv throw "Invalid key length"
|
|
54
|
+
// the moment we first talk to the device. That throw originates inside a
|
|
55
|
+
// socket callback, so it goes unhandled and crashes Homebridge - which then
|
|
56
|
+
// restarts and crashes again: a boot loop. Refuse to bring the LAN backend
|
|
57
|
+
// up for such a device and say why; with a Tuya Cloud session configured it
|
|
58
|
+
// still works as a cloud-only device, and other devices are unaffected.
|
|
59
|
+
this._invalidKey = !this.context.fake && !TuyaAccessory.isValidLocalKey(this.context.key);
|
|
60
|
+
if (this._invalidKey && this.log) {
|
|
61
|
+
const key = this.context.key;
|
|
62
|
+
const keyLength = typeof key === 'string' ? Buffer.byteLength(key, 'utf8') : (Buffer.isBuffer(key) ? key.length : 0);
|
|
63
|
+
this.log.error(`${this.context.name} (${this.context.id}) has an invalid local key: a Tuya local key must be exactly 16 characters (this one has ${keyLength}). LAN control is disabled for this device until the key is corrected.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
this.state = {};
|
|
46
67
|
this._cachedBuffer = Buffer.allocUnsafe(0);
|
|
47
68
|
|
|
@@ -53,7 +74,6 @@ class TuyaAccessory extends EventEmitter {
|
|
|
53
74
|
|
|
54
75
|
if (this._protocolVersion >= 3.2) {
|
|
55
76
|
this.context.pingGap = Math.min(this.context.pingGap || 9, 9);
|
|
56
|
-
//this.log.info(`Changing ping gap for ${this.context.name} to ${this.context.pingGap}s`);
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
this.connected = false;
|
|
@@ -67,7 +87,19 @@ class TuyaAccessory extends EventEmitter {
|
|
|
67
87
|
this.session_key = null;
|
|
68
88
|
}
|
|
69
89
|
|
|
90
|
+
// A Tuya local key is the AES-128 key for the LAN protocol, so it must be
|
|
91
|
+
// exactly 16 bytes (createCipheriv rejects any other length). Measure a
|
|
92
|
+
// string the way createCipheriv does - by its UTF-8 byte length - so a key
|
|
93
|
+
// that merely looks 16 characters long but isn't (e.g. a stray multibyte
|
|
94
|
+
// character) is still caught.
|
|
95
|
+
static isValidLocalKey(key) {
|
|
96
|
+
if (Buffer.isBuffer(key)) return key.length === 16;
|
|
97
|
+
if (typeof key === 'string') return Buffer.byteLength(key, 'utf8') === 16;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
70
101
|
_connect() {
|
|
102
|
+
if (this._invalidKey) return;
|
|
71
103
|
if (this.context.fake) {
|
|
72
104
|
this.connected = true;
|
|
73
105
|
return setTimeout(() => {
|
|
@@ -80,7 +112,6 @@ class TuyaAccessory extends EventEmitter {
|
|
|
80
112
|
this._incrementAttemptCounter();
|
|
81
113
|
|
|
82
114
|
(this._socket.reconnect = () => {
|
|
83
|
-
//this.log.debug(`reconnect called for ${this.context.name}`);
|
|
84
115
|
if (this._socket._pinger) {
|
|
85
116
|
clearTimeout(this._socket._pinger);
|
|
86
117
|
this._socket._pinger = null;
|
|
@@ -196,7 +227,11 @@ class TuyaAccessory extends EventEmitter {
|
|
|
196
227
|
|
|
197
228
|
this._socket.on('error', err => {
|
|
198
229
|
this.connected = false;
|
|
199
|
-
|
|
230
|
+
// Routine: many Tuya devices recycle their LAN socket every few
|
|
231
|
+
// minutes, and a transient ECONNRESET/EPIPE reconnects silently.
|
|
232
|
+
// Keep the churn at debug; a genuine outage surfaces as HomeKit
|
|
233
|
+
// "No Response" (and via the cloud-fallback warnings).
|
|
234
|
+
this.log.debug(`Socket had a problem and will reconnect to ${this.context.name} (${err && err.code || err})`);
|
|
200
235
|
|
|
201
236
|
if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE') && this._connectionAttempts < 10) {
|
|
202
237
|
this.log.debug(`Reconnecting with connection attempts = ${this._connectionAttempts}`);
|
|
@@ -209,10 +244,10 @@ class TuyaAccessory extends EventEmitter {
|
|
|
209
244
|
if (err) {
|
|
210
245
|
if (err.code === 'ENOBUFS') {
|
|
211
246
|
this.log.warn('Operating system complained of resource exhaustion; did I open too many sockets?');
|
|
212
|
-
this.log.
|
|
247
|
+
this.log.debug('Slowing down retry attempts; if you see this happening often, it could mean some sort of incompatibility.');
|
|
213
248
|
delay = 60000;
|
|
214
249
|
} else if (this._connectionAttempts > 10) {
|
|
215
|
-
this.log.
|
|
250
|
+
this.log.debug('Slowing down retry attempts; if you see this happening often, it could mean some sort of incompatibility.');
|
|
216
251
|
delay = 60000;
|
|
217
252
|
}
|
|
218
253
|
}
|
|
@@ -240,14 +275,15 @@ class TuyaAccessory extends EventEmitter {
|
|
|
240
275
|
clearTimeout(this._socket._pinger);
|
|
241
276
|
this._socket._pinger = null;
|
|
242
277
|
}
|
|
243
|
-
|
|
244
|
-
//this.log.info('Closed connection with', this.context.name);
|
|
245
278
|
});
|
|
246
279
|
|
|
247
280
|
this._socket.on('end', () => {
|
|
248
281
|
this.connected = false;
|
|
249
282
|
this.session_key = null;
|
|
250
|
-
|
|
283
|
+
// Routine for devices that recycle their socket; we reconnect
|
|
284
|
+
// promptly below, so keep it at debug rather than announcing a
|
|
285
|
+
// disconnect every few minutes.
|
|
286
|
+
this.log.debug('Disconnected from', this.context.name);
|
|
251
287
|
|
|
252
288
|
// The device closed the connection on its own. This is routine for
|
|
253
289
|
// many Tuya devices (e.g. LED ceiling lights) that recycle their
|
|
@@ -280,7 +316,6 @@ class TuyaAccessory extends EventEmitter {
|
|
|
280
316
|
_incrementAttemptCounter() {
|
|
281
317
|
this._connectionAttempts++;
|
|
282
318
|
setTimeout(() => {
|
|
283
|
-
this.log.debug(`decrementing this._connectionAttempts, currently ${this._connectionAttempts}`);
|
|
284
319
|
this._connectionAttempts--;
|
|
285
320
|
}, 10000);
|
|
286
321
|
}
|
|
@@ -301,7 +336,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
301
336
|
let data = task.msg.slice(len - size, len - 8).toString('utf8').trim().replace(/\0/g, '');
|
|
302
337
|
|
|
303
338
|
if (this.context.intro === false && cmd !== 9)
|
|
304
|
-
this.log.
|
|
339
|
+
this.log.debug('Message from', this.context.name + ':', data);
|
|
305
340
|
|
|
306
341
|
switch (cmd) {
|
|
307
342
|
case 7:
|
|
@@ -329,13 +364,12 @@ class TuyaAccessory extends EventEmitter {
|
|
|
329
364
|
data = JSON.parse(decryptedMsg);
|
|
330
365
|
} catch (ex) {
|
|
331
366
|
data = decryptedMsg;
|
|
332
|
-
this.log.
|
|
333
|
-
this.log.
|
|
367
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, data);
|
|
368
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
334
369
|
break;
|
|
335
370
|
}
|
|
336
371
|
|
|
337
372
|
if (data && data.dps) {
|
|
338
|
-
//this.log.info('Update from', this.context.name, 'with command', cmd + ':', data.dps);
|
|
339
373
|
this._change(data.dps);
|
|
340
374
|
}
|
|
341
375
|
break;
|
|
@@ -344,7 +378,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
344
378
|
case 10:
|
|
345
379
|
if (data) {
|
|
346
380
|
if (data === 'json obj data unvalid') {
|
|
347
|
-
this.log.
|
|
381
|
+
this.log.debug(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
|
|
348
382
|
this.emit('change', {}, this.state);
|
|
349
383
|
break;
|
|
350
384
|
}
|
|
@@ -352,8 +386,8 @@ class TuyaAccessory extends EventEmitter {
|
|
|
352
386
|
try {
|
|
353
387
|
data = JSON.parse(data);
|
|
354
388
|
} catch (ex) {
|
|
355
|
-
this.log.
|
|
356
|
-
this.log.
|
|
389
|
+
this.log.debug(`Malformed update from ${this.context.name} with command ${cmd}:`, data);
|
|
390
|
+
this.log.debug(`Raw update from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
357
391
|
break;
|
|
358
392
|
}
|
|
359
393
|
|
|
@@ -362,8 +396,8 @@ class TuyaAccessory extends EventEmitter {
|
|
|
362
396
|
break;
|
|
363
397
|
|
|
364
398
|
default:
|
|
365
|
-
this.log.
|
|
366
|
-
this.log.
|
|
399
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, data);
|
|
400
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
367
401
|
}
|
|
368
402
|
|
|
369
403
|
callback();
|
|
@@ -408,7 +442,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
408
442
|
}
|
|
409
443
|
|
|
410
444
|
if (cmd === 10 && decryptedMsg === 'json obj data unvalid') {
|
|
411
|
-
this.log.
|
|
445
|
+
this.log.debug(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
|
|
412
446
|
this.emit('change', {}, this.state);
|
|
413
447
|
return callback();
|
|
414
448
|
}
|
|
@@ -417,8 +451,8 @@ class TuyaAccessory extends EventEmitter {
|
|
|
417
451
|
try {
|
|
418
452
|
data = JSON.parse(decryptedMsg);
|
|
419
453
|
} catch(ex) {
|
|
420
|
-
this.log.
|
|
421
|
-
this.log.
|
|
454
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
|
|
455
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
422
456
|
return callback();
|
|
423
457
|
}
|
|
424
458
|
|
|
@@ -427,18 +461,17 @@ class TuyaAccessory extends EventEmitter {
|
|
|
427
461
|
case 10:
|
|
428
462
|
if (data) {
|
|
429
463
|
if (data.dps) {
|
|
430
|
-
//this.log.info(`Heard back from ${this.context.name} with command ${cmd}`);
|
|
431
464
|
this._change(data.dps);
|
|
432
465
|
} else {
|
|
433
|
-
this.log.
|
|
434
|
-
this.log.
|
|
466
|
+
this.log.debug(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
|
|
467
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
435
468
|
}
|
|
436
469
|
}
|
|
437
470
|
break;
|
|
438
471
|
|
|
439
472
|
default:
|
|
440
|
-
this.log.
|
|
441
|
-
this.log.
|
|
473
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
|
|
474
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
442
475
|
}
|
|
443
476
|
|
|
444
477
|
callback();
|
|
@@ -537,7 +570,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
537
570
|
}
|
|
538
571
|
|
|
539
572
|
if (cmd === 10 && parsedPayload === 'json obj data unvalid') {
|
|
540
|
-
this.log.
|
|
573
|
+
this.log.debug(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
|
|
541
574
|
this.emit('change', {}, this.state);
|
|
542
575
|
return callback();
|
|
543
576
|
}
|
|
@@ -548,18 +581,17 @@ class TuyaAccessory extends EventEmitter {
|
|
|
548
581
|
case 16:
|
|
549
582
|
if (parsedPayload) {
|
|
550
583
|
if (parsedPayload.dps) {
|
|
551
|
-
//this.log.info(`Heard back from ${this.context.name} with command ${cmd}`);
|
|
552
584
|
this._change(parsedPayload.dps);
|
|
553
585
|
} else {
|
|
554
|
-
this.log.
|
|
555
|
-
this.log.
|
|
586
|
+
this.log.debug(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
|
|
587
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
556
588
|
}
|
|
557
589
|
}
|
|
558
590
|
break;
|
|
559
591
|
|
|
560
592
|
default:
|
|
561
|
-
this.log.
|
|
562
|
-
this.log.
|
|
593
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
|
|
594
|
+
this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
|
|
563
595
|
}
|
|
564
596
|
|
|
565
597
|
callback();
|
|
@@ -603,7 +635,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
603
635
|
decipher.setAuthTag(tag);
|
|
604
636
|
decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
605
637
|
} catch(ex) {
|
|
606
|
-
this.log.
|
|
638
|
+
this.log.debug(`Failed to decrypt message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, ex);
|
|
607
639
|
return callback();
|
|
608
640
|
}
|
|
609
641
|
|
|
@@ -663,7 +695,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
663
695
|
}
|
|
664
696
|
|
|
665
697
|
if (cmd === 10 && parsedPayload === 'json obj data unvalid') {
|
|
666
|
-
this.log.
|
|
698
|
+
this.log.debug(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
|
|
667
699
|
this.emit('change', {}, this.state);
|
|
668
700
|
return callback();
|
|
669
701
|
}
|
|
@@ -675,11 +707,11 @@ class TuyaAccessory extends EventEmitter {
|
|
|
675
707
|
if (parsedPayload && parsedPayload.dps) {
|
|
676
708
|
this._change(parsedPayload.dps);
|
|
677
709
|
} else {
|
|
678
|
-
this.log.
|
|
710
|
+
this.log.debug(`Malformed message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
|
|
679
711
|
}
|
|
680
712
|
break;
|
|
681
713
|
default:
|
|
682
|
-
this.log.
|
|
714
|
+
this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
|
|
683
715
|
}
|
|
684
716
|
|
|
685
717
|
callback();
|
|
@@ -702,7 +734,6 @@ class TuyaAccessory extends EventEmitter {
|
|
|
702
734
|
|
|
703
735
|
let result = false;
|
|
704
736
|
if (hasDataPoint) {
|
|
705
|
-
//this.log.info(" Sending", this.context.name, JSON.stringify(dps));
|
|
706
737
|
const t = (Date.now() / 1000).toFixed(0);
|
|
707
738
|
const payload = {
|
|
708
739
|
devId: this.context.id,
|
|
@@ -725,13 +756,14 @@ class TuyaAccessory extends EventEmitter {
|
|
|
725
756
|
data,
|
|
726
757
|
cmd: this._protocolVersion >= 3.4 ? 13 : 7
|
|
727
758
|
});
|
|
728
|
-
|
|
759
|
+
// A non-true result here is the socket reporting backpressure (or a
|
|
760
|
+
// closed connection), not necessarily a dropped command; callers that
|
|
761
|
+
// care already check the boolean. Keep it as a debug breadcrumb.
|
|
762
|
+
if (result !== true) this.log.debug(`${this.context.name}: socket write returned ${JSON.stringify(result)}.`);
|
|
729
763
|
if (this.context.sendEmptyUpdate) {
|
|
730
|
-
//this.log.info(" Sending", this.context.name, 'empty signature');
|
|
731
764
|
this._send({cmd: this._protocolVersion >= 3.4 ? 13 : 7});
|
|
732
765
|
}
|
|
733
766
|
} else {
|
|
734
|
-
//this.log.info(`Sending first query to ${this.context.name} (${this.context.version})`);
|
|
735
767
|
result = this._send({
|
|
736
768
|
data: {
|
|
737
769
|
gwId: this.context.id,
|
|
@@ -762,6 +794,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
762
794
|
|
|
763
795
|
_send(o) {
|
|
764
796
|
if (this.context.fake) return;
|
|
797
|
+
if (this._invalidKey) return false;
|
|
765
798
|
if (!this.connected) return false;
|
|
766
799
|
|
|
767
800
|
if (this._protocolVersion < 3.2) return this._send_3_1(o);
|
|
@@ -963,7 +996,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
963
996
|
}
|
|
964
997
|
|
|
965
998
|
_fakeUpdate(dps) {
|
|
966
|
-
this.log.
|
|
999
|
+
this.log.debug('Fake update:', JSON.stringify(dps));
|
|
967
1000
|
Object.keys(dps).forEach(dp => {
|
|
968
1001
|
this.state[dp] = dps[dp];
|
|
969
1002
|
});
|
package/lib/TuyaCloudApi.js
CHANGED
|
@@ -53,9 +53,18 @@ const REGION_ENDPOINTS = {
|
|
|
53
53
|
// see one we drop the cached token and retry the call once.
|
|
54
54
|
const TOKEN_INVALID_CODES = new Set([1010, 1011, 1004]);
|
|
55
55
|
|
|
56
|
+
// Header/body/response field names whose values may be a credential or token.
|
|
57
|
+
// Their values are masked before an HTTP trace is logged (debug.logCloudHttp),
|
|
58
|
+
// so the trace never leaks the access token, signature, account password, uid,
|
|
59
|
+
// or access key/id.
|
|
60
|
+
const SECRET_FIELDS = /^(access_token|refresh_token|password|sign|secret|access_?key|access_?id|client_id|uid|localKey|key)$/i;
|
|
61
|
+
|
|
56
62
|
class TuyaCloudApi {
|
|
57
|
-
constructor({accessId, accessKey, region, endpoint, username, password, countryCode, schema, log} = {}) {
|
|
63
|
+
constructor({accessId, accessKey, region, endpoint, username, password, countryCode, schema, log, logHttp} = {}) {
|
|
58
64
|
this.log = log || console;
|
|
65
|
+
// Dev/diagnostic switch (debug.logCloudHttp): trace every cloud HTTP
|
|
66
|
+
// request/response, with credentials redacted. Off by default.
|
|
67
|
+
this.logHttp = !!logHttp;
|
|
59
68
|
this.accessId = ('' + (accessId || '')).trim();
|
|
60
69
|
this.accessKey = ('' + (accessKey || '')).trim();
|
|
61
70
|
|
|
@@ -78,6 +87,10 @@ class TuyaCloudApi {
|
|
|
78
87
|
this.uid = null; // linked app-account uid (needed for realtime MQTT)
|
|
79
88
|
this._tokenExpiry = 0; // ms epoch when the token must be refreshed
|
|
80
89
|
this._tokenPromise = null; // in-flight token request (de-dupes concurrent callers)
|
|
90
|
+
|
|
91
|
+
// Reuse TLS connections across requests instead of a fresh handshake each
|
|
92
|
+
// time — noticeably cuts per-command latency and the startup read burst.
|
|
93
|
+
this._agent = new https.Agent({keepAlive: true, maxSockets: 8});
|
|
81
94
|
}
|
|
82
95
|
|
|
83
96
|
isConfigured() {
|
|
@@ -209,15 +222,67 @@ class TuyaCloudApi {
|
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
// Read every reported data-point in one call → array of {code, value}.
|
|
225
|
+
// Uses the current iot-03 device API, not the legacy /v1.0/devices/* set: the
|
|
226
|
+
// legacy endpoints answer 2003 ("function not support") for devices they don't
|
|
227
|
+
// model, where iot-03 works. This is the same family tinytuya and the official
|
|
228
|
+
// Tuya Homebridge plugin use throughout.
|
|
212
229
|
async getStatus(deviceId) {
|
|
213
|
-
const res = await this.request('GET', `/v1.0/devices/${encodeURIComponent(deviceId)}/status`);
|
|
230
|
+
const res = await this.request('GET', `/v1.0/iot-03/devices/${encodeURIComponent(deviceId)}/status`);
|
|
214
231
|
return (res && Array.isArray(res.result)) ? res.result : [];
|
|
215
232
|
}
|
|
216
233
|
|
|
217
|
-
//
|
|
234
|
+
// Read the device's "thing shadow" properties. Like getStatus, but each item
|
|
235
|
+
// ALSO carries its numeric `dp_id` — which is exactly the id the LAN protocol
|
|
236
|
+
// uses to address that data-point. That mapping is what lets the plugin bridge
|
|
237
|
+
// a LAN-style (numeric DP) configuration to the cloud (string codes), and back,
|
|
238
|
+
// transparently — so the same accessory works over either transport.
|
|
239
|
+
//
|
|
240
|
+
// Returns an array of {code, dp_id, value} on success, or null when the
|
|
241
|
+
// endpoint isn't available (older projects, or the device-shadow API isn't
|
|
242
|
+
// authorised). Callers fall back to getStatus() in that case (code-only, no
|
|
243
|
+
// numeric mapping). Never throws.
|
|
244
|
+
// Docs: https://developer.tuya.com/en/docs/cloud/116cc8bf6f?id=Kcp2kwfrpe719
|
|
245
|
+
async getShadowProperties(deviceId) {
|
|
246
|
+
try {
|
|
247
|
+
const res = await this.request('GET', `/v2.0/cloud/thing/${encodeURIComponent(deviceId)}/shadow/properties`);
|
|
248
|
+
return (res && res.result && Array.isArray(res.result.properties)) ? res.result.properties : null;
|
|
249
|
+
} catch (ex) {
|
|
250
|
+
this.log.debug(`Tuya Cloud shadow/properties unavailable for ${deviceId} (${ex.message}); falling back to /status.`);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Read the device record (name, product, and crucially `online`). Used to
|
|
256
|
+
// learn whether the device is currently reachable. Returns the raw `result`
|
|
257
|
+
// object or null. Requires the project to have the device-management API
|
|
258
|
+
// authorized; callers treat a failure as "online unknown".
|
|
259
|
+
async getDeviceInfo(deviceId) {
|
|
260
|
+
const res = await this.request('GET', `/v1.0/devices/${encodeURIComponent(deviceId)}`);
|
|
261
|
+
return (res && res.result && typeof res.result === 'object') ? res.result : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Issue one or more commands: [{code, value}, …]. Uses the current iot-03
|
|
265
|
+
// device-control endpoint (the legacy /v1.0/devices/{id}/commands answers 2008
|
|
266
|
+
// for devices it doesn't model), matching tinytuya and the official plugin.
|
|
218
267
|
async sendCommands(deviceId, commands) {
|
|
219
268
|
if (!Array.isArray(commands) || !commands.length) return true;
|
|
220
|
-
const res = await this.request('POST', `/v1.0/devices/${encodeURIComponent(deviceId)}/commands`, {commands});
|
|
269
|
+
const res = await this.request('POST', `/v1.0/iot-03/devices/${encodeURIComponent(deviceId)}/commands`, {commands});
|
|
270
|
+
return !!(res && res.result);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Control a device through the thing-model "send property" endpoint. Some
|
|
274
|
+
// fully-custom devices (e.g. gate controllers) have no standard instruction
|
|
275
|
+
// set at all — every /commands API, legacy or iot-03, answers 2008 for them —
|
|
276
|
+
// but the cloud still models them as thing-model properties (their shadow
|
|
277
|
+
// reads fine). Issuing those properties is how Tuya's own app drives such a
|
|
278
|
+
// device. Same [{code, value}, …] in; the body is {properties: "<json>"} where
|
|
279
|
+
// <json> is a JSON string of {code: value, …}.
|
|
280
|
+
// Docs: https://developer.tuya.com/en/docs/cloud/c057ad5cfd?id=Kcp2kxdzftp91
|
|
281
|
+
async sendProperties(deviceId, commands) {
|
|
282
|
+
if (!Array.isArray(commands) || !commands.length) return true;
|
|
283
|
+
const properties = {};
|
|
284
|
+
commands.forEach(c => { properties[c.code] = c.value; });
|
|
285
|
+
const res = await this.request('POST', `/v2.0/cloud/thing/${encodeURIComponent(deviceId)}/shadow/properties/issue`, {properties: JSON.stringify(properties)});
|
|
221
286
|
return !!(res && res.result);
|
|
222
287
|
}
|
|
223
288
|
|
|
@@ -250,6 +315,46 @@ class TuyaCloudApi {
|
|
|
250
315
|
|
|
251
316
|
/* ------------------------- transport ------------------------------- */
|
|
252
317
|
|
|
318
|
+
_maskSecret(value) {
|
|
319
|
+
const s = '' + value;
|
|
320
|
+
return s.length <= 8 ? '***' : `${s.slice(0, 4)}…${s.slice(-4)}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Deep copy with any credential-looking field masked, so a full HTTP trace
|
|
324
|
+
// can be logged without leaking the token, signature, account password, or
|
|
325
|
+
// access key/id (the device `value`s themselves are not secret and stay).
|
|
326
|
+
_redactForLog(value) {
|
|
327
|
+
if (Array.isArray(value)) return value.map(v => this._redactForLog(v));
|
|
328
|
+
if (value && typeof value === 'object') {
|
|
329
|
+
const out = {};
|
|
330
|
+
for (const k of Object.keys(value)) {
|
|
331
|
+
out[k] = SECRET_FIELDS.test(k) ? this._maskSecret(value[k]) : this._redactForLog(value[k]);
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
return value;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// One redacted request/response trace line, emitted only when the
|
|
339
|
+
// debug.logCloudHttp switch is set. It's routine, per-request and verbose, so
|
|
340
|
+
// it goes to debug. Never throws and never leaks a secret (see _redactForLog).
|
|
341
|
+
_traceHttp(method, urlPath, headers, bodyStr, statusCode, responseBody) {
|
|
342
|
+
if (!this.logHttp) return;
|
|
343
|
+
try {
|
|
344
|
+
let reqBody = '(none)';
|
|
345
|
+
if (bodyStr) {
|
|
346
|
+
try { reqBody = JSON.stringify(this._redactForLog(JSON.parse(bodyStr))); }
|
|
347
|
+
catch (_) { reqBody = ('' + bodyStr).slice(0, 200); }
|
|
348
|
+
}
|
|
349
|
+
this.log.debug(
|
|
350
|
+
`[TuyaCloud HTTP] ${method} ${urlPath} → HTTP ${statusCode}` +
|
|
351
|
+
` | req.headers=${JSON.stringify(this._redactForLog(headers))}` +
|
|
352
|
+
` | req.body=${reqBody}` +
|
|
353
|
+
` | res=${JSON.stringify(this._redactForLog(responseBody))}`
|
|
354
|
+
);
|
|
355
|
+
} catch (_) { /* logging must never throw */ }
|
|
356
|
+
}
|
|
357
|
+
|
|
253
358
|
_httpsRequest(method, urlPath, headers, bodyStr) {
|
|
254
359
|
return new Promise((resolve, reject) => {
|
|
255
360
|
let url;
|
|
@@ -264,21 +369,29 @@ class TuyaCloudApi {
|
|
|
264
369
|
port: url.port || 443,
|
|
265
370
|
path: url.pathname + url.search,
|
|
266
371
|
method,
|
|
267
|
-
headers
|
|
372
|
+
headers,
|
|
373
|
+
agent: this._agent
|
|
268
374
|
}, resp => {
|
|
269
375
|
let data = '';
|
|
270
376
|
resp.setEncoding('utf8');
|
|
271
377
|
resp.on('data', chunk => { data += chunk; });
|
|
272
378
|
resp.on('end', () => {
|
|
379
|
+
let parsed;
|
|
273
380
|
try {
|
|
274
|
-
|
|
381
|
+
parsed = JSON.parse(data);
|
|
275
382
|
} catch (ex) {
|
|
276
|
-
|
|
383
|
+
this._traceHttp(method, urlPath, headers, bodyStr, resp.statusCode, data);
|
|
384
|
+
return reject(new Error(`Tuya Cloud returned non-JSON (HTTP ${resp.statusCode}): ${('' + data).slice(0, 200)}`));
|
|
277
385
|
}
|
|
386
|
+
this._traceHttp(method, urlPath, headers, bodyStr, resp.statusCode, parsed);
|
|
387
|
+
resolve(parsed);
|
|
278
388
|
});
|
|
279
389
|
});
|
|
280
390
|
|
|
281
|
-
req.on('error',
|
|
391
|
+
req.on('error', err => {
|
|
392
|
+
if (this.logHttp) { try { this.log.debug(`[TuyaCloud HTTP] ${method} ${urlPath} → error: ${err.message}`); } catch (_) { /* never throw */ } }
|
|
393
|
+
reject(err);
|
|
394
|
+
});
|
|
282
395
|
req.setTimeout(20000, () => req.destroy(new Error('request timed out')));
|
|
283
396
|
if (bodyStr) req.write(bodyStr);
|
|
284
397
|
req.end();
|