homebridge-tuya-plus 3.14.0-dev.19 → 3.14.0-dev.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Changelog.md +4 -3
- package/Readme.MD +4 -6
- package/config.schema.json +4 -13
- package/index.js +349 -359
- package/lib/IrrigationSystemAccessory.js +12 -23
- package/lib/SimpleGarageDoorAccessory.js +49 -8
- package/lib/TuyaCloudApi.js +21 -0
- package/lib/TuyaCloudDevice.js +86 -15
- package/lib/TuyaDevice.js +266 -0
- package/package.json +2 -2
- package/test/IrrigationSystemAccessory.test.js +44 -49
- package/test/SimpleGarageDoorAccessory.test.js +59 -7
- package/test/TuyaCloudApi.test.js +17 -0
- package/test/TuyaCloudDevice.test.js +49 -0
- package/test/TuyaDevice.test.js +252 -0
- package/test/index.test.js +141 -0
- package/wiki/Supported-Device-Types.md +5 -4
- package/wiki/Tuya-Cloud-Setup.md +15 -29
|
@@ -473,46 +473,19 @@ describe('IrrigationSystemAccessory — charging state', () => {
|
|
|
473
473
|
});
|
|
474
474
|
});
|
|
475
475
|
|
|
476
|
-
describe('IrrigationSystemAccessory —
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
476
|
+
describe('IrrigationSystemAccessory — data-points addressed by code', () => {
|
|
477
|
+
// The accessory is transport-agnostic: a data-point may be a numeric id or a
|
|
478
|
+
// Tuya "code". The LAN+cloud TuyaDevice keeps state dual-keyed and translates
|
|
479
|
+
// writes, so irrigation has no cloud-specific logic — it just uses the dp
|
|
480
|
+
// strings it's given. A device reached over the cloud commonly addresses its
|
|
481
|
+
// zones as switch_1.. and battery as battery_percentage.
|
|
482
482
|
beforeEach(() => jest.useFakeTimers());
|
|
483
483
|
afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
|
|
484
484
|
|
|
485
|
-
test('
|
|
486
|
-
const { accessory } = makeHarness(cloudState(), cloudCtx);
|
|
487
|
-
['switch_1', 'switch_2', 'switch_3', 'switch_4'].forEach(code => {
|
|
488
|
-
expect(valve(accessory, code)).toBeTruthy();
|
|
489
|
-
});
|
|
490
|
-
expect(valve(accessory, '1')).toBeFalsy();
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
test('turning a zone on writes the code/value to the device', () => {
|
|
494
|
-
const { accessory, device } = makeHarness(cloudState(), cloudCtx);
|
|
495
|
-
valve(accessory, 'switch_1').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
496
|
-
jest.advanceTimersByTime(500);
|
|
497
|
-
expect(device.update).toHaveBeenCalledWith({ switch_1: true });
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
test('battery is read from the battery_percentage code', () => {
|
|
501
|
-
const { accessory } = makeHarness(cloudState(), cloudCtx);
|
|
502
|
-
const battery = accessory.getService(Service.Battery);
|
|
503
|
-
expect(battery.getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
test('a realtime change keyed by code is reflected in HomeKit', () => {
|
|
507
|
-
const { accessory, device } = makeHarness(cloudState(), cloudCtx);
|
|
508
|
-
device.emitChange({ switch_2: true });
|
|
509
|
-
expect(valve(accessory, 'switch_2').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
test('an explicit valve list may use custom codes', () => {
|
|
485
|
+
test('an explicit valve list may use string codes', () => {
|
|
513
486
|
const { accessory, device } = makeHarness(
|
|
514
487
|
{ zone_a: false, zone_b: false, battery_percentage: 50 },
|
|
515
|
-
{
|
|
488
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], dpBattery: 'battery_percentage' }
|
|
516
489
|
);
|
|
517
490
|
expect(valve(accessory, 'zone_a')).toBeTruthy();
|
|
518
491
|
valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
@@ -520,35 +493,57 @@ describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)',
|
|
|
520
493
|
expect(device.update).toHaveBeenCalledWith({ zone_b: true });
|
|
521
494
|
});
|
|
522
495
|
|
|
523
|
-
test('
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
496
|
+
test('battery can be read from a code data-point', () => {
|
|
497
|
+
const { accessory } = makeHarness(
|
|
498
|
+
{ zone_a: false, battery_percentage: 99 },
|
|
499
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }], dpBattery: 'battery_percentage' }
|
|
500
|
+
);
|
|
501
|
+
expect(accessory.getService(Service.Battery).getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('a device-side change keyed by code is reflected in HomeKit', () => {
|
|
505
|
+
const { accessory, device } = makeHarness(
|
|
506
|
+
{ zone_a: false, zone_b: false },
|
|
507
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true }
|
|
508
|
+
);
|
|
509
|
+
device.emitChange({ zone_b: true });
|
|
510
|
+
expect(valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('turning a zone off still writes false when the device never echoed the "on"', () => {
|
|
514
|
+
// A device (notably over the cloud) may not optimistically advance `state`;
|
|
515
|
+
// it only moves when the device confirms. A follow-up "off" must STILL be
|
|
516
|
+
// sent — otherwise the valve stays open while HomeKit shows it closed (the
|
|
528
517
|
// exact "can turn on but not off" report).
|
|
529
|
-
const { accessory, device } = makeHarness(
|
|
518
|
+
const { accessory, device } = makeHarness(
|
|
519
|
+
{ zone_a: false },
|
|
520
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }], noBattery: true, defaultDuration: 0 }
|
|
521
|
+
);
|
|
530
522
|
device.update.mockImplementation(() => true); // writes never touch state
|
|
531
|
-
const v = valve(accessory, '
|
|
523
|
+
const v = valve(accessory, 'zone_a');
|
|
532
524
|
|
|
533
525
|
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
534
526
|
jest.advanceTimersByTime(500);
|
|
535
|
-
expect(device.update).toHaveBeenLastCalledWith({
|
|
527
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: true });
|
|
536
528
|
|
|
537
529
|
v.getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
538
530
|
jest.advanceTimersByTime(500);
|
|
539
|
-
expect(device.update).toHaveBeenLastCalledWith({
|
|
531
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: false });
|
|
540
532
|
});
|
|
541
533
|
|
|
542
|
-
test('master OFF closes zones the
|
|
543
|
-
const { accessory, device } = makeHarness(
|
|
534
|
+
test('master OFF closes zones the device has not echoed as open', () => {
|
|
535
|
+
const { accessory, device } = makeHarness(
|
|
536
|
+
{ zone_a: false, zone_b: false },
|
|
537
|
+
{ valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true, defaultDuration: 0 }
|
|
538
|
+
);
|
|
544
539
|
device.update.mockImplementation(() => true); // writes never touch state
|
|
545
540
|
|
|
546
|
-
valve(accessory, '
|
|
547
|
-
valve(accessory, '
|
|
541
|
+
valve(accessory, 'zone_a').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
542
|
+
valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
548
543
|
jest.advanceTimersByTime(500);
|
|
549
544
|
|
|
550
545
|
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
551
546
|
jest.advanceTimersByTime(500);
|
|
552
|
-
expect(device.update).toHaveBeenLastCalledWith({
|
|
547
|
+
expect(device.update).toHaveBeenLastCalledWith({ zone_a: false, zone_b: false });
|
|
553
548
|
});
|
|
554
549
|
});
|
|
@@ -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,
|
|
@@ -409,35 +411,71 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
|
|
|
409
411
|
beforeEach(() => jest.useFakeTimers());
|
|
410
412
|
afterEach(() => jest.useRealTimers());
|
|
411
413
|
|
|
412
|
-
test('
|
|
414
|
+
test('Stop is anchored to the gate reporting it is moving, not the button press', () => {
|
|
413
415
|
const { instance, device } = makeSimpleGarage();
|
|
414
416
|
instance.partialOpenMs = 2000;
|
|
417
|
+
// Gate starts closed, so the open command needs time to reach it and
|
|
418
|
+
// get the gate moving before a stop can do anything.
|
|
419
|
+
device.state['105'] = STATE_CLOSING_OR_CLOSED;
|
|
415
420
|
|
|
416
421
|
instance._handlePartialOpen();
|
|
417
422
|
|
|
418
|
-
// Open goes out immediately.
|
|
423
|
+
// Open goes out immediately, but the auto-stop is NOT armed yet.
|
|
419
424
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
420
425
|
expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
|
|
421
426
|
expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
|
|
427
|
+
expect(instance.partialStopTimer).toBeNull();
|
|
428
|
+
expect(instance.partialPending).toBe(true);
|
|
429
|
+
|
|
430
|
+
// No amount of waiting fires a stop until the gate reports it's moving —
|
|
431
|
+
// this is what stops a too-early stop from being a dropped no-op.
|
|
432
|
+
jest.advanceTimersByTime(10_000);
|
|
433
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
434
|
+
|
|
435
|
+
// Controller reports it has started opening: now the countdown arms.
|
|
436
|
+
emitState(device, STATE_OPENING_OR_OPEN);
|
|
437
|
+
expect(instance.partialStopTimer).not.toBeNull();
|
|
438
|
+
expect(instance.partialPending).toBe(false);
|
|
422
439
|
|
|
423
|
-
// Nothing happens until the timer elapses.
|
|
424
440
|
jest.advanceTimersByTime(2000 - 1);
|
|
425
441
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
426
442
|
|
|
427
|
-
//
|
|
443
|
+
// The stop fires, and is re-sent a couple of times to survive a dropped
|
|
444
|
+
// write (the controller occasionally drops a lone command).
|
|
428
445
|
jest.advanceTimersByTime(1);
|
|
429
446
|
expect(device.update).toHaveBeenCalledTimes(2);
|
|
430
447
|
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
|
|
448
|
+
jest.advanceTimersByTime(300);
|
|
449
|
+
expect(device.update).toHaveBeenNthCalledWith(3, { '103': true });
|
|
450
|
+
jest.advanceTimersByTime(300);
|
|
451
|
+
expect(device.update).toHaveBeenNthCalledWith(4, { '103': true });
|
|
431
452
|
|
|
432
|
-
// And nothing more after
|
|
453
|
+
// And nothing more after the re-sends.
|
|
433
454
|
jest.advanceTimersByTime(10_000);
|
|
434
|
-
expect(device.update).toHaveBeenCalledTimes(
|
|
455
|
+
expect(device.update).toHaveBeenCalledTimes(4);
|
|
435
456
|
expect(instance.partialStopTimer).toBeNull();
|
|
436
457
|
});
|
|
437
458
|
|
|
438
|
-
test('
|
|
459
|
+
test('Arms straight away when the gate already reports open/opening', () => {
|
|
439
460
|
const { instance, device } = makeSimpleGarage();
|
|
440
461
|
instance.partialOpenMs = 2000;
|
|
462
|
+
device.state['105'] = STATE_OPENING_OR_OPEN;
|
|
463
|
+
|
|
464
|
+
instance._handlePartialOpen();
|
|
465
|
+
|
|
466
|
+
// Open fired and the countdown armed without waiting for a fresh report.
|
|
467
|
+
expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
|
|
468
|
+
expect(instance.partialStopTimer).not.toBeNull();
|
|
469
|
+
expect(instance.partialPending).toBe(false);
|
|
470
|
+
|
|
471
|
+
jest.advanceTimersByTime(2000);
|
|
472
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('Re-entrant press while in progress is ignored (idempotent)', () => {
|
|
476
|
+
const { instance, device } = makeSimpleGarage();
|
|
477
|
+
instance.partialOpenMs = 2000;
|
|
478
|
+
device.state['105'] = STATE_OPENING_OR_OPEN; // arms immediately
|
|
441
479
|
|
|
442
480
|
instance._handlePartialOpen();
|
|
443
481
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
@@ -454,6 +492,20 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
|
|
|
454
492
|
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
|
|
455
493
|
});
|
|
456
494
|
|
|
495
|
+
test('Re-entrant press while waiting for movement is ignored', () => {
|
|
496
|
+
const { instance, device } = makeSimpleGarage();
|
|
497
|
+
instance.partialOpenMs = 2000;
|
|
498
|
+
device.state['105'] = STATE_CLOSING_OR_CLOSED;
|
|
499
|
+
|
|
500
|
+
instance._handlePartialOpen();
|
|
501
|
+
expect(device.update).toHaveBeenCalledTimes(1); // open
|
|
502
|
+
expect(instance.partialPending).toBe(true);
|
|
503
|
+
|
|
504
|
+
// A second press before the gate reports moving must not fire another open.
|
|
505
|
+
instance._handlePartialOpen();
|
|
506
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
457
509
|
test('A direct close cancels the partial auto-stop and runs stop-before-close', () => {
|
|
458
510
|
const { instance, device } = makeSimpleGarage();
|
|
459
511
|
instance.partialOpenMs = 2000;
|
|
@@ -138,6 +138,23 @@ describe('TuyaCloudApi — endpoints', () => {
|
|
|
138
138
|
await expect(api.getStatus('dev')).resolves.toEqual([{code: 'switch_1', value: true}]);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test('getShadowProperties returns the properties array (code + dp_id)', async () => {
|
|
142
|
+
const api = ready();
|
|
143
|
+
let path;
|
|
144
|
+
api._httpsRequest = async (method, p) => {
|
|
145
|
+
path = p;
|
|
146
|
+
return {success: true, result: {properties: [{code: 'switch_led', dp_id: 20, value: true}]}};
|
|
147
|
+
};
|
|
148
|
+
await expect(api.getShadowProperties('dev')).resolves.toEqual([{code: 'switch_led', dp_id: 20, value: true}]);
|
|
149
|
+
expect(path).toBe('/v2.0/cloud/thing/dev/shadow/properties');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('getShadowProperties returns null (never throws) when the shadow API is unavailable', async () => {
|
|
153
|
+
const api = ready();
|
|
154
|
+
api._httpsRequest = async () => ({success: false, code: 1106, msg: 'permission deny'});
|
|
155
|
+
await expect(api.getShadowProperties('dev')).resolves.toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
141
158
|
test('getDeviceInfo returns the device record (with online status)', async () => {
|
|
142
159
|
const api = ready();
|
|
143
160
|
let path;
|
|
@@ -127,6 +127,55 @@ describe('TuyaCloudDevice', () => {
|
|
|
127
127
|
expect(dev.connected).toBe(false);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test('builds the code<->dp_id map and dual-keys state from the shadow', async () => {
|
|
131
|
+
const props = [{code: 'switch_1', dp_id: 1, value: false}, {code: 'battery_percentage', dp_id: 46, value: 99}];
|
|
132
|
+
const api = makeApi();
|
|
133
|
+
api.getShadowProperties = jest.fn().mockResolvedValue(props);
|
|
134
|
+
const dev = makeDevice(api);
|
|
135
|
+
await dev._connect();
|
|
136
|
+
|
|
137
|
+
expect(api.getShadowProperties).toHaveBeenCalledWith('dev1');
|
|
138
|
+
expect(dev.codeByDpId).toEqual({'1': 'switch_1', '46': 'battery_percentage'});
|
|
139
|
+
expect(dev.dpIdByCode).toEqual({'switch_1': '1', 'battery_percentage': '46'});
|
|
140
|
+
// state is addressable by BOTH the cloud code and the numeric LAN dp id
|
|
141
|
+
expect(dev.state).toEqual({switch_1: false, '1': false, battery_percentage: 99, '46': 99});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('update translates a numeric dp id to its cloud code', async () => {
|
|
145
|
+
const api = makeApi();
|
|
146
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
147
|
+
const dev = makeDevice(api);
|
|
148
|
+
await dev._connect();
|
|
149
|
+
|
|
150
|
+
dev.update({'1': true}); // a LAN-style numeric write
|
|
151
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('realtime code-only deltas are mirrored to numeric ids via the learned map', async () => {
|
|
155
|
+
const api = makeApi();
|
|
156
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
157
|
+
const dev = makeDevice(api);
|
|
158
|
+
await dev._connect();
|
|
159
|
+
|
|
160
|
+
const changes = [];
|
|
161
|
+
dev.on('change', c => changes.push(c));
|
|
162
|
+
dev._applyStatus([{code: 'switch_1', value: true}]); // code-only (as MQTT delivers)
|
|
163
|
+
expect(dev.state.switch_1).toBe(true);
|
|
164
|
+
expect(dev.state['1']).toBe(true);
|
|
165
|
+
expect(changes[0]).toEqual({switch_1: true, '1': true});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('falls back to /status (code-only) when the shadow is unavailable', async () => {
|
|
169
|
+
const api = makeApi();
|
|
170
|
+
api.getShadowProperties = jest.fn().mockResolvedValue(null);
|
|
171
|
+
const dev = makeDevice(api);
|
|
172
|
+
await dev._connect();
|
|
173
|
+
|
|
174
|
+
expect(dev.codeByDpId).toEqual({});
|
|
175
|
+
expect(dev.state).toEqual({switch_1: false, battery_percentage: 99}); // code-only, no numeric mirror
|
|
176
|
+
expect(api.getStatus).toHaveBeenCalledWith('dev1');
|
|
177
|
+
});
|
|
178
|
+
|
|
130
179
|
test('subscribes to realtime and re-reads state when the stream (re)connects', async () => {
|
|
131
180
|
const api = makeApi();
|
|
132
181
|
const messaging = Object.assign(new EventEmitter(), {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The LAN backend opens real TCP sockets; stub it so attachLan() can be tested
|
|
4
|
+
// without touching the network. Cloud backend stays real (it's inert with
|
|
5
|
+
// connect:false).
|
|
6
|
+
jest.mock('../lib/TuyaAccessory', () => jest.fn().mockImplementation(function(props) {
|
|
7
|
+
this.context = {...props};
|
|
8
|
+
this.connected = false;
|
|
9
|
+
this.state = {};
|
|
10
|
+
this.update = jest.fn().mockReturnValue(true);
|
|
11
|
+
this.on = jest.fn();
|
|
12
|
+
this._connect = jest.fn();
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const EventEmitter = require('events');
|
|
16
|
+
const TuyaDevice = require('../lib/TuyaDevice');
|
|
17
|
+
const TuyaCloudDevice = require('../lib/TuyaCloudDevice');
|
|
18
|
+
|
|
19
|
+
const log = {info: () => {}, warn: () => {}, error: () => {}, debug: () => {}};
|
|
20
|
+
|
|
21
|
+
// A minimal stand-in for either backend (TuyaAccessory / TuyaCloudDevice): an
|
|
22
|
+
// EventEmitter exposing the surface TuyaDevice routes through.
|
|
23
|
+
function fakeBackend(extra = {}) {
|
|
24
|
+
const b = new EventEmitter();
|
|
25
|
+
b.connected = false;
|
|
26
|
+
b.state = {};
|
|
27
|
+
b.update = jest.fn().mockReturnValue(true);
|
|
28
|
+
b.codeByDpId = {};
|
|
29
|
+
b.dpIdByCode = {};
|
|
30
|
+
b._connect = jest.fn();
|
|
31
|
+
return Object.assign(b, extra);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeDevice(props = {}) {
|
|
35
|
+
return new TuyaDevice({id: 'dev1', key: 'k', name: 'Lamp', type: 'switch', log, connect: false, ...props});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Attach fake backends to a device and wire their events (white-box, so the merge
|
|
39
|
+
// / fallback logic can be exercised without any real network).
|
|
40
|
+
function withBackends(dev, {lan, cloud} = {}) {
|
|
41
|
+
if (cloud) { dev.cloud = cloud; dev._wire(cloud, 'cloud'); }
|
|
42
|
+
if (lan) { dev.lan = lan; dev._wire(lan, 'lan'); }
|
|
43
|
+
return dev;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('TuyaDevice — connectivity', () => {
|
|
47
|
+
test('connected reflects either backend', () => {
|
|
48
|
+
const dev = makeDevice();
|
|
49
|
+
const lan = fakeBackend();
|
|
50
|
+
const cloud = fakeBackend();
|
|
51
|
+
withBackends(dev, {lan, cloud});
|
|
52
|
+
|
|
53
|
+
expect(dev.connected).toBe(false);
|
|
54
|
+
lan.connected = true;
|
|
55
|
+
expect(dev.connected).toBe(true);
|
|
56
|
+
lan.connected = false;
|
|
57
|
+
cloud.connected = true;
|
|
58
|
+
expect(dev.connected).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("'connect' is emitted once, on the first backend to connect", () => {
|
|
62
|
+
const dev = makeDevice();
|
|
63
|
+
const lan = fakeBackend();
|
|
64
|
+
const cloud = fakeBackend();
|
|
65
|
+
withBackends(dev, {lan, cloud});
|
|
66
|
+
|
|
67
|
+
let connects = 0;
|
|
68
|
+
dev.on('connect', () => connects++);
|
|
69
|
+
lan.emit('connect');
|
|
70
|
+
cloud.emit('connect');
|
|
71
|
+
expect(connects).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('TuyaDevice — pure LAN (no cloud) preserves legacy behaviour', () => {
|
|
76
|
+
test('update returns the synchronous boolean from the LAN backend', () => {
|
|
77
|
+
const dev = makeDevice();
|
|
78
|
+
const lan = fakeBackend({connected: true});
|
|
79
|
+
withBackends(dev, {lan});
|
|
80
|
+
|
|
81
|
+
lan.update.mockReturnValue(true);
|
|
82
|
+
expect(dev.update({'1': false})).toBe(true);
|
|
83
|
+
expect(lan.update).toHaveBeenCalledWith({'1': false});
|
|
84
|
+
|
|
85
|
+
lan.update.mockReturnValue(false);
|
|
86
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('update is false when there is no transport at all', () => {
|
|
90
|
+
const dev = makeDevice();
|
|
91
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('TuyaDevice — state merge & registration', () => {
|
|
96
|
+
test('first change drives registration and exposes the merged state', () => {
|
|
97
|
+
const dev = makeDevice();
|
|
98
|
+
const lan = fakeBackend({connected: true, state: {'1': true, '2': 50}});
|
|
99
|
+
withBackends(dev, {lan});
|
|
100
|
+
|
|
101
|
+
const seen = [];
|
|
102
|
+
dev.on('change', (changes, state) => seen.push({changes, state}));
|
|
103
|
+
lan.emit('change');
|
|
104
|
+
|
|
105
|
+
expect(seen).toHaveLength(1);
|
|
106
|
+
expect(seen[0].state).toEqual({'1': true, '2': 50});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('LAN wins over the cloud while connected; cloud-only DPs are kept; LAN is mirrored to codes', () => {
|
|
110
|
+
const dev = makeDevice();
|
|
111
|
+
const lan = fakeBackend({connected: true, state: {'1': true}});
|
|
112
|
+
const cloud = fakeBackend({
|
|
113
|
+
connected: true,
|
|
114
|
+
state: {'switch_led': false, '1': false, 'battery_percentage': 80},
|
|
115
|
+
codeByDpId: {'1': 'switch_led'},
|
|
116
|
+
dpIdByCode: {'switch_led': '1'}
|
|
117
|
+
});
|
|
118
|
+
withBackends(dev, {lan, cloud});
|
|
119
|
+
|
|
120
|
+
lan.emit('change');
|
|
121
|
+
expect(dev.state['1']).toBe(true); // LAN value wins
|
|
122
|
+
expect(dev.state['switch_led']).toBe(true); // …mirrored to its code
|
|
123
|
+
expect(dev.state['battery_percentage']).toBe(80); // cloud-only DP retained
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('when the LAN drops, the cloud takes over and the change is emitted', () => {
|
|
127
|
+
const dev = makeDevice();
|
|
128
|
+
const lan = fakeBackend({connected: true, state: {'1': true}});
|
|
129
|
+
const cloud = fakeBackend({connected: true, state: {'1': false}});
|
|
130
|
+
withBackends(dev, {lan, cloud});
|
|
131
|
+
|
|
132
|
+
lan.emit('change');
|
|
133
|
+
expect(dev.state['1']).toBe(true);
|
|
134
|
+
|
|
135
|
+
lan.connected = false; // LAN lost
|
|
136
|
+
const seen = [];
|
|
137
|
+
dev.on('change', changes => seen.push(changes));
|
|
138
|
+
cloud.emit('change');
|
|
139
|
+
|
|
140
|
+
expect(dev.state['1']).toBe(false);
|
|
141
|
+
expect(seen[0]).toEqual({'1': false});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('TuyaDevice — writes with fallback', () => {
|
|
146
|
+
test('LAN is used first when both backends are up', async () => {
|
|
147
|
+
const dev = makeDevice();
|
|
148
|
+
const lan = fakeBackend({connected: true});
|
|
149
|
+
const cloud = fakeBackend({connected: true, update: jest.fn().mockResolvedValue(true)});
|
|
150
|
+
withBackends(dev, {lan, cloud});
|
|
151
|
+
|
|
152
|
+
await expect(dev.update({'1': true})).resolves.toBe(true);
|
|
153
|
+
expect(lan.update).toHaveBeenCalledWith({'1': true});
|
|
154
|
+
expect(cloud.update).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('a failed LAN write falls back to the cloud', async () => {
|
|
158
|
+
const dev = makeDevice();
|
|
159
|
+
const lan = fakeBackend({connected: true, update: jest.fn().mockReturnValue(false)});
|
|
160
|
+
const cloud = fakeBackend({connected: true, update: jest.fn().mockResolvedValue(true)});
|
|
161
|
+
withBackends(dev, {lan, cloud});
|
|
162
|
+
|
|
163
|
+
await expect(dev.update({'1': true})).resolves.toBe(true);
|
|
164
|
+
expect(cloud.update).toHaveBeenCalledWith({'1': true});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('with the LAN down, writes go straight to the cloud (raw keys; the cloud translates)', async () => {
|
|
168
|
+
const dev = makeDevice();
|
|
169
|
+
const cloud = fakeBackend({connected: true, update: jest.fn().mockResolvedValue(true)});
|
|
170
|
+
withBackends(dev, {cloud});
|
|
171
|
+
|
|
172
|
+
await expect(dev.update({'switch_led': true})).resolves.toBe(true);
|
|
173
|
+
expect(cloud.update).toHaveBeenCalledWith({'switch_led': true});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('a code-style write is translated to numeric ids for the LAN', async () => {
|
|
177
|
+
const dev = makeDevice();
|
|
178
|
+
const lan = fakeBackend({connected: true, update: jest.fn().mockReturnValue(true)});
|
|
179
|
+
const cloud = fakeBackend({connected: true, dpIdByCode: {'switch_led': '1'}, codeByDpId: {'1': 'switch_led'}});
|
|
180
|
+
withBackends(dev, {lan, cloud});
|
|
181
|
+
|
|
182
|
+
await dev.update({'switch_led': true});
|
|
183
|
+
expect(lan.update).toHaveBeenCalledWith({'1': true});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('update resolves false when nothing is reachable', async () => {
|
|
187
|
+
const dev = makeDevice();
|
|
188
|
+
const cloud = fakeBackend({connected: false, update: jest.fn()});
|
|
189
|
+
withBackends(dev, {cloud});
|
|
190
|
+
await expect(dev.update({'1': true})).resolves.toBe(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('TuyaDevice — registration source guard', () => {
|
|
195
|
+
test('a LAN-primary device defers cloud-driven registration until it has the map or the LAN grace elapses', () => {
|
|
196
|
+
const dev = makeDevice(); // has a key → LAN-primary
|
|
197
|
+
dev.cloud = fakeBackend({codeByDpId: {}}); // cloud, but no numeric map yet
|
|
198
|
+
|
|
199
|
+
expect(dev._mayRegisterFrom('lan')).toBe(true);
|
|
200
|
+
expect(dev._mayRegisterFrom('cloud')).toBe(false);
|
|
201
|
+
|
|
202
|
+
dev.cloud.codeByDpId = {'1': 'switch_led'}; // map learned → safe
|
|
203
|
+
expect(dev._mayRegisterFrom('cloud')).toBe(true);
|
|
204
|
+
|
|
205
|
+
dev.cloud.codeByDpId = {};
|
|
206
|
+
dev._lanGraceElapsed = true; // …or the LAN had its head start
|
|
207
|
+
expect(dev._mayRegisterFrom('cloud')).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a keyless (cloud-only) device registers off the cloud immediately', () => {
|
|
211
|
+
const keyless = makeDevice({key: undefined});
|
|
212
|
+
keyless.cloud = fakeBackend({codeByDpId: {}});
|
|
213
|
+
expect(keyless._mayRegisterFrom('cloud')).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('TuyaDevice — composition', () => {
|
|
218
|
+
test('a cloud backend is built when a shared cloud session is supplied', () => {
|
|
219
|
+
const api = {
|
|
220
|
+
isConfigured: () => true,
|
|
221
|
+
getStatus: jest.fn().mockResolvedValue([]),
|
|
222
|
+
getDeviceInfo: jest.fn().mockResolvedValue({online: true}),
|
|
223
|
+
sendCommands: jest.fn()
|
|
224
|
+
};
|
|
225
|
+
const dev = makeDevice({cloudApi: api});
|
|
226
|
+
expect(dev.cloud).toBeInstanceOf(TuyaCloudDevice);
|
|
227
|
+
expect(dev.lan).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('no cloud backend without a shared session', () => {
|
|
231
|
+
const dev = makeDevice();
|
|
232
|
+
expect(dev.cloud).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('attachLan builds the LAN backend with the discovered version (forceVersion still wins)', () => {
|
|
236
|
+
const dev = makeDevice({ip: '10.0.0.5'});
|
|
237
|
+
dev.attachLan({ip: '10.0.0.9', version: '3.3'});
|
|
238
|
+
expect(dev.lan).not.toBeNull();
|
|
239
|
+
expect(dev.lan.context.ip).toBe('10.0.0.9');
|
|
240
|
+
expect(dev.lan.context.version).toBe('3.3');
|
|
241
|
+
|
|
242
|
+
const forced = makeDevice({forceVersion: '3.5'});
|
|
243
|
+
forced.attachLan({ip: '10.0.0.9', version: '3.3'});
|
|
244
|
+
expect(forced.lan.context.version).toBe('3.5');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('attachLan is a no-op without a local key (cloud-only device)', () => {
|
|
248
|
+
const dev = makeDevice({key: undefined});
|
|
249
|
+
dev.attachLan({ip: '10.0.0.9'});
|
|
250
|
+
expect(dev.lan).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
});
|