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.
- package/.github/CODEOWNERS +5 -0
- package/.github/workflows/publish-dev.yml +91 -0
- package/Readme.MD +33 -0
- package/config.schema.json +231 -12
- package/index.js +21 -5
- package/lib/BaseAccessory.js +7 -0
- package/lib/DehumidifierAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +611 -0
- package/lib/OilDiffuserAccessory.js +1 -1
- package/lib/SimpleFanLightAccessory.js +54 -12
- package/lib/SimpleGarageDoorAccessory.js +178 -262
- package/lib/TuyaAccessory.js +95 -28
- package/lib/TuyaDiscovery.js +52 -19
- package/lib/WledDimmerAccessory.js +703 -0
- package/package.json +1 -1
- package/test/IrrigationSystemAccessory.test.js +446 -0
- package/test/SimpleFanLightAccessory.test.js +299 -0
- package/test/SimpleGarageDoorAccessory.test.js +258 -538
- package/test/TuyaAccessory.protocol.test.js +626 -0
- package/test/getCategory.test.js +65 -0
- package/test/support/mocks.js +30 -2
- package/wiki/Supported-Device-Types.md +167 -22
- package/wiki/User-documented-device-config.md +57 -0
|
@@ -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
|
+
});
|