homebridge-tuya-plus 3.13.0 → 3.13.1-dev.2

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.
@@ -0,0 +1,626 @@
1
+ 'use strict';
2
+
3
+ // Protocol-level tests for TuyaAccessory.
4
+ //
5
+ // Covers version routing (3.1 - 3.5), packet construction/parsing for the
6
+ // 0x55AA (ECB/HMAC) and 0x6699 (GCM) stacks, the 3.4+/3.5+ session-key
7
+ // handshake, and forward compatibility: a device reporting a newer protocol
8
+ // version (e.g. a future 3.6) must be served by the newest (3.5/GCM) stack
9
+ // with the reported version string in payload headers - never by a broken
10
+ // mix of legacy paths.
11
+
12
+ const crypto = require('crypto');
13
+ const EventEmitter = require('events');
14
+ const TuyaAccessory = require('../lib/TuyaAccessory');
15
+ const TuyaDiscovery = require('../lib/TuyaDiscovery');
16
+
17
+ const KEY = '0123456789abcdef'; // 16-byte AES-128 local key
18
+
19
+ const makeLog = () => {
20
+ const log = jest.fn();
21
+ log.info = jest.fn();
22
+ log.warn = jest.fn();
23
+ log.error = jest.fn();
24
+ log.debug = jest.fn();
25
+ return log;
26
+ };
27
+
28
+ const makeDevice = (version, extra = {}) => new TuyaAccessory({
29
+ id: 'bf1234567890abcdef12',
30
+ key: KEY,
31
+ ip: '192.168.1.50',
32
+ name: 'Protocol Test Device',
33
+ version,
34
+ connect: false,
35
+ log: makeLog(),
36
+ ...extra,
37
+ });
38
+
39
+ const attachSocketStub = device => {
40
+ const written = [];
41
+ device.connected = true;
42
+ device._socket = {
43
+ write: buf => { written.push(buf); return true; },
44
+ _ping: jest.fn(),
45
+ };
46
+ return written;
47
+ };
48
+
49
+ // ---- 0x6699 (3.5+) frame helpers, mirroring the documented format ----
50
+
51
+ const buildPacket35 = (key, cmd, seq, plainPayload) => {
52
+ const iv = crypto.randomBytes(12);
53
+ const unknown = Buffer.alloc(2);
54
+ const seqBuf = Buffer.alloc(4); seqBuf.writeUInt32BE(seq, 0);
55
+ const cmdBuf = Buffer.alloc(4); cmdBuf.writeUInt32BE(cmd, 0);
56
+ const lenBuf = Buffer.alloc(4); lenBuf.writeUInt32BE(12 + plainPayload.length + 16, 0);
57
+ const aad = Buffer.concat([unknown, seqBuf, cmdBuf, lenBuf]);
58
+
59
+ const cipher = crypto.createCipheriv('aes-128-gcm', key, iv);
60
+ cipher.setAAD(aad);
61
+ const encrypted = Buffer.concat([cipher.update(plainPayload), cipher.final()]);
62
+ const tag = cipher.getAuthTag();
63
+
64
+ return Buffer.concat([
65
+ Buffer.from('00006699', 'hex'),
66
+ aad,
67
+ iv,
68
+ encrypted,
69
+ tag,
70
+ Buffer.from('00009966', 'hex'),
71
+ ]);
72
+ };
73
+
74
+ const decryptPacket35 = (key, pkt) => {
75
+ const len = pkt.length;
76
+ const iv = pkt.slice(18, 30);
77
+ const encrypted = pkt.slice(30, len - 20);
78
+ const tag = pkt.slice(len - 20, len - 4);
79
+ const aad = pkt.slice(4, 18);
80
+
81
+ const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv);
82
+ decipher.setAAD(aad);
83
+ decipher.setAuthTag(tag);
84
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
85
+ };
86
+
87
+ // "3.x" + 12 null bytes + retcode-less payload (client -> device has no retcode)
88
+ const versionHeader = version => Buffer.concat([Buffer.from(version), Buffer.alloc(12)]);
89
+
90
+ // PKCS#7 used by 3.4 (ECB without auto padding)
91
+ const pkcs7pad = data => {
92
+ const padding = 0x10 - (data.length & 0xf);
93
+ return Buffer.concat([data, Buffer.alloc(padding, padding)]);
94
+ };
95
+
96
+ const ecbEncrypt = (key, data) => {
97
+ const cipher = crypto.createCipheriv('aes-128-ecb', key, null);
98
+ cipher.setAutoPadding(false);
99
+ const out = cipher.update(pkcs7pad(data));
100
+ cipher.final();
101
+ return out;
102
+ };
103
+
104
+ const hmac256 = (key, data) => crypto.createHmac('sha256', key).update(data).digest();
105
+
106
+ // CRC32 as used by the 3.2/3.3 frames
107
+ const crc32Table = (() => {
108
+ const table = [];
109
+ for (let i = 0; i < 256; i++) {
110
+ let crc = i;
111
+ for (let j = 8; j > 0; j--) crc = (crc & 1) ? (crc >>> 1) ^ 3988292384 : crc >>> 1;
112
+ table.push(crc);
113
+ }
114
+ return table;
115
+ })();
116
+ const crc32 = buffer => {
117
+ let crc = 0xffffffff;
118
+ for (let i = 0; i < buffer.length; i++) crc = crc32Table[buffer[i] ^ (crc & 0xff)] ^ (crc >>> 8);
119
+ return ~crc;
120
+ };
121
+
122
+ afterEach(() => {
123
+ jest.restoreAllMocks();
124
+ jest.useRealTimers();
125
+ });
126
+
127
+ describe('protocol version normalization', () => {
128
+ test.each([
129
+ ['3.1', '3.1', 3.1],
130
+ ['3.3', '3.3', 3.3],
131
+ [3.3, '3.3', 3.3], // numbers from config.json
132
+ ['3.4', '3.4', 3.4],
133
+ [3.4, '3.4', 3.4],
134
+ ['3.5', '3.5', 3.5],
135
+ ['3.6', '3.6', 3.6], // future version: kept verbatim for headers
136
+ [3.6, '3.6', 3.6],
137
+ [undefined, '3.5', 3.5],
138
+ ['', '3.5', 3.5],
139
+ ])('version %p becomes context %p / numeric %p', (input, expectedStr, expectedNum) => {
140
+ const device = makeDevice(input);
141
+ expect(device.context.version).toBe(expectedStr);
142
+ expect(device._protocolVersion).toBe(expectedNum);
143
+ });
144
+
145
+ test('unparseable version falls back to 3.5 with a warning', () => {
146
+ const device = makeDevice('bananas');
147
+ expect(device.context.version).toBe('3.5');
148
+ expect(device._protocolVersion).toBe(3.5);
149
+ expect(device.log.warn).toHaveBeenCalled();
150
+ });
151
+
152
+ test('versions newer than 3.5 log an informational note', () => {
153
+ const device = makeDevice('3.6');
154
+ expect(device.log.info).toHaveBeenCalledWith(expect.stringContaining('3.6'));
155
+ });
156
+ });
157
+
158
+ describe('message handler routing', () => {
159
+ const HANDLERS = ['_msgHandler_3_1', '_msgHandler_3_3', '_msgHandler_3_4', '_msgHandler_3_5'];
160
+
161
+ const selectedHandler = async version => {
162
+ const spies = {};
163
+ HANDLERS.forEach(name => {
164
+ spies[name] = jest.spyOn(TuyaAccessory.prototype, name)
165
+ .mockImplementation((task, callback) => callback());
166
+ });
167
+
168
+ const device = makeDevice(version);
169
+ await device._msgQueue.push({msg: Buffer.alloc(0)});
170
+
171
+ const called = HANDLERS.filter(name => spies[name].mock.calls.length > 0);
172
+ expect(called).toHaveLength(1);
173
+ return called[0];
174
+ };
175
+
176
+ test.each([
177
+ ['3.1', '_msgHandler_3_1'],
178
+ ['3.2', '_msgHandler_3_3'], // 3.2 shares the 3.3 stack
179
+ ['3.3', '_msgHandler_3_3'],
180
+ [3.3, '_msgHandler_3_3'],
181
+ ['3.4', '_msgHandler_3_4'],
182
+ [3.4, '_msgHandler_3_4'],
183
+ ['3.5', '_msgHandler_3_5'],
184
+ ['3.6', '_msgHandler_3_5'], // newer than 3.5: newest stack
185
+ [3.6, '_msgHandler_3_5'],
186
+ [undefined, '_msgHandler_3_5'],
187
+ ])('version %p uses %s', async (version, expected) => {
188
+ expect(await selectedHandler(version)).toBe(expected);
189
+ });
190
+ });
191
+
192
+ describe('send routing', () => {
193
+ const SENDERS = ['_send_3_1', '_send_3_3', '_send_3_4', '_send_3_5'];
194
+
195
+ const selectedSender = version => {
196
+ const spies = {};
197
+ SENDERS.forEach(name => {
198
+ spies[name] = jest.spyOn(TuyaAccessory.prototype, name).mockReturnValue(true);
199
+ });
200
+
201
+ const device = makeDevice(version);
202
+ device.connected = true;
203
+ device._send({cmd: 9});
204
+
205
+ const called = SENDERS.filter(name => spies[name].mock.calls.length > 0);
206
+ expect(called).toHaveLength(1);
207
+ return called[0];
208
+ };
209
+
210
+ test.each([
211
+ ['3.1', '_send_3_1'],
212
+ ['3.2', '_send_3_3'],
213
+ ['3.3', '_send_3_3'],
214
+ [3.3, '_send_3_3'],
215
+ ['3.4', '_send_3_4'],
216
+ [3.4, '_send_3_4'],
217
+ ['3.5', '_send_3_5'],
218
+ ['3.6', '_send_3_5'],
219
+ [3.6, '_send_3_5'],
220
+ ])('version %p uses %s', (version, expected) => {
221
+ expect(selectedSender(version)).toBe(expected);
222
+ });
223
+ });
224
+
225
+ describe('0x6699 (3.5+) sends', () => {
226
+ test('control packet for a 3.6 device is valid GCM with a 3.6 version header', () => {
227
+ const device = makeDevice('3.6');
228
+ const written = attachSocketStub(device);
229
+
230
+ expect(device.update({1: true})).toBe(true);
231
+ expect(written).toHaveLength(1);
232
+
233
+ const pkt = written[0];
234
+ expect(pkt.readUInt32BE(0)).toBe(0x00006699);
235
+ expect(pkt.readUInt32BE(pkt.length - 4)).toBe(0x00009966);
236
+ expect(pkt.readUInt32BE(10)).toBe(13); // CONTROL_NEW
237
+ // length field counts IV + ciphertext + tag
238
+ expect(pkt.readUInt32BE(14)).toBe(pkt.length - 22);
239
+
240
+ const plain = decryptPacket35(KEY, pkt); // throws on AAD/tag mismatch
241
+ expect(plain.slice(0, 3).toString()).toBe('3.6');
242
+ expect([...plain.slice(3, 15)].every(b => b === 0)).toBe(true);
243
+
244
+ const json = JSON.parse(plain.slice(15).toString());
245
+ expect(json.protocol).toBe(5);
246
+ expect(json.data.dps).toEqual({'1': true});
247
+ expect(json.data.devId).toBe(device.context.id);
248
+ });
249
+
250
+ test('initial query for 3.5+/3.6 devices uses DP_QUERY_NEW (16) without version header', () => {
251
+ const device = makeDevice('3.6');
252
+ const written = attachSocketStub(device);
253
+
254
+ device.update();
255
+ const pkt = written[0];
256
+ expect(pkt.readUInt32BE(10)).toBe(16);
257
+
258
+ const json = JSON.parse(decryptPacket35(KEY, pkt).toString());
259
+ expect(json.gwId).toBe(device.context.id);
260
+ expect(json.devId).toBe(device.context.id);
261
+ });
262
+
263
+ test('control packet for a 3.5 device keeps the 3.5 version header', () => {
264
+ const device = makeDevice('3.5');
265
+ const written = attachSocketStub(device);
266
+
267
+ device.update({2: 'low'});
268
+ const plain = decryptPacket35(KEY, written[0]);
269
+ expect(plain.slice(0, 3).toString()).toBe('3.5');
270
+ });
271
+ });
272
+
273
+ describe('0x6699 (3.5+) receives', () => {
274
+ const statusPayload = (version, dps) => Buffer.concat([
275
+ Buffer.alloc(4), // return code
276
+ versionHeader(version),
277
+ Buffer.from(JSON.stringify({protocol: 4, t: 1750000000, data: {dps}})),
278
+ ]);
279
+
280
+ test('a 3.6 device status push decodes and updates state', async () => {
281
+ const device = makeDevice('3.6');
282
+ const changes = [];
283
+ device.on('change', c => changes.push(c));
284
+
285
+ const pkt = buildPacket35(KEY, 8, 7, statusPayload('3.6', {'1': true, '4': 22}));
286
+ await new Promise(resolve => device._msgHandler_3_5({msg: pkt}, resolve));
287
+
288
+ expect(changes).toEqual([{'1': true, '4': 22}]);
289
+ expect(device.state).toEqual({'1': true, '4': 22});
290
+ });
291
+
292
+ test('version header mismatch is tolerated (device answers 3.5, configured 3.6)', async () => {
293
+ const device = makeDevice('3.6');
294
+ const pkt = buildPacket35(KEY, 8, 8, statusPayload('3.5', {'20': false}));
295
+ await new Promise(resolve => device._msgHandler_3_5({msg: pkt}, resolve));
296
+ expect(device.state).toEqual({'20': false});
297
+ });
298
+
299
+ test('DP_QUERY_NEW (16) responses without version header decode', async () => {
300
+ const device = makeDevice('3.5');
301
+ const payload = Buffer.concat([
302
+ Buffer.alloc(4),
303
+ Buffer.from(JSON.stringify({dps: {'1': false}})),
304
+ ]);
305
+ const pkt = buildPacket35(KEY, 16, 2, payload);
306
+ await new Promise(resolve => device._msgHandler_3_5({msg: pkt}, resolve));
307
+ expect(device.state).toEqual({'1': false});
308
+ });
309
+
310
+ test('messages are decrypted with the session key once negotiated', async () => {
311
+ const device = makeDevice('3.6');
312
+ device.session_key = crypto.randomBytes(16);
313
+
314
+ const pkt = buildPacket35(device.session_key, 8, 9, statusPayload('3.6', {'3': 'auto'}));
315
+ await new Promise(resolve => device._msgHandler_3_5({msg: pkt}, resolve));
316
+ expect(device.state).toEqual({'3': 'auto'});
317
+ });
318
+ });
319
+
320
+ describe('3.5+ session key negotiation', () => {
321
+ test.each(['3.5', '3.6'])('handshake derives the session key and queries state (version %s)', async version => {
322
+ jest.useFakeTimers();
323
+
324
+ const device = makeDevice(version);
325
+ const written = attachSocketStub(device);
326
+ const connected = jest.fn();
327
+ device.on('connect', connected);
328
+
329
+ // Normally generated when the socket becomes ready (BIND / cmd 3)
330
+ device._tmpLocalKey = crypto.randomBytes(16);
331
+
332
+ // Device replies to BIND with: retcode + remote nonce + HMAC(local nonce)
333
+ const remoteNonce = crypto.randomBytes(16);
334
+ const sessNegResponse = Buffer.concat([
335
+ Buffer.alloc(4),
336
+ remoteNonce,
337
+ hmac256(KEY, device._tmpLocalKey),
338
+ ]);
339
+ const pkt = buildPacket35(KEY, 4, 1, sessNegResponse);
340
+ await new Promise(resolve => device._msgHandler_3_5({msg: pkt}, resolve));
341
+
342
+ // Expected session key: GCM(localNonce XOR remoteNonce) under the local
343
+ // key with IV = first 12 bytes of the local nonce
344
+ const xored = Buffer.from(device._tmpLocalKey);
345
+ for (let i = 0; i < xored.length; i++) xored[i] ^= remoteNonce[i];
346
+ const cipher = crypto.createCipheriv('aes-128-gcm', KEY, device._tmpLocalKey.slice(0, 12));
347
+ cipher.setAAD(Buffer.alloc(0));
348
+ const expectedSessionKey = Buffer.concat([cipher.update(xored), cipher.final()]);
349
+
350
+ expect(Buffer.isBuffer(device.session_key)).toBe(true);
351
+ expect(device.session_key.equals(expectedSessionKey)).toBe(true);
352
+ expect(connected).toHaveBeenCalled();
353
+
354
+ // First write: SESS_KEY_NEG_FINISH (cmd 5) carrying HMAC(remote nonce),
355
+ // still encrypted with the local key
356
+ expect(written.length).toBe(2);
357
+ expect(written[0].readUInt32BE(10)).toBe(5);
358
+ expect(decryptPacket35(KEY, written[0]).equals(hmac256(KEY, remoteNonce))).toBe(true);
359
+
360
+ // Second write: state query (cmd 16) already under the session key
361
+ expect(written[1].readUInt32BE(10)).toBe(16);
362
+ const query = JSON.parse(decryptPacket35(device.session_key, written[1]).toString());
363
+ expect(query.gwId).toBe(device.context.id);
364
+
365
+ jest.clearAllTimers();
366
+ });
367
+
368
+ test('handshake aborts on local-nonce HMAC mismatch', async () => {
369
+ jest.useFakeTimers();
370
+
371
+ const device = makeDevice('3.6');
372
+ attachSocketStub(device);
373
+ device._tmpLocalKey = crypto.randomBytes(16);
374
+
375
+ const badResponse = Buffer.concat([
376
+ Buffer.alloc(4),
377
+ crypto.randomBytes(16),
378
+ crypto.randomBytes(32), // wrong HMAC
379
+ ]);
380
+ const pkt = buildPacket35(KEY, 4, 1, badResponse);
381
+
382
+ expect(() => device._msgHandler_3_5({msg: pkt}, () => {})).toThrow(/HMAC mismatch/);
383
+ expect(device.session_key).toBeNull();
384
+
385
+ jest.clearAllTimers();
386
+ });
387
+ });
388
+
389
+ describe('0x55AA (3.4) stack', () => {
390
+ test('control packet is ECB-encrypted with a 3.4 header and valid HMAC', () => {
391
+ const device = makeDevice('3.4');
392
+ const written = attachSocketStub(device);
393
+
394
+ device.update({1: false});
395
+ const pkt = written[0];
396
+ const len = pkt.length;
397
+
398
+ expect(pkt.readUInt32BE(0)).toBe(0x000055aa);
399
+ expect(pkt.readUInt32BE(len - 4)).toBe(0x0000aa55);
400
+ expect(pkt.readUInt32BE(8)).toBe(13); // CONTROL_NEW
401
+ expect(pkt.readUInt32BE(12)).toBe(len - 16); // ciphertext + hmac + suffix
402
+
403
+ const mac = pkt.slice(len - 36, len - 4);
404
+ expect(mac.equals(hmac256(KEY, pkt.slice(0, len - 36)))).toBe(true);
405
+
406
+ const decipher = crypto.createDecipheriv('aes-128-ecb', KEY, null);
407
+ decipher.setAutoPadding(false);
408
+ let plain = decipher.update(pkt.slice(16, len - 36));
409
+ decipher.final();
410
+ plain = plain.slice(0, plain.length - plain[plain.length - 1]);
411
+
412
+ expect(plain.slice(0, 3).toString()).toBe('3.4');
413
+ const json = JSON.parse(plain.slice(15).toString());
414
+ expect(json.protocol).toBe(5);
415
+ expect(json.data.dps).toEqual({'1': false});
416
+ });
417
+
418
+ test('status push (cmd 8) with retcode and encrypted 3.4 header updates state', async () => {
419
+ const device = makeDevice('3.4');
420
+
421
+ const plain = Buffer.concat([
422
+ versionHeader('3.4'),
423
+ Buffer.from(JSON.stringify({protocol: 4, t: 1750000000, data: {dps: {'5': 'mid'}}})),
424
+ ]);
425
+ const encrypted = ecbEncrypt(KEY, plain);
426
+
427
+ const header = Buffer.alloc(16);
428
+ header.writeUInt32BE(0x000055aa, 0);
429
+ header.writeUInt32BE(3, 4); // seq
430
+ header.writeUInt32BE(8, 8); // STATUS
431
+ header.writeUInt32BE(4 + encrypted.length + 36, 12); // retcode + payload + hmac + suffix
432
+ const retcode = Buffer.alloc(4);
433
+
434
+ const body = Buffer.concat([header, retcode, encrypted]);
435
+ const pkt = Buffer.concat([body, hmac256(KEY, body), Buffer.from('0000aa55', 'hex')]);
436
+
437
+ await new Promise(resolve => device._msgHandler_3_4({msg: pkt}, resolve));
438
+ expect(device.state).toEqual({'5': 'mid'});
439
+ });
440
+ });
441
+
442
+ describe('0x55AA (3.2/3.3) stack', () => {
443
+ test('3.2 devices send control packets with a cleartext 3.2 header', () => {
444
+ const device = makeDevice('3.2');
445
+ const written = attachSocketStub(device);
446
+
447
+ device.update({1: true});
448
+ const pkt = written[0];
449
+ const len = pkt.length;
450
+
451
+ expect(pkt.readUInt32BE(0)).toBe(0x000055aa);
452
+ expect(pkt.readUInt32BE(8)).toBe(7); // CONTROL
453
+ expect(pkt.slice(16, 19).toString()).toBe('3.2');
454
+ expect(pkt.readInt32BE(len - 8)).toBe(crc32(pkt.slice(0, len - 8)));
455
+
456
+ const decipher = crypto.createDecipheriv('aes-128-ecb', KEY, '');
457
+ const plain = Buffer.concat([decipher.update(pkt.slice(31, len - 8)), decipher.final()]);
458
+ expect(JSON.parse(plain.toString()).dps).toEqual({'1': true});
459
+ });
460
+
461
+ test('3.3 devices keep the 3.3 cleartext header', () => {
462
+ const device = makeDevice('3.3');
463
+ const written = attachSocketStub(device);
464
+
465
+ device.update({1: true});
466
+ expect(written[0].slice(16, 19).toString()).toBe('3.3');
467
+ });
468
+
469
+ test('3.3 initial query uses DP_QUERY (10)', () => {
470
+ const device = makeDevice('3.3');
471
+ const written = attachSocketStub(device);
472
+
473
+ device.update();
474
+ expect(written[0].readUInt32BE(8)).toBe(10);
475
+ });
476
+
477
+ test('3.2 status messages decode through the 3.3 handler', async () => {
478
+ const device = makeDevice('3.2');
479
+
480
+ const json = Buffer.from(JSON.stringify({dps: {'1': true}}));
481
+ const cipher = crypto.createCipheriv('aes-128-ecb', KEY, '');
482
+ const encrypted = Buffer.concat([cipher.update(json), cipher.final()]);
483
+
484
+ const header = Buffer.alloc(16);
485
+ header.writeUInt32BE(0x000055aa, 0);
486
+ header.writeUInt32BE(1, 4);
487
+ header.writeUInt32BE(8, 8); // STATUS
488
+ header.writeUInt32BE(15 + encrypted.length + 8, 12);
489
+ const pkt = Buffer.concat([
490
+ header,
491
+ versionHeader('3.2'),
492
+ encrypted,
493
+ Buffer.alloc(4), // crc (not validated on receive)
494
+ Buffer.from('0000aa55', 'hex'),
495
+ ]);
496
+
497
+ await new Promise(resolve => device._msgHandler_3_3({msg: pkt}, resolve));
498
+ expect(device.state).toEqual({'1': true});
499
+ });
500
+ });
501
+
502
+ describe('discovery of 3.5+/3.6 devices', () => {
503
+ const GCM_DISCOVERY_KEY = crypto.createHash('md5').update('yGAdlopoPVldABfn').digest();
504
+
505
+ afterEach(() => {
506
+ TuyaDiscovery.removeAllListeners();
507
+ TuyaDiscovery.discovered.clear();
508
+ TuyaDiscovery.limitedIds.splice(0);
509
+ });
510
+
511
+ test('a 6699 broadcast advertising version 3.6 is passed through verbatim', () => {
512
+ TuyaDiscovery.log = makeLog();
513
+
514
+ const payload = Buffer.concat([
515
+ Buffer.alloc(4),
516
+ Buffer.from(JSON.stringify({ip: '192.168.1.99', gwId: 'bf9999999999999999ff', version: '3.6'})),
517
+ ]);
518
+ const pkt = buildPacket35(GCM_DISCOVERY_KEY, 0, 1, payload);
519
+
520
+ const found = [];
521
+ TuyaDiscovery.on('discover', d => found.push(d));
522
+ TuyaDiscovery._handleV35(pkt, 7000, {address: '192.168.1.99'});
523
+
524
+ expect(found).toHaveLength(1);
525
+ expect(found[0].id).toBe('bf9999999999999999ff');
526
+ expect(found[0].ip).toBe('192.168.1.99');
527
+ expect(found[0].version).toBe('3.6');
528
+ });
529
+ });
530
+
531
+ // Many Tuya devices (e.g. LED ceiling lights) recycle their long-lived LAN
532
+ // sockets every few minutes, closing the connection with a TCP FIN ('end').
533
+ // The handler must tear that connection down and reconnect promptly instead of
534
+ // leaving a half-dead socket whose stale heartbeat timer later fires a
535
+ // misleading ERR_PING_TIMED_OUT - which used to be the only thing that
536
+ // triggered a reconnect.
537
+ describe('graceful disconnect handling', () => {
538
+ const net = require('net');
539
+ let realNetSocket;
540
+ let createdSockets;
541
+
542
+ const makeFakeSocket = () => {
543
+ const s = new EventEmitter();
544
+ s.destroyed = false;
545
+ s.setKeepAlive = () => {};
546
+ s.setNoDelay = () => {};
547
+ s.connect = jest.fn();
548
+ s.write = jest.fn(() => true);
549
+ s.destroy = jest.fn(function destroy() { this.destroyed = true; });
550
+ return s;
551
+ };
552
+
553
+ beforeEach(() => {
554
+ jest.useFakeTimers();
555
+ createdSockets = [];
556
+ realNetSocket = net.Socket;
557
+ // TuyaAccessory calls net.Socket() dynamically, so swapping the property
558
+ // is enough to hand it a controllable fake (no real network I/O).
559
+ net.Socket = function FakeSocket() {
560
+ const s = makeFakeSocket();
561
+ createdSockets.push(s);
562
+ return s;
563
+ };
564
+ });
565
+
566
+ afterEach(() => {
567
+ net.Socket = realNetSocket;
568
+ jest.clearAllTimers();
569
+ jest.useRealTimers();
570
+ });
571
+
572
+ // Bring a device up to a steady, connected state on a fake socket.
573
+ const establish = device => {
574
+ device._connect();
575
+ const socket = device._socket;
576
+ // An established connection has already cleared its connect watchdog.
577
+ clearTimeout(socket._connTimeout);
578
+ socket._connTimeout = null;
579
+ device.connected = true;
580
+ return socket;
581
+ };
582
+
583
+ test("a device-initiated 'end' clears the heartbeat, tears down, and schedules a reconnect without a spurious ping timeout", () => {
584
+ const device = makeDevice('3.5');
585
+ const socket = establish(device);
586
+
587
+ // A heartbeat retry timer is in flight. With the old handler it survived
588
+ // the disconnect and later emitted a misleading ERR_PING_TIMED_OUT.
589
+ const pingErrors = [];
590
+ socket.on('error', err => {
591
+ if (err && err.message === 'ERR_PING_TIMED_OUT') pingErrors.push(err);
592
+ });
593
+ socket._pinger = setTimeout(
594
+ () => device._socket.emit('error', new Error('ERR_PING_TIMED_OUT')),
595
+ 50
596
+ );
597
+
598
+ // The device closes the LAN connection on its own (TCP FIN -> 'end').
599
+ socket.emit('end');
600
+
601
+ expect(device.connected).toBe(false);
602
+ expect(socket._pinger).toBeNull(); // stale timer cleared
603
+ expect(socket.destroy).toHaveBeenCalledTimes(1); // socket torn down
604
+ expect(socket._errorReconnect).toBeTruthy(); // a reconnect is scheduled
605
+
606
+ // Advance well past where the leaked timer would have fired: it must not.
607
+ jest.advanceTimersByTime(1000);
608
+ expect(pingErrors).toHaveLength(0);
609
+
610
+ // The log reflects a clean disconnect, never a ping failure.
611
+ expect(device.log.info).toHaveBeenCalledWith('Disconnected from', device.context.name);
612
+ const logged = device.log.info.mock.calls.flat().join(' ');
613
+ expect(logged).not.toMatch(/ERR_PING_TIMED_OUT/);
614
+ });
615
+
616
+ test("the 'close' teardown clears any lingering heartbeat timer", () => {
617
+ const device = makeDevice('3.5');
618
+ const socket = establish(device);
619
+
620
+ socket._pinger = setTimeout(() => {}, 10000);
621
+ socket.emit('close');
622
+
623
+ expect(socket._pinger).toBeNull();
624
+ expect(device.connected).toBe(false);
625
+ });
626
+ });
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ const { HAP } = require('./support/mocks');
4
+ const { Categories } = HAP;
5
+
6
+ // Mirror of index.js CLASS_DEF. Every configurable device type must resolve to
7
+ // a real HAP category: getCategory() returning undefined makes HomeKit store
8
+ // the accessory as Categories.OTHER, so the cached category never matches the
9
+ // expectation again and the accessory is unregistered & recreated on every
10
+ // Homebridge restart — wiping its name, room and automations (issue #45).
11
+ const CLASS_DEF = {
12
+ outlet: require('../lib/OutletAccessory'),
13
+ simplelight: require('../lib/SimpleLightAccessory'),
14
+ rgbtwlight: require('../lib/RGBTWLightAccessory'),
15
+ rgbtwoutlet: require('../lib/RGBTWOutletAccessory'),
16
+ twlight: require('../lib/TWLightAccessory'),
17
+ multioutlet: require('../lib/MultiOutletAccessory'),
18
+ custommultioutlet: require('../lib/CustomMultiOutletAccessory'),
19
+ airconditioner: require('../lib/AirConditionerAccessory'),
20
+ airpurifier: require('../lib/AirPurifierAccessory'),
21
+ dehumidifier: require('../lib/DehumidifierAccessory'),
22
+ convector: require('../lib/ConvectorAccessory'),
23
+ garagedoor: require('../lib/GarageDoorAccessory'),
24
+ simplegaragedoor: require('../lib/SimpleGarageDoorAccessory'),
25
+ simpledimmer: require('../lib/SimpleDimmerAccessory'),
26
+ simpledimmer2: require('../lib/SimpleDimmer2Accessory'),
27
+ simpleblinds: require('../lib/SimpleBlindsAccessory'),
28
+ simpleheater: require('../lib/SimpleHeaterAccessory'),
29
+ switch: require('../lib/SwitchAccessory'),
30
+ irrigationsystem: require('../lib/IrrigationSystemAccessory'),
31
+ fan: require('../lib/SimpleFanAccessory'),
32
+ fanlight: require('../lib/SimpleFanLightAccessory'),
33
+ watervalve: require('../lib/ValveAccessory'),
34
+ oildiffuser: require('../lib/OilDiffuserAccessory'),
35
+ doorbell: require('../lib/DoorbellAccessory'),
36
+ verticalblindswithtilt: require('../lib/VerticalBlindsWithTilt'),
37
+ percentblinds: require('../lib/PercentBlindsAccessory'),
38
+ };
39
+
40
+ const validCategoryValues = new Set(Object.values(Categories));
41
+
42
+ describe('getCategory()', () => {
43
+ test.each(Object.entries(CLASS_DEF))('%s resolves to a real HAP category', (type, AccessoryClass) => {
44
+ expect(typeof AccessoryClass.getCategory).toBe('function');
45
+
46
+ const category = AccessoryClass.getCategory(Categories);
47
+
48
+ // Must be a defined, numeric category — not undefined (the issue #45 bug).
49
+ expect(category).toBeDefined();
50
+ expect(typeof category).toBe('number');
51
+ // ...and an actual member of the HAP Categories enum.
52
+ expect(validCategoryValues.has(category)).toBe(true);
53
+ });
54
+
55
+ test('the specific types reported in issue #45 are fixed', () => {
56
+ expect(CLASS_DEF.fanlight.getCategory(Categories)).toBe(Categories.FAN);
57
+ expect(CLASS_DEF.dehumidifier.getCategory(Categories)).toBe(Categories.AIR_DEHUMIDIFIER);
58
+ expect(CLASS_DEF.oildiffuser.getCategory(Categories)).toBe(Categories.AIR_DEHUMIDIFIER);
59
+ });
60
+
61
+ test('BaseAccessory falls back to OTHER for a missing override', () => {
62
+ const BaseAccessory = require('../lib/BaseAccessory');
63
+ expect(BaseAccessory.getCategory(Categories)).toBe(Categories.OTHER);
64
+ });
65
+ });