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.
@@ -473,46 +473,19 @@ describe('IrrigationSystemAccessory — charging state', () => {
473
473
  });
474
474
  });
475
475
 
476
- describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)', () => {
477
- // Mirrors a real Tuya "sfkzq" watering controller reached over the cloud:
478
- // valves are switch_1..4 and battery is battery_percentage.
479
- const cloudState = () => ({ switch_1: false, switch_2: false, switch_3: false, switch_4: false, battery_percentage: 99 });
480
- const cloudCtx = { cloud: true };
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('default valves use Tuya codes switch_1..switch_4 (not numeric ids)', () => {
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
- { cloud: true, valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }] }
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('turning a zone off still writes false when the cloud never echoed the "on"', () => {
524
- // The real cloud device never optimistically advances `state`; it only
525
- // moves when the realtime stream confirms the device. Emulate that by
526
- // making update() a no-op on state. A follow-up "off" must STILL be sent
527
- // — otherwise the valve stays open while HomeKit shows it closed (the
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(cloudState(), { ...cloudCtx, defaultDuration: 0 });
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, 'switch_1');
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({ switch_1: true });
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({ switch_1: false });
531
+ expect(device.update).toHaveBeenLastCalledWith({ zone_a: false });
540
532
  });
541
533
 
542
- test('master OFF closes zones the cloud has not echoed as open', () => {
543
- const { accessory, device } = makeHarness(cloudState(), { ...cloudCtx, defaultDuration: 0 });
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, 'switch_1').getCharacteristic(Characteristic.Active).triggerSet(1);
547
- valve(accessory, 'switch_2').getCharacteristic(Characteristic.Active).triggerSet(1);
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({ switch_1: false, switch_2: false });
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('Fires open, then fires stop after partialOpenMs', () => {
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
- // Then a single stop is fired.
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 that.
453
+ // And nothing more after the re-sends.
433
454
  jest.advanceTimersByTime(10_000);
434
- expect(device.update).toHaveBeenCalledTimes(2);
455
+ expect(device.update).toHaveBeenCalledTimes(4);
435
456
  expect(instance.partialStopTimer).toBeNull();
436
457
  });
437
458
 
438
- test('Re-entrant press while armed is ignored (idempotent)', () => {
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
+ });