homebridge-tuya-plus 3.14.0-dev.4 → 3.14.0-dev.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish-dev.yml +298 -31
- package/AGENTS.md +162 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +16 -3
- package/Readme.MD +8 -32
- package/config.schema.json +63 -78
- package/index.js +599 -358
- package/lib/AirPurifierAccessory.js +1 -1
- package/lib/BaseAccessory.js +54 -17
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/DoorbellAccessory.js +9 -16
- package/lib/GarageDoorAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +93 -114
- package/lib/MultiOutletAccessory.js +3 -2
- package/lib/OilDiffuserAccessory.js +10 -8
- package/lib/RGBTWLightAccessory.js +13 -11
- package/lib/RGBTWOutletAccessory.js +11 -9
- package/lib/SimpleBlindsAccessory.js +5 -5
- package/lib/SimpleDimmer2Accessory.js +1 -1
- package/lib/SimpleDimmerAccessory.js +10 -6
- package/lib/SimpleGarageDoorAccessory.js +78 -24
- package/lib/SimpleHeaterAccessory.js +4 -4
- package/lib/SimpleLightAccessory.js +1 -1
- package/lib/SwitchAccessory.js +3 -2
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +75 -42
- package/lib/TuyaCloudApi.js +90 -4
- package/lib/TuyaCloudDevice.js +204 -25
- package/lib/TuyaCloudMessaging.js +28 -41
- package/lib/TuyaDevice.js +269 -0
- package/lib/TuyaDiscovery.js +25 -9
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +27 -25
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -6
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +122 -68
- 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/TuyaAccessory.protocol.test.js +76 -3
- package/test/TuyaCloudApi.test.js +80 -0
- package/test/TuyaCloudDevice.test.js +253 -1
- package/test/TuyaCloudMessaging.test.js +24 -5
- package/test/TuyaDevice.test.js +266 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +271 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +48 -30
- package/wiki/Tuya-Cloud-Setup.md +24 -113
|
@@ -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,
|
|
@@ -373,11 +375,13 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
|
|
|
373
375
|
// Disconnect handling
|
|
374
376
|
// ---------------------------------------------------------------------------
|
|
375
377
|
describe('SimpleGarageDoorAccessory — disconnected', () => {
|
|
376
|
-
test('
|
|
378
|
+
test('Surfaces a No Response error and writes nothing when disconnected', () => {
|
|
377
379
|
const { instance, device } = makeSimpleGarage();
|
|
378
380
|
device.connected = false;
|
|
379
381
|
|
|
380
|
-
|
|
382
|
+
// A tap on an unreachable gate must fail in HomeKit instead of being
|
|
383
|
+
// silently dropped while HomeKit believes the open/close succeeded.
|
|
384
|
+
expect(() => instance.setTargetDoorState(TDS.OPEN)).toThrow(HAP.HapStatusError);
|
|
381
385
|
|
|
382
386
|
expect(device.update).not.toHaveBeenCalled();
|
|
383
387
|
});
|
|
@@ -407,35 +411,71 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
|
|
|
407
411
|
beforeEach(() => jest.useFakeTimers());
|
|
408
412
|
afterEach(() => jest.useRealTimers());
|
|
409
413
|
|
|
410
|
-
test('
|
|
414
|
+
test('Stop is anchored to the gate reporting it is moving, not the button press', () => {
|
|
411
415
|
const { instance, device } = makeSimpleGarage();
|
|
412
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;
|
|
413
420
|
|
|
414
421
|
instance._handlePartialOpen();
|
|
415
422
|
|
|
416
|
-
// Open goes out immediately.
|
|
423
|
+
// Open goes out immediately, but the auto-stop is NOT armed yet.
|
|
417
424
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
418
425
|
expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
|
|
419
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);
|
|
420
439
|
|
|
421
|
-
// Nothing happens until the timer elapses.
|
|
422
440
|
jest.advanceTimersByTime(2000 - 1);
|
|
423
441
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
424
442
|
|
|
425
|
-
//
|
|
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).
|
|
426
445
|
jest.advanceTimersByTime(1);
|
|
427
446
|
expect(device.update).toHaveBeenCalledTimes(2);
|
|
428
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 });
|
|
429
452
|
|
|
430
|
-
// And nothing more after
|
|
453
|
+
// And nothing more after the re-sends.
|
|
431
454
|
jest.advanceTimersByTime(10_000);
|
|
432
|
-
expect(device.update).toHaveBeenCalledTimes(
|
|
455
|
+
expect(device.update).toHaveBeenCalledTimes(4);
|
|
433
456
|
expect(instance.partialStopTimer).toBeNull();
|
|
434
457
|
});
|
|
435
458
|
|
|
436
|
-
test('
|
|
459
|
+
test('Arms straight away when the gate already reports open/opening', () => {
|
|
460
|
+
const { instance, device } = makeSimpleGarage();
|
|
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)', () => {
|
|
437
476
|
const { instance, device } = makeSimpleGarage();
|
|
438
477
|
instance.partialOpenMs = 2000;
|
|
478
|
+
device.state['105'] = STATE_OPENING_OR_OPEN; // arms immediately
|
|
439
479
|
|
|
440
480
|
instance._handlePartialOpen();
|
|
441
481
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
@@ -452,6 +492,20 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
|
|
|
452
492
|
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
|
|
453
493
|
});
|
|
454
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
|
+
|
|
455
509
|
test('A direct close cancels the partial auto-stop and runs stop-before-close', () => {
|
|
456
510
|
const { instance, device } = makeSimpleGarage();
|
|
457
511
|
instance.partialOpenMs = 2000;
|
|
@@ -155,6 +155,78 @@ describe('protocol version normalization', () => {
|
|
|
155
155
|
});
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
// A wrong-length local key made crypto.createCipheriv throw "Invalid key length"
|
|
159
|
+
// from inside a socket callback (see _send_3_3), which goes unhandled and crashes
|
|
160
|
+
// Homebridge into a restart loop. An out-of-spec key must be refused clearly,
|
|
161
|
+
// up front, without ever reaching the cipher or taking the process down.
|
|
162
|
+
describe('invalid local key handling', () => {
|
|
163
|
+
describe('isValidLocalKey', () => {
|
|
164
|
+
test.each([
|
|
165
|
+
['0123456789abcdef', true], // 16 ASCII chars
|
|
166
|
+
['short', false],
|
|
167
|
+
['0123456789abcdefXX', false], // 18 chars
|
|
168
|
+
['', false],
|
|
169
|
+
['012345678901234é', false], // 16 chars but 17 UTF-8 bytes
|
|
170
|
+
[Buffer.alloc(16), true],
|
|
171
|
+
[Buffer.alloc(8), false],
|
|
172
|
+
[null, false],
|
|
173
|
+
[undefined, false],
|
|
174
|
+
[12345678901234567, false], // not a string/Buffer
|
|
175
|
+
])('isValidLocalKey(%p) === %p', (key, expected) => {
|
|
176
|
+
expect(TuyaAccessory.isValidLocalKey(key)).toBe(expected);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a wrong-length key is flagged and logged without throwing', () => {
|
|
181
|
+
let device;
|
|
182
|
+
expect(() => { device = makeDevice('3.3', {key: 'too-short'}); }).not.toThrow();
|
|
183
|
+
expect(device._invalidKey).toBe(true);
|
|
184
|
+
expect(device.log.error).toHaveBeenCalledWith(expect.stringContaining('16 characters'));
|
|
185
|
+
const logged = device.log.error.mock.calls.flat().join(' ');
|
|
186
|
+
expect(logged).toContain('Protocol Test Device');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a valid 16-character key is accepted and logs no error', () => {
|
|
190
|
+
const device = makeDevice('3.3');
|
|
191
|
+
expect(device._invalidKey).toBe(false);
|
|
192
|
+
expect(device.log.error).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('_connect() opens no socket for an invalid-key device', () => {
|
|
196
|
+
const device = makeDevice('3.5', {key: 'bad'});
|
|
197
|
+
device._connect();
|
|
198
|
+
expect(device._socket).toBeUndefined();
|
|
199
|
+
expect(device.connected).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// The exact path that used to crash: a connected device whose update() flows
|
|
203
|
+
// into _send -> _send_3_x -> createCipheriv. It must short-circuit to false.
|
|
204
|
+
test.each(['3.1', '3.3', '3.4', '3.5'])(
|
|
205
|
+
'update() never reaches the cipher for an invalid-key %s device, even when connected',
|
|
206
|
+
version => {
|
|
207
|
+
const device = makeDevice(version, {key: 'nope'});
|
|
208
|
+
const written = attachSocketStub(device); // also forces connected = true
|
|
209
|
+
|
|
210
|
+
let result;
|
|
211
|
+
expect(() => { result = device.update({1: true}); }).not.toThrow();
|
|
212
|
+
expect(result).toBe(false);
|
|
213
|
+
expect(written).toHaveLength(0);
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
test('a fake device without a key is never treated as invalid', () => {
|
|
218
|
+
const device = new TuyaAccessory({
|
|
219
|
+
id: 'bf1234567890abcdef12',
|
|
220
|
+
name: 'Fake Device',
|
|
221
|
+
fake: true,
|
|
222
|
+
connect: false,
|
|
223
|
+
log: makeLog(),
|
|
224
|
+
});
|
|
225
|
+
expect(device._invalidKey).toBe(false);
|
|
226
|
+
expect(device.log.error).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
158
230
|
describe('message handler routing', () => {
|
|
159
231
|
const HANDLERS = ['_msgHandler_3_1', '_msgHandler_3_3', '_msgHandler_3_4', '_msgHandler_3_5'];
|
|
160
232
|
|
|
@@ -607,9 +679,10 @@ describe('graceful disconnect handling', () => {
|
|
|
607
679
|
jest.advanceTimersByTime(1000);
|
|
608
680
|
expect(pingErrors).toHaveLength(0);
|
|
609
681
|
|
|
610
|
-
// The log reflects a clean disconnect, never a ping failure.
|
|
611
|
-
|
|
612
|
-
|
|
682
|
+
// The log reflects a clean disconnect, never a ping failure. (A device
|
|
683
|
+
// recycling its socket is routine, so the disconnect is logged at debug.)
|
|
684
|
+
expect(device.log.debug).toHaveBeenCalledWith('Disconnected from', device.context.name);
|
|
685
|
+
const logged = device.log.debug.mock.calls.flat().join(' ');
|
|
613
686
|
expect(logged).not.toMatch(/ERR_PING_TIMED_OUT/);
|
|
614
687
|
});
|
|
615
688
|
|
|
@@ -138,6 +138,31 @@ describe('TuyaCloudApi — endpoints', () => {
|
|
|
138
138
|
await expect(api.getStatus('dev')).resolves.toEqual([{code: 'switch_1', value: true}]);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test('getShadowProperties returns the properties array (code + dp_id)', async () => {
|
|
142
|
+
const api = ready();
|
|
143
|
+
let path;
|
|
144
|
+
api._httpsRequest = async (method, p) => {
|
|
145
|
+
path = p;
|
|
146
|
+
return {success: true, result: {properties: [{code: 'switch_led', dp_id: 20, value: true}]}};
|
|
147
|
+
};
|
|
148
|
+
await expect(api.getShadowProperties('dev')).resolves.toEqual([{code: 'switch_led', dp_id: 20, value: true}]);
|
|
149
|
+
expect(path).toBe('/v2.0/cloud/thing/dev/shadow/properties');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('getShadowProperties returns null (never throws) when the shadow API is unavailable', async () => {
|
|
153
|
+
const api = ready();
|
|
154
|
+
api._httpsRequest = async () => ({success: false, code: 1106, msg: 'permission deny'});
|
|
155
|
+
await expect(api.getShadowProperties('dev')).resolves.toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('getDeviceInfo returns the device record (with online status)', async () => {
|
|
159
|
+
const api = ready();
|
|
160
|
+
let path;
|
|
161
|
+
api._httpsRequest = async (method, p) => { path = p; return {success: true, result: {id: 'dev', online: false, name: 'X'}}; };
|
|
162
|
+
await expect(api.getDeviceInfo('dev')).resolves.toEqual({id: 'dev', online: false, name: 'X'});
|
|
163
|
+
expect(path).toBe('/v1.0/devices/dev');
|
|
164
|
+
});
|
|
165
|
+
|
|
141
166
|
test('sendCommands posts the commands and reports success', async () => {
|
|
142
167
|
const api = ready();
|
|
143
168
|
let captured;
|
|
@@ -194,3 +219,58 @@ describe('TuyaCloudApi — endpoints', () => {
|
|
|
194
219
|
await expect(api.request('GET', '/x')).rejects.toThrow(/no permissions/);
|
|
195
220
|
});
|
|
196
221
|
});
|
|
222
|
+
|
|
223
|
+
describe('TuyaCloudApi — HTTP trace (debug.logCloudHttp)', () => {
|
|
224
|
+
const makeLog = () => ({info: () => {}, warn: () => {}, error: () => {}, debug: jest.fn()});
|
|
225
|
+
|
|
226
|
+
test('logHttp is off unless explicitly enabled', () => {
|
|
227
|
+
expect(makeApi().logHttp).toBe(false);
|
|
228
|
+
expect(makeApi({logHttp: true}).logHttp).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('redaction masks credentials but keeps device data', () => {
|
|
232
|
+
const api = makeApi();
|
|
233
|
+
const redacted = api._redactForLog({
|
|
234
|
+
client_id: 'aid1234567890', access_token: 'tok_abcdef123456', sign: 'SIGNATUREVALUE', t: '1700', nonce: 'n-1',
|
|
235
|
+
result: {access_token: 'inner-token-xyz', refresh_token: 'refresh-xyz', uid: 'uid-1234567', online: true},
|
|
236
|
+
commands: [{code: 'switch_1', value: true}]
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(redacted.client_id).not.toBe('aid1234567890');
|
|
240
|
+
expect(redacted.access_token).not.toBe('tok_abcdef123456');
|
|
241
|
+
expect(redacted.sign).not.toBe('SIGNATUREVALUE');
|
|
242
|
+
expect(redacted.result.access_token).not.toBe('inner-token-xyz');
|
|
243
|
+
expect(redacted.result.refresh_token).not.toBe('refresh-xyz');
|
|
244
|
+
expect(redacted.result.uid).not.toBe('uid-1234567');
|
|
245
|
+
// Non-secret fields and the actual device data-points pass through intact.
|
|
246
|
+
expect(redacted.t).toBe('1700');
|
|
247
|
+
expect(redacted.nonce).toBe('n-1');
|
|
248
|
+
expect(redacted.result.online).toBe(true);
|
|
249
|
+
expect(redacted.commands).toEqual([{code: 'switch_1', value: true}]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('no trace is logged when the switch is off', () => {
|
|
253
|
+
const log = makeLog();
|
|
254
|
+
const api = makeApi({log});
|
|
255
|
+
api._traceHttp('POST', '/v1.0/devices/dev/commands', {sign: 'X'}, '{"commands":[]}', 200, {success: true});
|
|
256
|
+
expect(log.debug).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('a trace carries the request/response but never the raw token or signature', () => {
|
|
260
|
+
const log = makeLog();
|
|
261
|
+
const api = makeApi({log, logHttp: true});
|
|
262
|
+
const headers = {client_id: 'aid', sign: 'SIGN_SECRET_VALUE', access_token: 'TOKEN_SECRET_VALUE', t: '1', nonce: 'n', 'Content-Type': 'application/json'};
|
|
263
|
+
const body = JSON.stringify({commands: [{code: 'wfh_open', value: true}]});
|
|
264
|
+
|
|
265
|
+
api._traceHttp('POST', '/v1.0/devices/dev/commands', headers, body, 200, {success: false, code: 2008, msg: 'command or value not support'});
|
|
266
|
+
|
|
267
|
+
expect(log.debug).toHaveBeenCalledTimes(1);
|
|
268
|
+
const line = log.debug.mock.calls[0][0];
|
|
269
|
+
expect(line).toContain('POST /v1.0/devices/dev/commands');
|
|
270
|
+
expect(line).toContain('wfh_open'); // the attempted command is visible
|
|
271
|
+
expect(line).toContain('2008'); // and the cloud's verdict
|
|
272
|
+
expect(line).toContain('command or value not support');
|
|
273
|
+
expect(line).not.toContain('TOKEN_SECRET_VALUE'); // …but secrets are not
|
|
274
|
+
expect(line).not.toContain('SIGN_SECRET_VALUE');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -9,6 +9,7 @@ function makeApi(status = [{code: 'switch_1', value: false}, {code: 'battery_per
|
|
|
9
9
|
return {
|
|
10
10
|
isConfigured: () => true,
|
|
11
11
|
getStatus: jest.fn().mockResolvedValue(status),
|
|
12
|
+
getDeviceInfo: jest.fn().mockResolvedValue({online: true}),
|
|
12
13
|
sendCommands: jest.fn().mockResolvedValue(true)
|
|
13
14
|
};
|
|
14
15
|
}
|
|
@@ -55,10 +56,31 @@ describe('TuyaCloudDevice', () => {
|
|
|
55
56
|
const api = makeApi();
|
|
56
57
|
const dev = makeDevice(api);
|
|
57
58
|
expect(dev.connected).toBe(false);
|
|
58
|
-
expect(
|
|
59
|
+
expect(dev.update({switch_1: true})).toBe(false);
|
|
59
60
|
expect(api.sendCommands).not.toHaveBeenCalled();
|
|
60
61
|
});
|
|
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
|
+
|
|
62
84
|
test('applyStatus emits only the changed data-points', async () => {
|
|
63
85
|
const api = makeApi();
|
|
64
86
|
const dev = makeDevice(api);
|
|
@@ -77,6 +99,83 @@ describe('TuyaCloudDevice', () => {
|
|
|
77
99
|
expect(changes).toHaveLength(0);
|
|
78
100
|
});
|
|
79
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
|
+
|
|
80
179
|
test('subscribes to realtime and re-reads state when the stream (re)connects', async () => {
|
|
81
180
|
const api = makeApi();
|
|
82
181
|
const messaging = Object.assign(new EventEmitter(), {
|
|
@@ -102,4 +201,157 @@ describe('TuyaCloudDevice', () => {
|
|
|
102
201
|
await new Promise(r => setImmediate(r));
|
|
103
202
|
expect(api.getStatus).toHaveBeenCalledWith('dev1');
|
|
104
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 device reachable over the LAN logs the fallback failure as harmless (debug, not warn)', () => {
|
|
250
|
+
jest.useFakeTimers();
|
|
251
|
+
try {
|
|
252
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => true});
|
|
253
|
+
const warn = jest.spyOn(log, 'warn');
|
|
254
|
+
const debug = jest.spyOn(log, 'debug');
|
|
255
|
+
|
|
256
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
257
|
+
|
|
258
|
+
expect(warn).not.toHaveBeenCalled();
|
|
259
|
+
expect(debug).toHaveBeenCalledTimes(1);
|
|
260
|
+
expect(debug.mock.calls[0][0]).toContain('reachable over the LAN');
|
|
261
|
+
} finally {
|
|
262
|
+
jest.useRealTimers();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('the same failure re-surfaces (debug → warn) when the LAN path drops', () => {
|
|
267
|
+
jest.useFakeTimers();
|
|
268
|
+
try {
|
|
269
|
+
let lanUp = true;
|
|
270
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => lanUp});
|
|
271
|
+
const warn = jest.spyOn(log, 'warn');
|
|
272
|
+
const err = new Error('permission deny (code 1106)');
|
|
273
|
+
|
|
274
|
+
dev._onConnectFailure(err); // LAN up → harmless, debug only
|
|
275
|
+
expect(warn).not.toHaveBeenCalled();
|
|
276
|
+
|
|
277
|
+
lanUp = false;
|
|
278
|
+
dev._onConnectFailure(err); // identical error, but LAN now down → surfaced
|
|
279
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
280
|
+
expect(warn.mock.calls[0][0]).toContain("isn't reachable over the LAN");
|
|
281
|
+
} finally {
|
|
282
|
+
jest.useRealTimers();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('permission-deny adds the offline-unbinding hint; an unrelated error does not', () => {
|
|
287
|
+
jest.useFakeTimers();
|
|
288
|
+
try {
|
|
289
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'}); // LAN-capable, LAN down
|
|
290
|
+
const warn = jest.spyOn(log, 'warn');
|
|
291
|
+
|
|
292
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
293
|
+
expect(warn.mock.calls[0][0]).toContain('offline for a long time');
|
|
294
|
+
|
|
295
|
+
dev._onConnectFailure(new Error('request timed out'));
|
|
296
|
+
expect(warn.mock.calls[1][0]).not.toContain('offline for a long time');
|
|
297
|
+
} finally {
|
|
298
|
+
jest.useRealTimers();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('a different error message is surfaced again, not suppressed', () => {
|
|
303
|
+
jest.useFakeTimers();
|
|
304
|
+
try {
|
|
305
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
306
|
+
const warn = jest.spyOn(log, 'warn');
|
|
307
|
+
|
|
308
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
309
|
+
dev._onConnectFailure(new Error('request timed out'));
|
|
310
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
311
|
+
} finally {
|
|
312
|
+
jest.useRealTimers();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('backoff is capped at 30 minutes', () => {
|
|
317
|
+
jest.useFakeTimers();
|
|
318
|
+
try {
|
|
319
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
320
|
+
jest.spyOn(log, 'warn');
|
|
321
|
+
jest.spyOn(log, 'debug');
|
|
322
|
+
const err = new Error('permission deny (code 1106)');
|
|
323
|
+
|
|
324
|
+
for (let i = 0; i < 20; i++) dev._onConnectFailure(err);
|
|
325
|
+
expect(dev._retryDelay).toBe(1800000);
|
|
326
|
+
} finally {
|
|
327
|
+
jest.useRealTimers();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('reconnecting after a failure logs recovery once and resets backoff', async () => {
|
|
332
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
333
|
+
dev._lastConnectError = 'permission deny (code 1106)';
|
|
334
|
+
dev._retryDelay = 240000;
|
|
335
|
+
const info = jest.spyOn(log, 'info');
|
|
336
|
+
|
|
337
|
+
await dev._connect(); // mocked api resolves → success path runs
|
|
338
|
+
|
|
339
|
+
expect(dev._lastConnectError).toBeNull();
|
|
340
|
+
expect(dev._retryDelay).toBe(0);
|
|
341
|
+
expect(info).toHaveBeenCalledWith(expect.stringContaining('connection restored'));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('a stopped device does not schedule another retry', () => {
|
|
345
|
+
jest.useFakeTimers();
|
|
346
|
+
try {
|
|
347
|
+
const dev = makeDevice(makeApi(), null, {key: 'abc'});
|
|
348
|
+
jest.spyOn(log, 'warn');
|
|
349
|
+
dev.stop();
|
|
350
|
+
dev._onConnectFailure(new Error('permission deny (code 1106)'));
|
|
351
|
+
expect(dev._retryTimer).toBeNull();
|
|
352
|
+
} finally {
|
|
353
|
+
jest.useRealTimers();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
});
|
|
105
357
|
});
|
|
@@ -56,6 +56,30 @@ describe('TuyaCloudMessaging — decryption + dispatch', () => {
|
|
|
56
56
|
expect(received[0]).toEqual([{code: 'switch_1', value: true, t}, {code: 'battery_percentage', value: 80, t}]);
|
|
57
57
|
});
|
|
58
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
|
+
|
|
59
83
|
test('decrypts a legacy ECB (v1.0) frame too', () => {
|
|
60
84
|
const mq = makeIdle();
|
|
61
85
|
const received = [];
|
|
@@ -86,9 +110,4 @@ describe('TuyaCloudMessaging — decryption + dispatch', () => {
|
|
|
86
110
|
mq.subscribeDevice('DEV1', () => { throw new Error('should not be called'); });
|
|
87
111
|
expect(() => mq._onMessage('topic', Buffer.from('not json'))).not.toThrow();
|
|
88
112
|
});
|
|
89
|
-
|
|
90
|
-
test('reports whether realtime is available (mqtt installed)', () => {
|
|
91
|
-
// mqtt is an (optional) dependency of this project, so it should load.
|
|
92
|
-
expect(typeof TuyaCloudMessaging.isAvailable()).toBe('boolean');
|
|
93
|
-
});
|
|
94
113
|
});
|