homebridge-tuya-plus 3.14.0-dev.3 → 3.14.0-dev.30
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 +120 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +21 -0
- package/Readme.MD +8 -24
- package/config.schema.json +122 -68
- package/index.js +498 -269
- package/lib/BaseAccessory.js +52 -15
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/IrrigationSystemAccessory.js +98 -95
- package/lib/MultiOutletAccessory.js +2 -1
- 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 +78 -24
- package/lib/SimpleHeaterAccessory.js +3 -3
- package/lib/SwitchAccessory.js +2 -1
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +0 -1
- package/lib/TuyaCloudApi.js +320 -0
- package/lib/TuyaCloudDevice.js +327 -0
- package/lib/TuyaCloudMessaging.js +219 -0
- package/lib/TuyaDevice.js +266 -0
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +5 -3
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -3
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +134 -31
- 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 +63 -9
- package/test/TuyaCloudApi.test.js +221 -0
- package/test/TuyaCloudDevice.test.js +304 -0
- package/test/TuyaCloudMessaging.test.js +113 -0
- package/test/TuyaDevice.test.js +252 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +183 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +60 -28
- package/wiki/Tuya-Cloud-Setup.md +71 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const TuyaCloudDevice = require('../lib/TuyaCloudDevice');
|
|
5
|
+
|
|
6
|
+
const log = {info: () => {}, warn: () => {}, error: () => {}, debug: () => {}};
|
|
7
|
+
|
|
8
|
+
function makeApi(status = [{code: 'switch_1', value: false}, {code: 'battery_percentage', value: 99}]) {
|
|
9
|
+
return {
|
|
10
|
+
isConfigured: () => true,
|
|
11
|
+
getStatus: jest.fn().mockResolvedValue(status),
|
|
12
|
+
getDeviceInfo: jest.fn().mockResolvedValue({online: true}),
|
|
13
|
+
sendCommands: jest.fn().mockResolvedValue(true)
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeDevice(api, messaging = null, extra = {}) {
|
|
18
|
+
return new TuyaCloudDevice({
|
|
19
|
+
cloudApi: api, messaging, log,
|
|
20
|
+
id: 'dev1', name: 'Sprinklers', type: 'irrigationsystem', cloud: true,
|
|
21
|
+
connect: false, ...extra
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('TuyaCloudDevice', () => {
|
|
26
|
+
test('connect reads status and emits connect then change (state keyed by code)', async () => {
|
|
27
|
+
const api = makeApi();
|
|
28
|
+
const dev = makeDevice(api);
|
|
29
|
+
|
|
30
|
+
const events = [];
|
|
31
|
+
dev.on('connect', () => events.push('connect'));
|
|
32
|
+
dev.on('change', (changes, state) => events.push({changes, state}));
|
|
33
|
+
|
|
34
|
+
await dev._connect();
|
|
35
|
+
|
|
36
|
+
expect(dev.connected).toBe(true);
|
|
37
|
+
expect(dev.state).toEqual({switch_1: false, battery_percentage: 99});
|
|
38
|
+
expect(events[0]).toBe('connect');
|
|
39
|
+
expect(events[1].state).toEqual({switch_1: false, battery_percentage: 99});
|
|
40
|
+
expect(api.getStatus).toHaveBeenCalledWith('dev1');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('update sends code/value commands and does NOT mutate local state', async () => {
|
|
44
|
+
const api = makeApi();
|
|
45
|
+
const dev = makeDevice(api);
|
|
46
|
+
await dev._connect();
|
|
47
|
+
|
|
48
|
+
const before = {...dev.state};
|
|
49
|
+
dev.update({switch_1: true});
|
|
50
|
+
|
|
51
|
+
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
|
|
52
|
+
expect(dev.state).toEqual(before); // optimism lives in the accessory, not here
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('update is a no-op (no throw) before connect', () => {
|
|
56
|
+
const api = makeApi();
|
|
57
|
+
const dev = makeDevice(api);
|
|
58
|
+
expect(dev.connected).toBe(false);
|
|
59
|
+
expect(dev.update({switch_1: true})).toBe(false);
|
|
60
|
+
expect(api.sendCommands).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('update resolves to the cloud command result so failures are awaitable', async () => {
|
|
64
|
+
const api = makeApi();
|
|
65
|
+
const dev = makeDevice(api);
|
|
66
|
+
await dev._connect();
|
|
67
|
+
|
|
68
|
+
api.sendCommands.mockResolvedValueOnce(true);
|
|
69
|
+
await expect(dev.update({switch_1: true})).resolves.toBe(true);
|
|
70
|
+
|
|
71
|
+
api.sendCommands.mockResolvedValueOnce(false);
|
|
72
|
+
await expect(dev.update({switch_1: true})).resolves.toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('update resolves to false (never rejects) when the cloud request throws', async () => {
|
|
76
|
+
const api = makeApi();
|
|
77
|
+
const dev = makeDevice(api);
|
|
78
|
+
await dev._connect();
|
|
79
|
+
|
|
80
|
+
api.sendCommands.mockRejectedValueOnce(new Error('network down'));
|
|
81
|
+
await expect(dev.update({switch_1: true})).resolves.toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('applyStatus emits only the changed data-points', async () => {
|
|
85
|
+
const api = makeApi();
|
|
86
|
+
const dev = makeDevice(api);
|
|
87
|
+
await dev._connect();
|
|
88
|
+
|
|
89
|
+
const changes = [];
|
|
90
|
+
dev.on('change', c => changes.push(c));
|
|
91
|
+
|
|
92
|
+
dev._applyStatus([{code: 'switch_1', value: true}, {code: 'battery_percentage', value: 99}]);
|
|
93
|
+
expect(changes).toHaveLength(1);
|
|
94
|
+
expect(changes[0]).toEqual({switch_1: true}); // battery unchanged → not emitted
|
|
95
|
+
expect(dev.state.switch_1).toBe(true);
|
|
96
|
+
|
|
97
|
+
changes.length = 0;
|
|
98
|
+
dev._applyStatus([{code: 'switch_1', value: true}]); // nothing changed
|
|
99
|
+
expect(changes).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('connect reflects the device online status (offline → not connected)', async () => {
|
|
103
|
+
const api = makeApi();
|
|
104
|
+
api.getDeviceInfo.mockResolvedValue({online: false});
|
|
105
|
+
const dev = makeDevice(api);
|
|
106
|
+
await dev._connect();
|
|
107
|
+
expect(api.getDeviceInfo).toHaveBeenCalledWith('dev1');
|
|
108
|
+
expect(dev.connected).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('online lookup failure → assume reachable (never block control)', async () => {
|
|
112
|
+
const api = makeApi();
|
|
113
|
+
api.getDeviceInfo.mockRejectedValue(new Error('no device-management permission'));
|
|
114
|
+
const dev = makeDevice(api);
|
|
115
|
+
await dev._connect();
|
|
116
|
+
expect(dev.connected).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a state refresh re-checks online and flips connected', async () => {
|
|
120
|
+
const api = makeApi();
|
|
121
|
+
const dev = makeDevice(api);
|
|
122
|
+
await dev._connect();
|
|
123
|
+
expect(dev.connected).toBe(true);
|
|
124
|
+
|
|
125
|
+
api.getDeviceInfo.mockResolvedValue({online: false});
|
|
126
|
+
await dev._refreshState();
|
|
127
|
+
expect(dev.connected).toBe(false);
|
|
128
|
+
});
|
|
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
|
+
|
|
179
|
+
test('subscribes to realtime and re-reads state when the stream (re)connects', async () => {
|
|
180
|
+
const api = makeApi();
|
|
181
|
+
const messaging = Object.assign(new EventEmitter(), {
|
|
182
|
+
connected: false,
|
|
183
|
+
subscribeDevice: jest.fn()
|
|
184
|
+
});
|
|
185
|
+
const dev = makeDevice(api, messaging);
|
|
186
|
+
await dev._connect();
|
|
187
|
+
|
|
188
|
+
expect(messaging.subscribeDevice).toHaveBeenCalledWith('dev1', expect.any(Function));
|
|
189
|
+
|
|
190
|
+
// A realtime message routed back to the device updates its state.
|
|
191
|
+
const handler = messaging.subscribeDevice.mock.calls[0][1];
|
|
192
|
+
const changes = [];
|
|
193
|
+
dev.on('change', c => changes.push(c));
|
|
194
|
+
handler([{code: 'switch_2', value: true}]);
|
|
195
|
+
expect(dev.state.switch_2).toBe(true);
|
|
196
|
+
expect(changes[0]).toEqual({switch_2: true});
|
|
197
|
+
|
|
198
|
+
// On 'online' the device re-reads the full state (catch-up).
|
|
199
|
+
api.getStatus.mockClear();
|
|
200
|
+
messaging.emit('online');
|
|
201
|
+
await new Promise(r => setImmediate(r));
|
|
202
|
+
expect(api.getStatus).toHaveBeenCalledWith('dev1');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('connect-failure handling (no log spam)', () => {
|
|
206
|
+
afterEach(() => jest.restoreAllMocks());
|
|
207
|
+
|
|
208
|
+
test('a repeated failure is surfaced once, suppressed after, and backs off', () => {
|
|
209
|
+
jest.useFakeTimers();
|
|
210
|
+
try {
|
|
211
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'}); // LAN-capable → fallback
|
|
212
|
+
const warn = jest.spyOn(log, 'warn');
|
|
213
|
+
const debug = jest.spyOn(log, 'debug');
|
|
214
|
+
const err = new Error('GET /v1.0/devices/dev1/status failed: permission deny (code 1106)');
|
|
215
|
+
|
|
216
|
+
dev._onConnectFailure(err);
|
|
217
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
218
|
+
expect(warn.mock.calls[0][0]).toContain('permission deny (code 1106)');
|
|
219
|
+
expect(dev._retryDelay).toBe(30000);
|
|
220
|
+
|
|
221
|
+
dev._onConnectFailure(err); // identical → not surfaced again, just debug + backoff
|
|
222
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(debug).toHaveBeenCalledWith(expect.stringContaining('still failing'));
|
|
224
|
+
expect(dev._retryDelay).toBe(60000);
|
|
225
|
+
|
|
226
|
+
dev._onConnectFailure(err);
|
|
227
|
+
expect(dev._retryDelay).toBe(120000);
|
|
228
|
+
} finally {
|
|
229
|
+
jest.useRealTimers();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('a cloud-only device (no key) surfaces the failure at error level', () => {
|
|
234
|
+
jest.useFakeTimers();
|
|
235
|
+
try {
|
|
236
|
+
const dev = makeDevice(makeApi()); // no key → cloud is the only path
|
|
237
|
+
const error = jest.spyOn(log, 'error');
|
|
238
|
+
const warn = jest.spyOn(log, 'warn');
|
|
239
|
+
|
|
240
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
241
|
+
expect(warn).not.toHaveBeenCalled();
|
|
242
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(error.mock.calls[0][0]).toContain('cloud-only');
|
|
244
|
+
} finally {
|
|
245
|
+
jest.useRealTimers();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a different error message is surfaced again, not suppressed', () => {
|
|
250
|
+
jest.useFakeTimers();
|
|
251
|
+
try {
|
|
252
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
253
|
+
const warn = jest.spyOn(log, 'warn');
|
|
254
|
+
|
|
255
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
256
|
+
dev._onConnectFailure(new Error('request timed out'));
|
|
257
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
258
|
+
} finally {
|
|
259
|
+
jest.useRealTimers();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('backoff is capped at 30 minutes', () => {
|
|
264
|
+
jest.useFakeTimers();
|
|
265
|
+
try {
|
|
266
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
267
|
+
jest.spyOn(log, 'warn');
|
|
268
|
+
jest.spyOn(log, 'debug');
|
|
269
|
+
const err = new Error('permission deny (code 1106)');
|
|
270
|
+
|
|
271
|
+
for (let i = 0; i < 20; i++) dev._onConnectFailure(err);
|
|
272
|
+
expect(dev._retryDelay).toBe(1800000);
|
|
273
|
+
} finally {
|
|
274
|
+
jest.useRealTimers();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('reconnecting after a failure logs recovery once and resets backoff', async () => {
|
|
279
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
280
|
+
dev._lastConnectError = 'permission deny (code 1106)';
|
|
281
|
+
dev._retryDelay = 240000;
|
|
282
|
+
const info = jest.spyOn(log, 'info');
|
|
283
|
+
|
|
284
|
+
await dev._connect(); // mocked api resolves → success path runs
|
|
285
|
+
|
|
286
|
+
expect(dev._lastConnectError).toBeNull();
|
|
287
|
+
expect(dev._retryDelay).toBe(0);
|
|
288
|
+
expect(info).toHaveBeenCalledWith(expect.stringContaining('connection restored'));
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('a stopped device does not schedule another retry', () => {
|
|
292
|
+
jest.useFakeTimers();
|
|
293
|
+
try {
|
|
294
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
295
|
+
jest.spyOn(log, 'warn');
|
|
296
|
+
dev.stop();
|
|
297
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
298
|
+
expect(dev._retryTimer).toBeNull();
|
|
299
|
+
} finally {
|
|
300
|
+
jest.useRealTimers();
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const TuyaCloudMessaging = require('../lib/TuyaCloudMessaging');
|
|
5
|
+
|
|
6
|
+
const log = {info: () => {}, warn: () => {}, error: () => {}, debug: () => {}};
|
|
7
|
+
|
|
8
|
+
// Encrypt exactly like Tuya's msg_encrypted_version 2.0 (AES-128-GCM):
|
|
9
|
+
// [ivLen(4 BE)][iv][ciphertext][tag(16)], key = password[8,24), AAD = 6-byte BE t.
|
|
10
|
+
function encryptGCM(plaintext, password, t) {
|
|
11
|
+
const key = Buffer.from(('' + password).substring(8, 24), 'utf8');
|
|
12
|
+
const iv = crypto.randomBytes(12);
|
|
13
|
+
const c = crypto.createCipheriv('aes-128-gcm', key, iv);
|
|
14
|
+
const aad = Buffer.allocUnsafe(6); aad.writeUIntBE(Number(t), 0, 6); c.setAAD(aad);
|
|
15
|
+
const enc = Buffer.concat([c.update(Buffer.from(plaintext, 'utf8')), c.final()]);
|
|
16
|
+
const tag = c.getAuthTag();
|
|
17
|
+
const ivLen = Buffer.allocUnsafe(4); ivLen.writeUIntBE(iv.length, 0, 4);
|
|
18
|
+
return Buffer.concat([ivLen, iv, enc, tag]).toString('base64');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// AES-128-ECB / PKCS7 (msg_encrypted_version 1.0).
|
|
22
|
+
function encryptECB(plaintext, password) {
|
|
23
|
+
const key = Buffer.from(('' + password).substring(8, 24), 'utf8');
|
|
24
|
+
const c = crypto.createCipheriv('aes-128-ecb', key, null);
|
|
25
|
+
return Buffer.concat([c.update(Buffer.from(plaintext, 'utf8')), c.final()]).toString('base64');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A messaging instance that won't try to open a real connection.
|
|
29
|
+
function makeIdle(password = 'abcdefgh0123456789WXYZ!!') {
|
|
30
|
+
const mq = new TuyaCloudMessaging({api: {}, log});
|
|
31
|
+
mq._started = true; // prevent start()
|
|
32
|
+
mq.config = {password};
|
|
33
|
+
return mq;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function envelope(payloadObj, password, {protocol = 4, t = Date.now(), mode = 'gcm'} = {}) {
|
|
37
|
+
const data = mode === 'ecb'
|
|
38
|
+
? encryptECB(JSON.stringify(payloadObj), password)
|
|
39
|
+
: encryptGCM(JSON.stringify(payloadObj), password, t);
|
|
40
|
+
return Buffer.from(JSON.stringify({protocol, data, t}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('TuyaCloudMessaging — decryption + dispatch', () => {
|
|
44
|
+
test('decrypts a GCM status frame and delivers it to the subscribed device', () => {
|
|
45
|
+
const mq = makeIdle();
|
|
46
|
+
const received = [];
|
|
47
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
48
|
+
|
|
49
|
+
const t = Date.now();
|
|
50
|
+
mq._onMessage('topic', envelope(
|
|
51
|
+
{devId: 'DEV1', status: [{code: 'switch_1', value: true, t}, {code: 'battery_percentage', value: 80, t}]},
|
|
52
|
+
mq.config.password, {t}
|
|
53
|
+
));
|
|
54
|
+
|
|
55
|
+
expect(received).toHaveLength(1);
|
|
56
|
+
expect(received[0]).toEqual([{code: 'switch_1', value: true, t}, {code: 'battery_percentage', value: 80, t}]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('decrypts a GCM frame even when the auth tag does not verify (Tuya quirk)', () => {
|
|
60
|
+
// Real Tuya status frames carry a GCM tag that does NOT verify against
|
|
61
|
+
// the documented AAD. We must decrypt with update() only (no final(),
|
|
62
|
+
// no tag check) or every realtime update is silently dropped — which is
|
|
63
|
+
// exactly what stopped external changes from reaching HomeKit. Simulate
|
|
64
|
+
// it by clobbering the trailing 16-byte tag: the plaintext must still
|
|
65
|
+
// come through.
|
|
66
|
+
const mq = makeIdle();
|
|
67
|
+
const received = [];
|
|
68
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
69
|
+
|
|
70
|
+
const t = Date.now();
|
|
71
|
+
const raw = Buffer.from(
|
|
72
|
+
encryptGCM(JSON.stringify({devId: 'DEV1', status: [{code: 'switch_1', value: true}]}), mq.config.password, t),
|
|
73
|
+
'base64'
|
|
74
|
+
);
|
|
75
|
+
crypto.randomBytes(16).copy(raw, raw.length - 16); // corrupt the auth tag
|
|
76
|
+
|
|
77
|
+
mq._onMessage('topic', Buffer.from(JSON.stringify({protocol: 4, data: raw.toString('base64'), t})));
|
|
78
|
+
|
|
79
|
+
expect(received).toHaveLength(1);
|
|
80
|
+
expect(received[0]).toEqual([{code: 'switch_1', value: true}]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('decrypts a legacy ECB (v1.0) frame too', () => {
|
|
84
|
+
const mq = makeIdle();
|
|
85
|
+
const received = [];
|
|
86
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
87
|
+
mq._onMessage('topic', envelope({devId: 'DEV1', status: [{code: 'switch_2', value: true}]}, mq.config.password, {mode: 'ecb'}));
|
|
88
|
+
expect(received[0]).toEqual([{code: 'switch_2', value: true}]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('ignores messages for devices we did not subscribe', () => {
|
|
92
|
+
const mq = makeIdle();
|
|
93
|
+
const received = [];
|
|
94
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
95
|
+
mq._onMessage('topic', envelope({devId: 'OTHER', status: [{code: 'switch_1', value: true}]}, mq.config.password, {}));
|
|
96
|
+
expect(received).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a frame that cannot be decrypted is dropped without throwing', () => {
|
|
100
|
+
const mq = makeIdle();
|
|
101
|
+
const received = [];
|
|
102
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
103
|
+
// Encrypted with the wrong password → auth/decrypt fails.
|
|
104
|
+
expect(() => mq._onMessage('topic', envelope({devId: 'DEV1', status: [{code: 'x', value: 1}]}, 'ZZZZZZZZwrongkeywrongkey', {}))).not.toThrow();
|
|
105
|
+
expect(received).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('malformed JSON envelope is ignored', () => {
|
|
109
|
+
const mq = makeIdle();
|
|
110
|
+
mq.subscribeDevice('DEV1', () => { throw new Error('should not be called'); });
|
|
111
|
+
expect(() => mq._onMessage('topic', Buffer.from('not json'))).not.toThrow();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -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
|
+
});
|
package/test/getCategory.test.js
CHANGED
|
@@ -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'),
|