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.
Files changed (53) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +162 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +16 -3
  5. package/Readme.MD +8 -32
  6. package/config.schema.json +79 -79
  7. package/index.js +599 -358
  8. package/lib/AirPurifierAccessory.js +1 -1
  9. package/lib/BaseAccessory.js +54 -17
  10. package/lib/ConvectorAccessory.js +1 -1
  11. package/lib/CustomMultiOutletAccessory.js +2 -1
  12. package/lib/DoorbellAccessory.js +9 -16
  13. package/lib/GarageDoorAccessory.js +1 -1
  14. package/lib/IrrigationSystemAccessory.js +260 -120
  15. package/lib/MultiOutletAccessory.js +3 -2
  16. package/lib/OilDiffuserAccessory.js +10 -8
  17. package/lib/RGBTWLightAccessory.js +13 -11
  18. package/lib/RGBTWOutletAccessory.js +11 -9
  19. package/lib/SimpleBlindsAccessory.js +5 -5
  20. package/lib/SimpleDimmer2Accessory.js +1 -1
  21. package/lib/SimpleDimmerAccessory.js +10 -6
  22. package/lib/SimpleGarageDoorAccessory.js +122 -26
  23. package/lib/SimpleHeaterAccessory.js +4 -4
  24. package/lib/SimpleLightAccessory.js +1 -1
  25. package/lib/SwitchAccessory.js +3 -2
  26. package/lib/TWLightAccessory.js +2 -2
  27. package/lib/TuyaAccessory.js +75 -42
  28. package/lib/TuyaCloudApi.js +121 -8
  29. package/lib/TuyaCloudDevice.js +434 -31
  30. package/lib/TuyaCloudMessaging.js +34 -41
  31. package/lib/TuyaDevice.js +269 -0
  32. package/lib/TuyaDiscovery.js +25 -9
  33. package/lib/ValveAccessory.js +16 -12
  34. package/lib/VerticalBlindsWithTilt.js +27 -25
  35. package/lib/WledDimmerAccessory.js +46 -81
  36. package/package.json +5 -6
  37. package/scripts/cleanup-pr-tags.js +122 -0
  38. package/test/BaseAccessory.test.js +94 -15
  39. package/test/IrrigationSystemAccessory.test.js +293 -67
  40. package/test/MultiOutletAccessory.test.js +18 -3
  41. package/test/RGBTWLightAccessory.test.js +3 -3
  42. package/test/SimpleFanLightAccessory.test.js +3 -3
  43. package/test/SimpleGarageDoorAccessory.test.js +193 -19
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +110 -1
  46. package/test/TuyaCloudDevice.test.js +564 -2
  47. package/test/TuyaCloudMessaging.test.js +24 -5
  48. package/test/TuyaDevice.test.js +266 -0
  49. package/test/getCategory.test.js +1 -0
  50. package/test/index.test.js +271 -0
  51. package/test/support/mocks.js +13 -0
  52. package/wiki/Supported-Device-Types.md +64 -34
  53. package/wiki/Tuya-Cloud-Setup.md +31 -108
@@ -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) return this.log.info('Insufficient details to initialize:', props);
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
- this.log.info(`Socket had a problem and will reconnect to ${this.context.name} (${err && err.code || err})`);
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.info('Slowing down retry attempts; if you see this happening often, it could mean some sort of incompatibility.');
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.info('Slowing down retry attempts; if you see this happening often, it could mean some sort of incompatibility.');
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
- this.log.info('Disconnected from', this.context.name);
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.info('Message from', this.context.name + ':', data);
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.info(`Odd message from ${this.context.name} with command ${cmd}:`, data);
333
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
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.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
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.info(`Malformed update from ${this.context.name} with command ${cmd}:`, data);
356
- this.log.info(`Raw update from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
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.info(`Odd message from ${this.context.name} with command ${cmd}:`, data);
366
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
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.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
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.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
421
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
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.info(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
434
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
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.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
441
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
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.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
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.info(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
555
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
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.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
562
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
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.info(`Failed to decrypt message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, ex);
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.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
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.info(`Malformed message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
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.info(`Odd message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
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
- if (result !== true) this.log.info(" Result", result);
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.info('Fake update:', JSON.stringify(dps));
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
  });
@@ -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
- // Issue one or more commands: [{code, value}, …].
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
- resolve(JSON.parse(data));
381
+ parsed = JSON.parse(data);
275
382
  } catch (ex) {
276
- reject(new Error(`Tuya Cloud returned non-JSON (HTTP ${resp.statusCode}): ${('' + data).slice(0, 200)}`));
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', reject);
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();