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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +162 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +16 -3
  5. package/Readme.MD +8 -32
  6. package/config.schema.json +79 -79
  7. package/index.js +599 -358
  8. package/lib/AirPurifierAccessory.js +1 -1
  9. package/lib/BaseAccessory.js +54 -17
  10. package/lib/ConvectorAccessory.js +1 -1
  11. package/lib/CustomMultiOutletAccessory.js +2 -1
  12. package/lib/DoorbellAccessory.js +9 -16
  13. package/lib/GarageDoorAccessory.js +1 -1
  14. package/lib/IrrigationSystemAccessory.js +260 -120
  15. package/lib/MultiOutletAccessory.js +3 -2
  16. package/lib/OilDiffuserAccessory.js +10 -8
  17. package/lib/RGBTWLightAccessory.js +13 -11
  18. package/lib/RGBTWOutletAccessory.js +11 -9
  19. package/lib/SimpleBlindsAccessory.js +5 -5
  20. package/lib/SimpleDimmer2Accessory.js +1 -1
  21. package/lib/SimpleDimmerAccessory.js +10 -6
  22. package/lib/SimpleGarageDoorAccessory.js +122 -26
  23. package/lib/SimpleHeaterAccessory.js +4 -4
  24. package/lib/SimpleLightAccessory.js +1 -1
  25. package/lib/SwitchAccessory.js +3 -2
  26. package/lib/TWLightAccessory.js +2 -2
  27. package/lib/TuyaAccessory.js +75 -42
  28. package/lib/TuyaCloudApi.js +121 -8
  29. package/lib/TuyaCloudDevice.js +434 -31
  30. package/lib/TuyaCloudMessaging.js +34 -41
  31. package/lib/TuyaDevice.js +269 -0
  32. package/lib/TuyaDiscovery.js +25 -9
  33. package/lib/ValveAccessory.js +16 -12
  34. package/lib/VerticalBlindsWithTilt.js +27 -25
  35. package/lib/WledDimmerAccessory.js +46 -81
  36. package/package.json +5 -6
  37. package/scripts/cleanup-pr-tags.js +122 -0
  38. package/test/BaseAccessory.test.js +94 -15
  39. package/test/IrrigationSystemAccessory.test.js +293 -67
  40. package/test/MultiOutletAccessory.test.js +18 -3
  41. package/test/RGBTWLightAccessory.test.js +3 -3
  42. package/test/SimpleFanLightAccessory.test.js +3 -3
  43. package/test/SimpleGarageDoorAccessory.test.js +193 -19
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +110 -1
  46. package/test/TuyaCloudDevice.test.js +564 -2
  47. package/test/TuyaCloudMessaging.test.js +24 -5
  48. package/test/TuyaDevice.test.js +266 -0
  49. package/test/getCategory.test.js +1 -0
  50. package/test/index.test.js +271 -0
  51. package/test/support/mocks.js +13 -0
  52. package/wiki/Supported-Device-Types.md +64 -34
  53. package/wiki/Tuya-Cloud-Setup.md +31 -108
@@ -0,0 +1,266 @@
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('the cloud backend can see whether the LAN path is currently up', () => {
236
+ const api = {
237
+ isConfigured: () => true,
238
+ getStatus: jest.fn().mockResolvedValue([]),
239
+ getDeviceInfo: jest.fn().mockResolvedValue({online: true}),
240
+ sendCommands: jest.fn()
241
+ };
242
+ const dev = makeDevice({cloudApi: api});
243
+
244
+ expect(dev.cloud.isLanConnected()).toBe(false); // no LAN attached yet
245
+ dev.lan = {connected: true};
246
+ expect(dev.cloud.isLanConnected()).toBe(true); // reflects live LAN state
247
+ });
248
+
249
+ test('attachLan builds the LAN backend with the discovered version (forceVersion still wins)', () => {
250
+ const dev = makeDevice({ip: '10.0.0.5'});
251
+ dev.attachLan({ip: '10.0.0.9', version: '3.3'});
252
+ expect(dev.lan).not.toBeNull();
253
+ expect(dev.lan.context.ip).toBe('10.0.0.9');
254
+ expect(dev.lan.context.version).toBe('3.3');
255
+
256
+ const forced = makeDevice({forceVersion: '3.5'});
257
+ forced.attachLan({ip: '10.0.0.9', version: '3.3'});
258
+ expect(forced.lan.context.version).toBe('3.5');
259
+ });
260
+
261
+ test('attachLan is a no-op without a local key (cloud-only device)', () => {
262
+ const dev = makeDevice({key: undefined});
263
+ dev.attachLan({ip: '10.0.0.9'});
264
+ expect(dev.lan).toBeNull();
265
+ });
266
+ });
@@ -23,6 +23,7 @@ const CLASS_DEF = {
23
23
  garagedoor: require('../lib/GarageDoorAccessory'),
24
24
  simplegaragedoor: require('../lib/SimpleGarageDoorAccessory'),
25
25
  simpledimmer: require('../lib/SimpleDimmerAccessory'),
26
+ wleddimmer: require('../lib/WledDimmerAccessory'),
26
27
  simpledimmer2: require('../lib/SimpleDimmer2Accessory'),
27
28
  simpleblinds: require('../lib/SimpleBlindsAccessory'),
28
29
  simpleheater: require('../lib/SimpleHeaterAccessory'),
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+
3
+ // Mock every module that would touch the network or HAP, so we can exercise the
4
+ // platform's orchestration (cloud-session setup, per-device cloud policy, and
5
+ // discovery -> attachLan routing) in isolation.
6
+ jest.mock('../lib/TuyaDevice', () => jest.fn().mockImplementation(function(props) {
7
+ this.context = {...props};
8
+ this.cloud = props.cloudApi ? {} : null; // truthy iff a shared cloud session was handed in
9
+ this.attachLan = jest.fn();
10
+ this._connect = jest.fn();
11
+ }));
12
+ jest.mock('../lib/TuyaAccessory', () => jest.fn().mockImplementation(function(props) {
13
+ this.context = {...props};
14
+ this._connect = jest.fn();
15
+ }));
16
+ jest.mock('../lib/TuyaCloudApi', () => jest.fn().mockImplementation(function(cfg) {
17
+ this.endpoint = 'https://openapi.example.com';
18
+ this.cfg = cfg;
19
+ this.getDeviceInfo = jest.fn();
20
+ }));
21
+ jest.mock('../lib/TuyaCloudMessaging', () => jest.fn().mockImplementation(function() {}));
22
+ jest.mock('../lib/TuyaDiscovery', () => ({
23
+ start: jest.fn(function() { return this; }),
24
+ on: jest.fn(function() { return this; })
25
+ }));
26
+
27
+ const TuyaDevice = require('../lib/TuyaDevice');
28
+ const TuyaAccessory = require('../lib/TuyaAccessory');
29
+ const TuyaCloudApi = require('../lib/TuyaCloudApi');
30
+ const TuyaCloudMessaging = require('../lib/TuyaCloudMessaging');
31
+ const TuyaDiscovery = require('../lib/TuyaDiscovery');
32
+
33
+ const makeHap = () => ({
34
+ Characteristic: class { setProps() { return this; } getDefaultValue() { return 0; } },
35
+ Formats: {FLOAT: 'float', UINT32: 'uint32', UINT16: 'uint16'},
36
+ Perms: {READ: 'pr', NOTIFY: 'ev', WRITE: 'pw'},
37
+ Categories: {OTHER: 1, SWITCH: 8, SPRINKLER: 28},
38
+ Service: {AccessoryInformation: {UUID: '3E'}},
39
+ uuid: {generate: s => 'uuid:' + s}
40
+ });
41
+
42
+ // Load the platform factory and capture the registered platform class.
43
+ const factory = require('../index');
44
+ function getPlatformClass() {
45
+ let cls;
46
+ factory({
47
+ platformAccessory: function() {},
48
+ hap: makeHap(),
49
+ registerPlatform: (pluginName, platformName, c) => { cls = c; }
50
+ });
51
+ return cls;
52
+ }
53
+
54
+ function makeLog() {
55
+ const log = jest.fn();
56
+ log.info = jest.fn(); log.warn = jest.fn(); log.error = jest.fn(); log.debug = jest.fn();
57
+ return log;
58
+ }
59
+
60
+ function makeApi() {
61
+ return {hap: makeHap(), on: jest.fn(), registerPlatformAccessories: jest.fn(), unregisterPlatformAccessories: jest.fn()};
62
+ }
63
+
64
+ function run(config) {
65
+ const Platform = getPlatformClass();
66
+ const platform = new Platform(makeLog(), config, makeApi());
67
+ platform.addAccessory = jest.fn(); // bypass HAP accessory creation
68
+ platform.discoverDevices();
69
+ return platform;
70
+ }
71
+
72
+ const propsFor = id => {
73
+ const call = TuyaDevice.mock.calls.find(c => c[0].id === id);
74
+ return call && call[0];
75
+ };
76
+ const instanceFor = id => TuyaDevice.mock.instances.find(i => i.context && i.context.id === id);
77
+
78
+ const SW = (extra = {}) => ({id: 'bf11111111111111', key: 'k1', type: 'switch', name: 'Switch', ...extra});
79
+ // A keyless device can't speak the LAN protocol, so it is cloud-only.
80
+ const SLEEPY = (extra = {}) => ({id: 'bf22222222222222', type: 'irrigationsystem', name: 'Sprinklers', ...extra});
81
+ const CLOUD = {accessId: 'aid', accessKey: 'akey', region: 'eu'};
82
+
83
+ // discoverDevices schedules a long discovery-timeout timer; fake timers keep it
84
+ // from holding the event loop open after the tests finish.
85
+ beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
86
+ afterEach(() => { jest.useRealTimers(); });
87
+
88
+ describe('TuyaLan — cloud session setup', () => {
89
+ test('no cloud config → no session, devices are pure-LAN', () => {
90
+ run({devices: [SW()]});
91
+ expect(TuyaCloudApi).not.toHaveBeenCalled();
92
+ expect(propsFor('bf11111111111111').cloudApi).toBeUndefined();
93
+ });
94
+
95
+ test('a top-level cloud block creates the single shared session', () => {
96
+ run({cloud: CLOUD, devices: [SW()]});
97
+ expect(TuyaCloudApi).toHaveBeenCalledTimes(1);
98
+ expect(TuyaCloudApi.mock.calls[0][0]).toMatchObject({accessId: 'aid', accessKey: 'akey', region: 'eu'});
99
+ expect(TuyaCloudMessaging).toHaveBeenCalledTimes(1);
100
+ });
101
+ });
102
+
103
+ describe('TuyaLan — cloud participation', () => {
104
+ test('with a session, every device shares the one global fallback', () => {
105
+ run({cloud: CLOUD, devices: [SW(), SLEEPY()]});
106
+ expect(propsFor('bf11111111111111').cloudApi).toBeDefined(); // LAN device, cloud fallback
107
+ expect(propsFor('bf22222222222222').cloudApi).toBeDefined(); // keyless, cloud-only
108
+ });
109
+
110
+ test('without a session, no device gets a cloud backend', () => {
111
+ run({devices: [SW()]});
112
+ expect(propsFor('bf11111111111111').cloudApi).toBeUndefined();
113
+ });
114
+ });
115
+
116
+ describe('TuyaLan — discovery routing', () => {
117
+ test('only keyed devices are discovered; keyless ones are cloud-only', () => {
118
+ run({cloud: CLOUD, devices: [SW(), SLEEPY()]});
119
+ expect(TuyaDiscovery.start).toHaveBeenCalledTimes(1);
120
+ expect(TuyaDiscovery.start.mock.calls[0][0].ids).toEqual(['bf11111111111111']);
121
+ });
122
+
123
+ test('a discovered device is handed its LAN target via attachLan', () => {
124
+ run({cloud: CLOUD, devices: [SW()]});
125
+ // capture the 'discover' handler registered on the discovery emitter
126
+ const onDiscover = TuyaDiscovery.on.mock.calls.find(c => c[0] === 'discover');
127
+ expect(onDiscover).toBeDefined();
128
+ onDiscover[1]({id: 'bf11111111111111', ip: '10.0.0.7', version: '3.3'});
129
+ const inst = instanceFor('bf11111111111111');
130
+ expect(inst.attachLan).toHaveBeenCalledWith({ip: '10.0.0.7', version: '3.3'});
131
+ });
132
+
133
+ test('a cloud-only configuration starts no LAN discovery', () => {
134
+ run({cloud: CLOUD, devices: [SLEEPY()]});
135
+ expect(TuyaDiscovery.start).not.toHaveBeenCalled();
136
+ });
137
+ });
138
+
139
+ describe('TuyaLan — debug.forceCloudFallback', () => {
140
+ test('skips LAN discovery so a keyed device never attaches a LAN backend', () => {
141
+ run({cloud: CLOUD, debug: {forceCloudFallback: true}, devices: [SW()]});
142
+ expect(TuyaDiscovery.start).not.toHaveBeenCalled();
143
+ expect(TuyaDiscovery.on).not.toHaveBeenCalled();
144
+ expect(instanceFor('bf11111111111111').attachLan).not.toHaveBeenCalled();
145
+ });
146
+
147
+ test('the forced device keeps its shared cloud session as its only transport', () => {
148
+ run({cloud: CLOUD, debug: {forceCloudFallback: true}, devices: [SW()]});
149
+ expect(propsFor('bf11111111111111').cloudApi).toBeDefined();
150
+ });
151
+
152
+ test('off (or absent) leaves discovery running as usual', () => {
153
+ run({cloud: CLOUD, debug: {forceCloudFallback: false}, devices: [SW()]});
154
+ expect(TuyaDiscovery.start).toHaveBeenCalledTimes(1);
155
+ });
156
+
157
+ test('lenient boolean spellings are accepted', () => {
158
+ run({cloud: CLOUD, debug: {forceCloudFallback: 'true'}, devices: [SW()]});
159
+ expect(TuyaDiscovery.start).not.toHaveBeenCalled();
160
+ });
161
+
162
+ test('without a cloud session, the unreachable device is reported as an error', () => {
163
+ const platform = run({debug: {forceCloudFallback: true}, devices: [SW()]});
164
+ expect(TuyaDiscovery.start).not.toHaveBeenCalled();
165
+ expect(platform.log.error).toHaveBeenCalled();
166
+ });
167
+ });
168
+
169
+ describe('TuyaLan — unconfigured device discovery', () => {
170
+ const UNKNOWN = {id: 'bf99999999999999', ip: '10.0.0.9'};
171
+ const BARE = 'Discovered a device that has not been configured yet (%s@%s).';
172
+ const ENRICHED = 'Discovered a device that has not been configured yet: %s%s (%s@%s).';
173
+
174
+ test('without a cloud session, the bare id@ip warning is logged', async () => {
175
+ const platform = run({devices: [SW()]});
176
+ await platform._warnUnconfiguredDevice(UNKNOWN);
177
+ expect(platform.log.warn).toHaveBeenCalledWith(BARE, UNKNOWN.id, UNKNOWN.ip);
178
+ });
179
+
180
+ test('with a cloud session, the warning is enriched with the device name and product', async () => {
181
+ const platform = run({cloud: CLOUD, devices: [SW()]});
182
+ platform.cloudApi.getDeviceInfo.mockResolvedValue({name: 'Living Room Light', product_name: 'Smart Bulb', category: 'dj', online: true});
183
+
184
+ await platform._warnUnconfiguredDevice(UNKNOWN);
185
+
186
+ expect(platform.cloudApi.getDeviceInfo).toHaveBeenCalledWith(UNKNOWN.id);
187
+ const [format, ...args] = platform.log.warn.mock.calls[0];
188
+ expect(format).toBe(ENRICHED);
189
+ const text = args.join(' ');
190
+ expect(text).toContain('"Living Room Light"');
191
+ expect(text).toContain('Smart Bulb');
192
+ expect(text).toContain('category dj');
193
+ expect(text).toContain(UNKNOWN.id);
194
+ });
195
+
196
+ test('a cloud lookup that fails falls back to the bare warning', async () => {
197
+ const platform = run({cloud: CLOUD, devices: [SW()]});
198
+ platform.cloudApi.getDeviceInfo.mockRejectedValue(new Error('device not found'));
199
+
200
+ await platform._warnUnconfiguredDevice(UNKNOWN);
201
+
202
+ expect(platform.log.warn).toHaveBeenCalledWith(BARE, UNKNOWN.id, UNKNOWN.ip);
203
+ expect(platform.log.debug).toHaveBeenCalled();
204
+ });
205
+
206
+ test('a cloud record without a name falls back to the bare warning', async () => {
207
+ const platform = run({cloud: CLOUD, devices: [SW()]});
208
+ platform.cloudApi.getDeviceInfo.mockResolvedValue({online: true});
209
+
210
+ await platform._warnUnconfiguredDevice(UNKNOWN);
211
+
212
+ expect(platform.log.warn).toHaveBeenCalledWith(BARE, UNKNOWN.id, UNKNOWN.ip);
213
+ });
214
+
215
+ test('the discover handler routes an unconfigured device through the cloud lookup', () => {
216
+ const platform = run({cloud: CLOUD, devices: [SW()]});
217
+ platform.cloudApi.getDeviceInfo.mockResolvedValue(null);
218
+
219
+ const onDiscover = TuyaDiscovery.on.mock.calls.find(c => c[0] === 'discover')[1];
220
+ onDiscover(UNKNOWN);
221
+
222
+ expect(platform.cloudApi.getDeviceInfo).toHaveBeenCalledWith(UNKNOWN.id);
223
+ });
224
+ });
225
+
226
+ describe('TuyaLan — duplicate device ids', () => {
227
+ // Two entries sharing an id resolve to one accessory UUID; configuring it twice
228
+ // used to crash the child bridge (addAccessory read back its own wrapper and
229
+ // tried to unregister it). The repeat must be dropped with a warning instead.
230
+ test('a repeated id is configured once and warns', () => {
231
+ const platform = run({devices: [SW(), SW()]});
232
+ expect(TuyaDevice).toHaveBeenCalledTimes(1);
233
+ expect(platform.addAccessory).toHaveBeenCalledTimes(1);
234
+ expect(platform.log.warn).toHaveBeenCalled();
235
+ });
236
+
237
+ test('distinct ids are all configured', () => {
238
+ run({devices: [SW(), SLEEPY()]});
239
+ expect(TuyaDevice).toHaveBeenCalledTimes(2);
240
+ });
241
+
242
+ test('a repeated fake id is configured once', () => {
243
+ run({devices: [SW({fake: true}), SW({fake: true})]});
244
+ expect(TuyaAccessory).toHaveBeenCalledTimes(1);
245
+ });
246
+ });
247
+
248
+ describe('TuyaLan — removeAccessory hardening', () => {
249
+ function platformWith(PlatformAccessory) {
250
+ let cls;
251
+ factory({platformAccessory: PlatformAccessory, hap: makeHap(), registerPlatform: (n, p, c) => { cls = c; }});
252
+ return new cls(makeLog(), {devices: [SW()]}, makeApi());
253
+ }
254
+ const PlatformAccessoryStub = function(name, uuid) { this.displayName = name; this.UUID = uuid; };
255
+
256
+ test('a non-PlatformAccessory (e.g. an accessory wrapper) is never unregistered', () => {
257
+ const platform = platformWith(PlatformAccessoryStub);
258
+ expect(() => platform.removeAccessory({accessory: {}})).not.toThrow();
259
+ expect(platform.api.unregisterPlatformAccessories).not.toHaveBeenCalled();
260
+ expect(platform.log.warn).toHaveBeenCalled();
261
+ });
262
+
263
+ test('a real PlatformAccessory is unregistered and dropped from the cache', () => {
264
+ const platform = platformWith(PlatformAccessoryStub);
265
+ const accessory = new PlatformAccessoryStub('Real', 'uuid:x');
266
+ platform.cachedAccessories.set('uuid:x', accessory);
267
+ platform.removeAccessory(accessory);
268
+ expect(platform.api.unregisterPlatformAccessories).toHaveBeenCalledTimes(1);
269
+ expect(platform.cachedAccessories.has('uuid:x')).toBe(false);
270
+ });
271
+ });
@@ -34,6 +34,7 @@ const HAP = {
34
34
  ProgramMode: { UUID: 'D1', NO_PROGRAM_SCHEDULED: 0, PROGRAM_SCHEDULED: 1, PROGRAM_SCHEDULED_MANUAL_MODE: 2 },
35
35
  IsConfigured: { UUID: 'D6', NOT_CONFIGURED: 0, CONFIGURED: 1 },
36
36
  ServiceLabelIndex: { UUID: 'CB' },
37
+ ServiceLabelNamespace: { UUID: 'CD', DOTS: 0, ARABIC_NUMERALS: 1 },
37
38
  ConfiguredName: { UUID: 'E3' },
38
39
  BatteryLevel: { UUID: '68' },
39
40
  StatusLowBattery: { UUID: '79', BATTERY_LEVEL_NORMAL: 0, BATTERY_LEVEL_LOW: 1 },
@@ -65,6 +66,7 @@ const HAP = {
65
66
  LockMechanism: { UUID: '45' },
66
67
  Valve: { UUID: 'D0' },
67
68
  IrrigationSystem: { UUID: 'CF' },
69
+ ServiceLabel: { UUID: 'CC' },
68
70
  Battery: { UUID: '96' },
69
71
  ContactSensor: { UUID: '80' },
70
72
  LeakSensor: { UUID: '83' },
@@ -73,6 +75,17 @@ const HAP = {
73
75
  },
74
76
  Formats: { FLOAT: 'float', UINT32: 'uint32', UINT16: 'uint16' },
75
77
  Perms: { READ: 'pr', NOTIFY: 'ev', WRITE: 'pw' },
78
+ // Subset of hap-nodejs HAPStatus + the HapStatusError class, so accessories
79
+ // can signal "No Response" the proper way (throw a HapStatusError from a
80
+ // get/set handler) and tests can assert on it.
81
+ HAPStatus: { SUCCESS: 0, SERVICE_COMMUNICATION_FAILURE: -70402 },
82
+ HapStatusError: class HapStatusError extends Error {
83
+ constructor(hapStatus) {
84
+ super('HapStatusError: ' + hapStatus);
85
+ this.name = 'HapStatusError';
86
+ this.hapStatus = hapStatus;
87
+ }
88
+ },
76
89
  // Mirrors hap-nodejs Categories. Intentionally kept faithful to the real
77
90
  // enum (no FANLIGHT, no bare DEHUMIDIFIER) so getCategory() regressions are
78
91
  // caught here instead of in production (see issue #45).