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.
- 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 +114 -25
- 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 +146 -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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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']
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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
|
-
//
|
|
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
|
|
525
|
+
// And nothing more after the re-sends.
|
|
431
526
|
jest.advanceTimersByTime(10_000);
|
|
432
|
-
expect(device.update).toHaveBeenCalledTimes(
|
|
527
|
+
expect(device.update).toHaveBeenCalledTimes(4);
|
|
433
528
|
expect(instance.partialStopTimer).toBeNull();
|
|
434
529
|
});
|
|
435
530
|
|
|
436
|
-
test('
|
|
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
|
-
|
|
612
|
-
|
|
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
|
+
});
|