homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-dev.51

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 +114 -25
  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 +146 -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
@@ -52,6 +52,8 @@ function makeSimpleGarage(initialContext = {}) {
52
52
  instance.stopBeforeCloseMs = 1500;
53
53
  instance.partialStopTimer = null;
54
54
  instance.pendingCloseTimer = null;
55
+ instance.partialPending = false;
56
+ instance.partialGeneration = 0;
55
57
  instance.currentDoorState = CDS.CLOSED;
56
58
  instance.characteristicCurrentDoorState = {
57
59
  value: CDS.CLOSED,
@@ -66,6 +68,7 @@ function makeSimpleGarage(initialContext = {}) {
66
68
  updateValue: jest.fn().mockImplementation(function(v) { this.value = v; return this; }),
67
69
  };
68
70
  accessory.context.cachedTargetDoorState = TDS.CLOSED;
71
+ instance._committedTarget = TDS.CLOSED;
69
72
 
70
73
  // Mirror the persistent change listener registered in production.
71
74
  device.on('change', changes => instance._onDeviceChange(changes));
@@ -224,9 +227,19 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
224
227
  beforeEach(() => jest.useFakeTimers());
225
228
  afterEach(() => jest.useRealTimers());
226
229
 
230
+ // A close is only ever issued from an open gate, so the committed target
231
+ // (cachedTargetDoorState) starts OPEN — the state HomeKit shows before the
232
+ // tap. Without it the very first close would match the helper's default
233
+ // committed target and be (correctly) treated as a redundant repeat.
234
+ function openGate(instance, device, state = STATE_OPENING_OR_OPEN) {
235
+ if (state !== undefined) device.state['105'] = state;
236
+ instance.accessory.context.cachedTargetDoorState = TDS.OPEN;
237
+ instance._committedTarget = TDS.OPEN;
238
+ }
239
+
227
240
  test('CLOSE fires immediately when the gate is already stopped (state 11)', () => {
228
241
  const { instance, device, accessory } = makeSimpleGarage();
229
- device.state['105'] = STATE_STOPPED;
242
+ openGate(instance, device, STATE_STOPPED);
230
243
 
231
244
  instance.setTargetDoorState(TDS.CLOSED);
232
245
 
@@ -240,7 +253,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
240
253
  test('CLOSE while opening (state 12) fires stop, then close after stopBeforeCloseMs', () => {
241
254
  const { instance, device } = makeSimpleGarage();
242
255
  instance.stopBeforeCloseMs = 1500;
243
- device.state['105'] = STATE_OPENING_OR_OPEN;
256
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
244
257
 
245
258
  instance.setTargetDoorState(TDS.CLOSED);
246
259
 
@@ -257,10 +270,10 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
257
270
  expect(instance.pendingCloseTimer).toBeNull();
258
271
  });
259
272
 
260
- test('CLOSE while closing (state 13) also uses stop-before-close', () => {
273
+ test('CLOSE that reads state 13 still uses stop-before-close (13 is not "stopped")', () => {
261
274
  const { instance, device } = makeSimpleGarage();
262
275
  instance.stopBeforeCloseMs = 1500;
263
- device.state['105'] = STATE_CLOSING_OR_CLOSED;
276
+ openGate(instance, device, STATE_CLOSING_OR_CLOSED);
264
277
 
265
278
  instance.setTargetDoorState(TDS.CLOSED);
266
279
  expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
@@ -272,7 +285,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
272
285
  test('CLOSE with no reported state yet uses stop-before-close', () => {
273
286
  const { instance, device } = makeSimpleGarage();
274
287
  instance.stopBeforeCloseMs = 1500;
275
- // device.state['105'] intentionally left undefined
288
+ openGate(instance, device, undefined); // device.state['105'] left undefined
276
289
 
277
290
  instance.setTargetDoorState(TDS.CLOSED);
278
291
  expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
@@ -284,7 +297,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
284
297
  test('A duplicate CLOSE during the wait does not restart or double-fire', () => {
285
298
  const { instance, device } = makeSimpleGarage();
286
299
  instance.stopBeforeCloseMs = 1500;
287
- device.state['105'] = STATE_OPENING_OR_OPEN;
300
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
288
301
 
289
302
  instance.setTargetDoorState(TDS.CLOSED);
290
303
  expect(device.update).toHaveBeenCalledTimes(1); // stop
@@ -298,10 +311,71 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
298
311
  expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
299
312
  });
300
313
 
314
+ test('A repeat close once committed is swallowed — no second stop-before-close into the moving gate', () => {
315
+ // HomeKit re-sends the same target seconds after a tap (a second
316
+ // controller echoing it, or its own retry). Acting on it fired another
317
+ // stop-before-close that halted the closing gate and restarted it — the
318
+ // reported stutter. Level-triggered dispatch makes the repeat a no-op.
319
+ const { instance, device } = makeSimpleGarage();
320
+ instance.stopBeforeCloseMs = 1500;
321
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
322
+
323
+ instance.setTargetDoorState(TDS.CLOSED);
324
+ jest.advanceTimersByTime(1500);
325
+ expect(device.update).toHaveBeenCalledTimes(2); // stop + close
326
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
327
+
328
+ // The gate is now committed closed and still travelling (reports 13).
329
+ emitState(device, STATE_CLOSING_OR_CLOSED);
330
+ jest.advanceTimersByTime(3000);
331
+ instance.setTargetDoorState(TDS.CLOSED); // HomeKit's repeat close
332
+ expect(device.update).toHaveBeenCalledTimes(2); // swallowed — no second stop/close
333
+ });
334
+
335
+ test('A stopped (11) report after the close does not re-enable the repeat (LAN stutter)', () => {
336
+ // Over the LAN the stop pulse makes the controller report 11 (=OPEN) just
337
+ // after the close has gone out — outside the stop-before-close window. That
338
+ // 11 must NOT move the committed target back to OPEN, or HomeKit's repeat
339
+ // close would fire a second stop-before-close into the already-closing gate.
340
+ const { instance, device } = makeSimpleGarage();
341
+ instance.stopBeforeCloseMs = 1500;
342
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
343
+
344
+ instance.setTargetDoorState(TDS.CLOSED);
345
+ jest.advanceTimersByTime(1500);
346
+ expect(device.update).toHaveBeenCalledTimes(2); // stop + close
347
+
348
+ emitState(device, STATE_STOPPED); // the stop's late 11 report
349
+ expect(instance._committedTarget).toBe(TDS.CLOSED); // not bounced to OPEN
350
+ instance.setTargetDoorState(TDS.CLOSED); // HomeKit's repeat
351
+ expect(device.update).toHaveBeenCalledTimes(2); // swallowed
352
+ });
353
+
354
+ test('A close runs again once the gate is reported open (committed target tracks reports)', () => {
355
+ const { instance, device } = makeSimpleGarage();
356
+ instance.stopBeforeCloseMs = 1500;
357
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
358
+
359
+ instance.setTargetDoorState(TDS.CLOSED);
360
+ jest.advanceTimersByTime(1500);
361
+ expect(device.update).toHaveBeenCalledTimes(2); // stop + close
362
+ emitState(device, STATE_CLOSING_OR_CLOSED); // committed = closed
363
+
364
+ instance.setTargetDoorState(TDS.CLOSED); // repeat — swallowed
365
+ expect(device.update).toHaveBeenCalledTimes(2);
366
+
367
+ // The gate is opened again (here, externally) — a close is a real
368
+ // transition once more and runs.
369
+ emitState(device, STATE_OPENING_OR_OPEN); // committed = open
370
+ instance.setTargetDoorState(TDS.CLOSED);
371
+ expect(device.update).toHaveBeenCalledTimes(3);
372
+ expect(device.update).toHaveBeenNthCalledWith(3, { '103': true });
373
+ });
374
+
301
375
  test('OPEN during the wait cancels the pending close and opens instead', () => {
302
376
  const { instance, device } = makeSimpleGarage();
303
377
  instance.stopBeforeCloseMs = 1500;
304
- device.state['105'] = STATE_OPENING_OR_OPEN;
378
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
305
379
 
306
380
  instance.setTargetDoorState(TDS.CLOSED);
307
381
  expect(device.update).toHaveBeenNthCalledWith(1, { '103': true }); // stop
@@ -319,7 +393,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
319
393
  test('Status reports are ignored during the stop-before-close window', () => {
320
394
  const { instance, device, accessory } = makeSimpleGarage();
321
395
  instance.stopBeforeCloseMs = 1500;
322
- device.state['105'] = STATE_OPENING_OR_OPEN;
396
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
323
397
  instance.currentDoorState = CDS.OPEN;
324
398
  instance.characteristicCurrentDoorState.value = CDS.OPEN;
325
399
 
@@ -343,7 +417,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
343
417
  test('stopBeforeCloseMs is configurable', () => {
344
418
  const { instance, device } = makeSimpleGarage();
345
419
  instance.stopBeforeCloseMs = 300;
346
- device.state['105'] = STATE_OPENING_OR_OPEN;
420
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
347
421
 
348
422
  instance.setTargetDoorState(TDS.CLOSED);
349
423
  expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
@@ -359,7 +433,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
359
433
  instance.dpClose = '2';
360
434
  instance.dpStop = '4';
361
435
  instance.stopBeforeCloseMs = 1000;
362
- device.state['105'] = STATE_OPENING_OR_OPEN;
436
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
363
437
 
364
438
  instance.setTargetDoorState(TDS.CLOSED);
365
439
  expect(device.update).toHaveBeenNthCalledWith(1, { '4': true });
@@ -373,11 +447,13 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
373
447
  // Disconnect handling
374
448
  // ---------------------------------------------------------------------------
375
449
  describe('SimpleGarageDoorAccessory — disconnected', () => {
376
- test('Skips writes when the device is disconnected', () => {
450
+ test('Surfaces a No Response error and writes nothing when disconnected', () => {
377
451
  const { instance, device } = makeSimpleGarage();
378
452
  device.connected = false;
379
453
 
380
- instance.setTargetDoorState(TDS.OPEN);
454
+ // A tap on an unreachable gate must fail in HomeKit instead of being
455
+ // silently dropped while HomeKit believes the open/close succeeded.
456
+ expect(() => instance.setTargetDoorState(TDS.OPEN)).toThrow(HAP.HapStatusError);
381
457
 
382
458
  expect(device.update).not.toHaveBeenCalled();
383
459
  });
@@ -407,35 +483,71 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
407
483
  beforeEach(() => jest.useFakeTimers());
408
484
  afterEach(() => jest.useRealTimers());
409
485
 
410
- test('Fires open, then fires stop after partialOpenMs', () => {
486
+ test('Stop is anchored to the gate reporting it is moving, not the button press', () => {
411
487
  const { instance, device } = makeSimpleGarage();
412
488
  instance.partialOpenMs = 2000;
489
+ // Gate starts closed, so the open command needs time to reach it and
490
+ // get the gate moving before a stop can do anything.
491
+ device.state['105'] = STATE_CLOSING_OR_CLOSED;
413
492
 
414
493
  instance._handlePartialOpen();
415
494
 
416
- // Open goes out immediately.
495
+ // Open goes out immediately, but the auto-stop is NOT armed yet.
417
496
  expect(device.update).toHaveBeenCalledTimes(1);
418
497
  expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
419
498
  expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
499
+ expect(instance.partialStopTimer).toBeNull();
500
+ expect(instance.partialPending).toBe(true);
501
+
502
+ // No amount of waiting fires a stop until the gate reports it's moving —
503
+ // this is what stops a too-early stop from being a dropped no-op.
504
+ jest.advanceTimersByTime(10_000);
505
+ expect(device.update).toHaveBeenCalledTimes(1);
506
+
507
+ // Controller reports it has started opening: now the countdown arms.
508
+ emitState(device, STATE_OPENING_OR_OPEN);
509
+ expect(instance.partialStopTimer).not.toBeNull();
510
+ expect(instance.partialPending).toBe(false);
420
511
 
421
- // Nothing happens until the timer elapses.
422
512
  jest.advanceTimersByTime(2000 - 1);
423
513
  expect(device.update).toHaveBeenCalledTimes(1);
424
514
 
425
- // Then a single stop is fired.
515
+ // The stop fires, and is re-sent a couple of times to survive a dropped
516
+ // write (the controller occasionally drops a lone command).
426
517
  jest.advanceTimersByTime(1);
427
518
  expect(device.update).toHaveBeenCalledTimes(2);
428
519
  expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
520
+ jest.advanceTimersByTime(300);
521
+ expect(device.update).toHaveBeenNthCalledWith(3, { '103': true });
522
+ jest.advanceTimersByTime(300);
523
+ expect(device.update).toHaveBeenNthCalledWith(4, { '103': true });
429
524
 
430
- // And nothing more after that.
525
+ // And nothing more after the re-sends.
431
526
  jest.advanceTimersByTime(10_000);
432
- expect(device.update).toHaveBeenCalledTimes(2);
527
+ expect(device.update).toHaveBeenCalledTimes(4);
433
528
  expect(instance.partialStopTimer).toBeNull();
434
529
  });
435
530
 
436
- test('Re-entrant press while armed is ignored (idempotent)', () => {
531
+ test('Arms straight away when the gate already reports open/opening', () => {
532
+ const { instance, device } = makeSimpleGarage();
533
+ instance.partialOpenMs = 2000;
534
+ device.state['105'] = STATE_OPENING_OR_OPEN;
535
+
536
+ instance._handlePartialOpen();
537
+
538
+ // Open fired and the countdown armed without waiting for a fresh report.
539
+ expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
540
+ expect(instance.partialStopTimer).not.toBeNull();
541
+ expect(instance.partialPending).toBe(false);
542
+
543
+ jest.advanceTimersByTime(2000);
544
+ expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
545
+ });
546
+
547
+ test('Re-entrant press while in progress is ignored (idempotent)', () => {
437
548
  const { instance, device } = makeSimpleGarage();
438
549
  instance.partialOpenMs = 2000;
550
+ device.state['105'] = STATE_OPENING_OR_OPEN; // arms immediately
439
551
 
440
552
  instance._handlePartialOpen();
441
553
  expect(device.update).toHaveBeenCalledTimes(1);
@@ -452,6 +564,20 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
452
564
  expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
453
565
  });
454
566
 
567
+ test('Re-entrant press while waiting for movement is ignored', () => {
568
+ const { instance, device } = makeSimpleGarage();
569
+ instance.partialOpenMs = 2000;
570
+ device.state['105'] = STATE_CLOSING_OR_CLOSED;
571
+
572
+ instance._handlePartialOpen();
573
+ expect(device.update).toHaveBeenCalledTimes(1); // open
574
+ expect(instance.partialPending).toBe(true);
575
+
576
+ // A second press before the gate reports moving must not fire another open.
577
+ instance._handlePartialOpen();
578
+ expect(device.update).toHaveBeenCalledTimes(1);
579
+ });
580
+
455
581
  test('A direct close cancels the partial auto-stop and runs stop-before-close', () => {
456
582
  const { instance, device } = makeSimpleGarage();
457
583
  instance.partialOpenMs = 2000;
@@ -526,6 +652,7 @@ describe('SimpleGarageDoorAccessory — force switches', () => {
526
652
  test('Force Close fires the close action (immediately when already stopped)', () => {
527
653
  const { instance, device, accessory } = makeSimpleGarage();
528
654
  device.state['105'] = STATE_STOPPED;
655
+ instance._committedTarget = TDS.OPEN; // gate open → close is a real transition
529
656
 
530
657
  instance.setTargetDoorState(TDS.CLOSED);
531
658
 
@@ -155,6 +155,78 @@ describe('protocol version normalization', () => {
155
155
  });
156
156
  });
157
157
 
158
+ // A wrong-length local key made crypto.createCipheriv throw "Invalid key length"
159
+ // from inside a socket callback (see _send_3_3), which goes unhandled and crashes
160
+ // Homebridge into a restart loop. An out-of-spec key must be refused clearly,
161
+ // up front, without ever reaching the cipher or taking the process down.
162
+ describe('invalid local key handling', () => {
163
+ describe('isValidLocalKey', () => {
164
+ test.each([
165
+ ['0123456789abcdef', true], // 16 ASCII chars
166
+ ['short', false],
167
+ ['0123456789abcdefXX', false], // 18 chars
168
+ ['', false],
169
+ ['012345678901234é', false], // 16 chars but 17 UTF-8 bytes
170
+ [Buffer.alloc(16), true],
171
+ [Buffer.alloc(8), false],
172
+ [null, false],
173
+ [undefined, false],
174
+ [12345678901234567, false], // not a string/Buffer
175
+ ])('isValidLocalKey(%p) === %p', (key, expected) => {
176
+ expect(TuyaAccessory.isValidLocalKey(key)).toBe(expected);
177
+ });
178
+ });
179
+
180
+ test('a wrong-length key is flagged and logged without throwing', () => {
181
+ let device;
182
+ expect(() => { device = makeDevice('3.3', {key: 'too-short'}); }).not.toThrow();
183
+ expect(device._invalidKey).toBe(true);
184
+ expect(device.log.error).toHaveBeenCalledWith(expect.stringContaining('16 characters'));
185
+ const logged = device.log.error.mock.calls.flat().join(' ');
186
+ expect(logged).toContain('Protocol Test Device');
187
+ });
188
+
189
+ test('a valid 16-character key is accepted and logs no error', () => {
190
+ const device = makeDevice('3.3');
191
+ expect(device._invalidKey).toBe(false);
192
+ expect(device.log.error).not.toHaveBeenCalled();
193
+ });
194
+
195
+ test('_connect() opens no socket for an invalid-key device', () => {
196
+ const device = makeDevice('3.5', {key: 'bad'});
197
+ device._connect();
198
+ expect(device._socket).toBeUndefined();
199
+ expect(device.connected).toBe(false);
200
+ });
201
+
202
+ // The exact path that used to crash: a connected device whose update() flows
203
+ // into _send -> _send_3_x -> createCipheriv. It must short-circuit to false.
204
+ test.each(['3.1', '3.3', '3.4', '3.5'])(
205
+ 'update() never reaches the cipher for an invalid-key %s device, even when connected',
206
+ version => {
207
+ const device = makeDevice(version, {key: 'nope'});
208
+ const written = attachSocketStub(device); // also forces connected = true
209
+
210
+ let result;
211
+ expect(() => { result = device.update({1: true}); }).not.toThrow();
212
+ expect(result).toBe(false);
213
+ expect(written).toHaveLength(0);
214
+ }
215
+ );
216
+
217
+ test('a fake device without a key is never treated as invalid', () => {
218
+ const device = new TuyaAccessory({
219
+ id: 'bf1234567890abcdef12',
220
+ name: 'Fake Device',
221
+ fake: true,
222
+ connect: false,
223
+ log: makeLog(),
224
+ });
225
+ expect(device._invalidKey).toBe(false);
226
+ expect(device.log.error).not.toHaveBeenCalled();
227
+ });
228
+ });
229
+
158
230
  describe('message handler routing', () => {
159
231
  const HANDLERS = ['_msgHandler_3_1', '_msgHandler_3_3', '_msgHandler_3_4', '_msgHandler_3_5'];
160
232
 
@@ -607,9 +679,10 @@ describe('graceful disconnect handling', () => {
607
679
  jest.advanceTimersByTime(1000);
608
680
  expect(pingErrors).toHaveLength(0);
609
681
 
610
- // The log reflects a clean disconnect, never a ping failure.
611
- expect(device.log.info).toHaveBeenCalledWith('Disconnected from', device.context.name);
612
- const logged = device.log.info.mock.calls.flat().join(' ');
682
+ // The log reflects a clean disconnect, never a ping failure. (A device
683
+ // recycling its socket is routine, so the disconnect is logged at debug.)
684
+ expect(device.log.debug).toHaveBeenCalledWith('Disconnected from', device.context.name);
685
+ const logged = device.log.debug.mock.calls.flat().join(' ');
613
686
  expect(logged).not.toMatch(/ERR_PING_TIMED_OUT/);
614
687
  });
615
688
 
@@ -138,6 +138,31 @@ 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
+
158
+ test('getDeviceInfo returns the device record (with online status)', async () => {
159
+ const api = ready();
160
+ let path;
161
+ api._httpsRequest = async (method, p) => { path = p; return {success: true, result: {id: 'dev', online: false, name: 'X'}}; };
162
+ await expect(api.getDeviceInfo('dev')).resolves.toEqual({id: 'dev', online: false, name: 'X'});
163
+ expect(path).toBe('/v1.0/devices/dev');
164
+ });
165
+
141
166
  test('sendCommands posts the commands and reports success', async () => {
142
167
  const api = ready();
143
168
  let captured;
@@ -148,7 +173,7 @@ describe('TuyaCloudApi — endpoints', () => {
148
173
  const ok = await api.sendCommands('dev', [{code: 'switch_1', value: true}]);
149
174
  expect(ok).toBe(true);
150
175
  expect(captured.method).toBe('POST');
151
- expect(captured.path).toBe('/v1.0/devices/dev/commands');
176
+ expect(captured.path).toBe('/v1.0/iot-03/devices/dev/commands');
152
177
  expect(captured.body).toEqual({commands: [{code: 'switch_1', value: true}]});
153
178
  });
154
179
 
@@ -159,6 +184,35 @@ describe('TuyaCloudApi — endpoints', () => {
159
184
  expect(api._httpsRequest).not.toHaveBeenCalled();
160
185
  });
161
186
 
187
+ test('getStatus reads from the iot-03 device endpoint', async () => {
188
+ const api = ready();
189
+ let path;
190
+ api._httpsRequest = async (method, p) => { path = p; return {success: true, result: []}; };
191
+ await api.getStatus('dev');
192
+ expect(path).toBe('/v1.0/iot-03/devices/dev/status');
193
+ });
194
+
195
+ test('sendProperties issues thing-model properties as a JSON-string body', async () => {
196
+ const api = ready();
197
+ let captured;
198
+ api._httpsRequest = async (method, path, headers, bodyStr) => {
199
+ captured = {method, path, body: JSON.parse(bodyStr)};
200
+ return {success: true, result: true};
201
+ };
202
+ const ok = await api.sendProperties('dev', [{code: 'wfh_close', value: true}]);
203
+ expect(ok).toBe(true);
204
+ expect(captured.method).toBe('POST');
205
+ expect(captured.path).toBe('/v2.0/cloud/thing/dev/shadow/properties/issue');
206
+ expect(captured.body).toEqual({properties: JSON.stringify({wfh_close: true})});
207
+ });
208
+
209
+ test('sendProperties short-circuits on an empty command list', async () => {
210
+ const api = ready();
211
+ api._httpsRequest = jest.fn();
212
+ await expect(api.sendProperties('dev', [])).resolves.toBe(true);
213
+ expect(api._httpsRequest).not.toHaveBeenCalled();
214
+ });
215
+
162
216
  test('getMqttConfig posts the expected body and returns the broker config', async () => {
163
217
  const api = ready();
164
218
  api.uid = 'UID';
@@ -194,3 +248,58 @@ describe('TuyaCloudApi — endpoints', () => {
194
248
  await expect(api.request('GET', '/x')).rejects.toThrow(/no permissions/);
195
249
  });
196
250
  });
251
+
252
+ describe('TuyaCloudApi — HTTP trace (debug.logCloudHttp)', () => {
253
+ const makeLog = () => ({info: () => {}, warn: () => {}, error: () => {}, debug: jest.fn()});
254
+
255
+ test('logHttp is off unless explicitly enabled', () => {
256
+ expect(makeApi().logHttp).toBe(false);
257
+ expect(makeApi({logHttp: true}).logHttp).toBe(true);
258
+ });
259
+
260
+ test('redaction masks credentials but keeps device data', () => {
261
+ const api = makeApi();
262
+ const redacted = api._redactForLog({
263
+ client_id: 'aid1234567890', access_token: 'tok_abcdef123456', sign: 'SIGNATUREVALUE', t: '1700', nonce: 'n-1',
264
+ result: {access_token: 'inner-token-xyz', refresh_token: 'refresh-xyz', uid: 'uid-1234567', online: true},
265
+ commands: [{code: 'switch_1', value: true}]
266
+ });
267
+
268
+ expect(redacted.client_id).not.toBe('aid1234567890');
269
+ expect(redacted.access_token).not.toBe('tok_abcdef123456');
270
+ expect(redacted.sign).not.toBe('SIGNATUREVALUE');
271
+ expect(redacted.result.access_token).not.toBe('inner-token-xyz');
272
+ expect(redacted.result.refresh_token).not.toBe('refresh-xyz');
273
+ expect(redacted.result.uid).not.toBe('uid-1234567');
274
+ // Non-secret fields and the actual device data-points pass through intact.
275
+ expect(redacted.t).toBe('1700');
276
+ expect(redacted.nonce).toBe('n-1');
277
+ expect(redacted.result.online).toBe(true);
278
+ expect(redacted.commands).toEqual([{code: 'switch_1', value: true}]);
279
+ });
280
+
281
+ test('no trace is logged when the switch is off', () => {
282
+ const log = makeLog();
283
+ const api = makeApi({log});
284
+ api._traceHttp('POST', '/v1.0/devices/dev/commands', {sign: 'X'}, '{"commands":[]}', 200, {success: true});
285
+ expect(log.debug).not.toHaveBeenCalled();
286
+ });
287
+
288
+ test('a trace carries the request/response but never the raw token or signature', () => {
289
+ const log = makeLog();
290
+ const api = makeApi({log, logHttp: true});
291
+ const headers = {client_id: 'aid', sign: 'SIGN_SECRET_VALUE', access_token: 'TOKEN_SECRET_VALUE', t: '1', nonce: 'n', 'Content-Type': 'application/json'};
292
+ const body = JSON.stringify({commands: [{code: 'wfh_open', value: true}]});
293
+
294
+ api._traceHttp('POST', '/v1.0/devices/dev/commands', headers, body, 200, {success: false, code: 2008, msg: 'command or value not support'});
295
+
296
+ expect(log.debug).toHaveBeenCalledTimes(1);
297
+ const line = log.debug.mock.calls[0][0];
298
+ expect(line).toContain('POST /v1.0/devices/dev/commands');
299
+ expect(line).toContain('wfh_open'); // the attempted command is visible
300
+ expect(line).toContain('2008'); // and the cloud's verdict
301
+ expect(line).toContain('command or value not support');
302
+ expect(line).not.toContain('TOKEN_SECRET_VALUE'); // …but secrets are not
303
+ expect(line).not.toContain('SIGN_SECRET_VALUE');
304
+ });
305
+ });