homebridge-tuya-community 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.eslintrc.js +29 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
  3. package/.github/ISSUE_TEMPLATE/new-device.md +24 -0
  4. package/.github/workflows/codeql-analysis.yml +67 -0
  5. package/.github/workflows/eslint.yml +28 -0
  6. package/Changelog.md +87 -0
  7. package/LICENSE +21 -0
  8. package/Readme.MD +99 -0
  9. package/assets/Tuya-Plugin-Branding.png +0 -0
  10. package/bin/cli-decode.js +197 -0
  11. package/bin/cli-find.js +207 -0
  12. package/bin/cli.js +13 -0
  13. package/config-example.MD +43 -0
  14. package/config.schema.json +554 -0
  15. package/index.js +288 -0
  16. package/lib/AirConditionerAccessory.js +445 -0
  17. package/lib/AirPurifierAccessory.js +531 -0
  18. package/lib/BaseAccessory.js +292 -0
  19. package/lib/ConvectorAccessory.js +313 -0
  20. package/lib/CustomMultiLightAccessory.js +70 -0
  21. package/lib/CustomMultiOutletAccessory.js +111 -0
  22. package/lib/DehumidifierAccessory.js +301 -0
  23. package/lib/EnergyCharacteristics.js +86 -0
  24. package/lib/GarageDoorAccessory.js +307 -0
  25. package/lib/MultiLightAccessory.js +64 -0
  26. package/lib/MultiOutletAccessory.js +106 -0
  27. package/lib/OilDiffuserAccessory.js +480 -0
  28. package/lib/OutletAccessory.js +83 -0
  29. package/lib/RGBTWLightAccessory.js +234 -0
  30. package/lib/RGBTWOutletAccessory.js +296 -0
  31. package/lib/SimpleBlindsAccessory.js +298 -0
  32. package/lib/SimpleDimmer2Accessory.js +54 -0
  33. package/lib/SimpleDimmerAccessory.js +54 -0
  34. package/lib/SimpleFanAccessory.js +132 -0
  35. package/lib/SimpleFanLightAccessory.js +205 -0
  36. package/lib/SimpleHeaterAccessory.js +154 -0
  37. package/lib/SimpleLightAccessory.js +39 -0
  38. package/lib/SingleLightAccessory.js +45 -0
  39. package/lib/SwitchAccessory.js +106 -0
  40. package/lib/TWLightAccessory.js +91 -0
  41. package/lib/TuyaAccessory.js +744 -0
  42. package/lib/TuyaDiscovery.js +278 -0
  43. package/lib/ValveAccessory.js +150 -0
  44. package/package.json +49 -0
@@ -0,0 +1,744 @@
1
+ const net = require('net');
2
+ const async = require('async');
3
+ const crypto = require('crypto');
4
+ const EventEmitter = require('events');
5
+
6
+ const isNonEmptyPlainObject = o => {
7
+ if (!o) return false;
8
+ for (let i in o) return true;
9
+ return false;
10
+ };
11
+
12
+ class TuyaAccessory extends EventEmitter {
13
+ constructor(props) {
14
+ super();
15
+
16
+ if (!(props.id && props.key && props.ip) && !props.fake) return this.log.info('Insufficient details to initialize:', props);
17
+
18
+ this.log = props.log;
19
+
20
+ this.context = {version: '3.1', port: 6668, ...props};
21
+
22
+ this.state = {};
23
+ this._cachedBuffer = Buffer.allocUnsafe(0);
24
+
25
+ this._msgQueue = async.queue(this[this.context.version < 3.2 ? '_msgHandler_3_1' : this.context.version === '3.4' ? '_msgHandler_3_4' : '_msgHandler_3_3'].bind(this), 1);
26
+
27
+ if (this.context.version >= 3.2) {
28
+ this.context.pingGap = Math.min(this.context.pingGap || 9, 9);
29
+ //this.log.info(`Changing ping gap for ${this.context.name} to ${this.context.pingGap}s`);
30
+ }
31
+
32
+ this.connected = false;
33
+ if (props.connect !== false) this._connect();
34
+
35
+ this._connectionAttempts = 0;
36
+ this._sendCounter = 0;
37
+
38
+ this._tmpLocalKey = null;
39
+ this._tmpRemoteKey = null;
40
+ this.session_key = null;
41
+ }
42
+
43
+ _connect() {
44
+ if (this.context.fake) {
45
+ this.connected = true;
46
+ return setTimeout(() => {
47
+ this.emit('change', {}, this.state);
48
+ }, 1000);
49
+ }
50
+
51
+ this._socket = net.Socket();
52
+
53
+ this._incrementAttemptCounter();
54
+
55
+ (this._socket.reconnect = () => {
56
+ //this.log.debug(`reconnect called for ${this.context.name}`);
57
+ if (this._socket._pinger) {
58
+ clearTimeout(this._socket._pinger);
59
+ this._socket._pinger = null;
60
+ }
61
+
62
+ if (this._socket._connTimeout) {
63
+ clearTimeout(this._socket._connTimeout);
64
+ this._socket._connTimeout = null;
65
+ }
66
+
67
+ if (this._socket._errorReconnect) {
68
+ clearTimeout(this._socket._errorReconnect);
69
+ this._socket._errorReconnect = null;
70
+ }
71
+
72
+ this._socket.setKeepAlive(true);
73
+ this._socket.setNoDelay(true);
74
+
75
+ this._socket._connTimeout = setTimeout(() => {
76
+ this._socket.emit('error', new Error('ERR_CONNECTION_TIMED_OUT'));
77
+ //this._socket.destroy();
78
+ //process.nextTick(this._connect.bind(this));
79
+ }, (this.context.connectTimeout || 30) * 1000);
80
+
81
+ this._incrementAttemptCounter();
82
+
83
+ this._socket.connect(this.context.port, this.context.ip);
84
+ })();
85
+
86
+ this._socket._ping = () => {
87
+ if (this._socket._pinger) clearTimeout(this._socket._pinger);
88
+ this._socket._pinger = setTimeout(() => {
89
+ //Retry ping
90
+ this._socket._pinger = setTimeout(() => {
91
+ this._socket.emit('error', new Error('ERR_PING_TIMED_OUT'));
92
+ }, 5000);
93
+
94
+ this._send({
95
+ cmd: 9
96
+ });
97
+ }, (this.context.pingTimeout || 30) * 1000);
98
+
99
+ this._send({
100
+ cmd: 9
101
+ });
102
+ };
103
+
104
+ this._socket.on('connect', () => {
105
+ if (this.context.version !== '3.4') {
106
+ clearTimeout(this._socket._connTimeout);
107
+
108
+ this.connected = true;
109
+ this.emit('connect');
110
+ if (this._socket._pinger)
111
+ clearTimeout(this._socket._pinger);
112
+ this._socket._pinger = setTimeout(() => this._socket._ping(), 1000);
113
+
114
+ if (this.context.intro === false) {
115
+ this.emit('change', {}, this.state);
116
+ process.nextTick(this.update.bind(this));
117
+ }
118
+ }
119
+ });
120
+
121
+ this._socket.on('ready', () => {
122
+ if (this.context.intro === false) return;
123
+ this.connected = true;
124
+
125
+ if (this.context.version === '3.4') {
126
+ this._tmpLocalKey = crypto.randomBytes(16);
127
+ const payload = {
128
+ data: this._tmpLocalKey,
129
+ encrypted: true,
130
+ cmd: 3 //CommandType.BIND
131
+ };
132
+ this._send(payload);
133
+ } else {
134
+ this.update();
135
+ }
136
+ });
137
+
138
+ this._socket.on('data', msg => {
139
+ this._cachedBuffer = Buffer.concat([this._cachedBuffer, msg]);
140
+
141
+ do {
142
+ let startingIndex = this._cachedBuffer.indexOf('000055aa', 'hex');
143
+ if (startingIndex === -1) {
144
+ this._cachedBuffer = Buffer.allocUnsafe(0);
145
+ break;
146
+ }
147
+ if (startingIndex !== 0) this._cachedBuffer = this._cachedBuffer.slice(startingIndex);
148
+
149
+ let endingIndex = this._cachedBuffer.indexOf('0000aa55', 'hex');
150
+ if (endingIndex === -1) break;
151
+
152
+ endingIndex += 4;
153
+
154
+ this._msgQueue.push({msg: this._cachedBuffer.slice(0, endingIndex)});
155
+
156
+ this._cachedBuffer = this._cachedBuffer.slice(endingIndex);
157
+ } while (this._cachedBuffer.length);
158
+ });
159
+
160
+ this._socket.on('error', err => {
161
+ this.connected = false;
162
+ this.log.info(`Socket had a problem and will reconnect to ${this.context.name} (${err && err.code || err})`);
163
+
164
+ if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE') && this._connectionAttempts < 10) {
165
+ this.log.debug(`Reconnecting with connection attempts = ${this._connectionAttempts}`);
166
+ return process.nextTick(this._socket.reconnect.bind(this));
167
+ }
168
+
169
+ this._socket.destroy();
170
+
171
+ let delay = 5000;
172
+ if (err) {
173
+ if (err.code === 'ENOBUFS') {
174
+ this.log.warn('Operating system complained of resource exhaustion; did I open too many sockets?');
175
+ this.log.info('Slowing down retry attempts; if you see this happening often, it could mean some sort of incompatibility.');
176
+ delay = 60000;
177
+ } else if (this._connectionAttempts > 10) {
178
+ this.log.info('Slowing down retry attempts; if you see this happening often, it could mean some sort of incompatibility.');
179
+ delay = 60000;
180
+ }
181
+ }
182
+
183
+ if (!this._socket._errorReconnect) {
184
+ this.log.debug(`after error setting _connect in ${delay}ms`);
185
+ this._socket._errorReconnect = setTimeout(() => {
186
+ this.log.debug(`executing _connect after ${delay}ms delay`);
187
+ process.nextTick(this._connect.bind(this));
188
+ }, delay);
189
+ }
190
+ });
191
+
192
+ this._socket.on('close', err => {
193
+ this.connected = false;
194
+ this.session_key = null;
195
+ //this.log.info('Closed connection with', this.context.name);
196
+ });
197
+
198
+ this._socket.on('end', () => {
199
+ this.connected = false;
200
+ this.session_key = null;
201
+ this.log.info('Disconnected from', this.context.name);
202
+ });
203
+ }
204
+
205
+ _incrementAttemptCounter() {
206
+ this._connectionAttempts++;
207
+ setTimeout(() => {
208
+ this.log.debug(`decrementing this._connectionAttempts, currently ${this._connectionAttempts}`);
209
+ this._connectionAttempts--;
210
+ }, 10000);
211
+ }
212
+
213
+ _msgHandler_3_1(task, callback) {
214
+ if (!(task.msg instanceof Buffer)) return callback();
215
+
216
+ const len = task.msg.length;
217
+ if (len < 16 ||
218
+ task.msg.readUInt32BE(0) !== 0x000055aa ||
219
+ task.msg.readUInt32BE(len - 4) !== 0x0000aa55
220
+ ) return callback();
221
+
222
+ const size = task.msg.readUInt32BE(12);
223
+ if (len - 8 < size) return callback();
224
+
225
+ const cmd = task.msg.readUInt32BE(8);
226
+ let data = task.msg.slice(len - size, len - 8).toString('utf8').trim().replace(/\0/g, '');
227
+
228
+ if (this.context.intro === false && cmd !== 9)
229
+ this.log.info('Message from', this.context.name + ':', data);
230
+
231
+ switch (cmd) {
232
+ case 7:
233
+ // ignoring
234
+ break;
235
+
236
+ case 9:
237
+ if (this._socket._pinger) clearTimeout(this._socket._pinger);
238
+ this._socket._pinger = setTimeout(() => {
239
+ this._socket._ping();
240
+ }, (this.context.pingGap || 20) * 1000);
241
+ break;
242
+
243
+ case 8:
244
+ let decryptedMsg;
245
+ try {
246
+ const decipher = crypto.createDecipheriv('aes-128-ecb', this.context.key, '');
247
+ decryptedMsg = decipher.update(data.substr(19), 'base64', 'utf8');
248
+ decryptedMsg += decipher.final('utf8');
249
+ } catch(ex) {
250
+ decryptedMsg = data.substr(19).toString('utf8');
251
+ }
252
+
253
+ try {
254
+ data = JSON.parse(decryptedMsg);
255
+ } catch (ex) {
256
+ data = decryptedMsg;
257
+ this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, data);
258
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
259
+ break;
260
+ }
261
+
262
+ if (data && data.dps) {
263
+ //this.log.info('Update from', this.context.name, 'with command', cmd + ':', data.dps);
264
+ this._change(data.dps);
265
+ }
266
+ break;
267
+
268
+ case 10:
269
+ if (data) {
270
+ if (data === 'json obj data unvalid') {
271
+ this.log.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
272
+ this.emit('change', {}, this.state);
273
+ break;
274
+ }
275
+
276
+ try {
277
+ data = JSON.parse(data);
278
+ } catch (ex) {
279
+ this.log.info(`Malformed update from ${this.context.name} with command ${cmd}:`, data);
280
+ this.log.info(`Raw update from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
281
+ break;
282
+ }
283
+
284
+ if (data && data.dps) this._change(data.dps);
285
+ }
286
+ break;
287
+
288
+ default:
289
+ this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, data);
290
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
291
+ }
292
+
293
+ callback();
294
+ }
295
+
296
+ _msgHandler_3_3(task, callback) {
297
+ if (!(task.msg instanceof Buffer)) return callback;
298
+
299
+ const len = task.msg.length;
300
+ if (len < 16 ||
301
+ task.msg.readUInt32BE(0) !== 0x000055aa ||
302
+ task.msg.readUInt32BE(len - 4) !== 0x0000aa55
303
+ ) return callback();
304
+
305
+ const size = task.msg.readUInt32BE(12);
306
+ if (len - 8 < size) return callback();
307
+
308
+ const cmd = task.msg.readUInt32BE(8);
309
+
310
+ if (cmd === 7) return callback(); // ignoring
311
+ if (cmd === 9) {
312
+ if (this._socket._pinger) clearTimeout(this._socket._pinger);
313
+ this._socket._pinger = setTimeout(() => {
314
+ this._socket._ping();
315
+ }, (this.context.pingGap || 20) * 1000);
316
+
317
+ return callback();
318
+ }
319
+
320
+ let versionPos = task.msg.indexOf('3.3');
321
+ if (versionPos === -1) versionPos = task.msg.indexOf('3.2');
322
+ const cleanMsg = task.msg.slice(versionPos === -1 ? len - size + ((task.msg.readUInt32BE(16) & 0xFFFFFF00) ? 0 : 4) : 15 + versionPos, len - 8);
323
+
324
+ let decryptedMsg;
325
+ try {
326
+ const decipher = crypto.createDecipheriv('aes-128-ecb', this.context.key, '');
327
+ decryptedMsg = decipher.update(cleanMsg, 'buffer', 'utf8');
328
+ decryptedMsg += decipher.final('utf8');
329
+ } catch (ex) {
330
+ decryptedMsg = cleanMsg.toString('utf8');
331
+ }
332
+
333
+ if (cmd === 10 && decryptedMsg === 'json obj data unvalid') {
334
+ this.log.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
335
+ this.emit('change', {}, this.state);
336
+ return callback();
337
+ }
338
+
339
+ let data;
340
+ try {
341
+ data = JSON.parse(decryptedMsg);
342
+ } catch(ex) {
343
+ this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
344
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
345
+ return callback();
346
+ }
347
+
348
+ switch (cmd) {
349
+ case 8:
350
+ case 10:
351
+ if (data) {
352
+ if (data.dps) {
353
+ //this.log.info(`Heard back from ${this.context.name} with command ${cmd}`);
354
+ this._change(data.dps);
355
+ } else {
356
+ this.log.info(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
357
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
358
+ }
359
+ }
360
+ break;
361
+
362
+ default:
363
+ this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
364
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
365
+ }
366
+
367
+ callback();
368
+ }
369
+
370
+ _msgHandler_3_4(task, callback) {
371
+ if (!(task.msg instanceof Buffer)) return callback;
372
+
373
+ const len = task.msg.length;
374
+ if (len < 16 ||
375
+ task.msg.readUInt32BE(0) !== 0x000055aa ||
376
+ task.msg.readUInt32BE(len - 4) !== 0x0000aa55
377
+ ) return callback();
378
+
379
+ const size = task.msg.readUInt32BE(12);
380
+ if (len - 8 < size) return callback();
381
+
382
+ const cmd = task.msg.readUInt32BE(8);
383
+
384
+ if (cmd === 7 || cmd === 13) return callback(); // ignoring
385
+ if (cmd === 9) {
386
+ if (this._socket._pinger) clearTimeout(this._socket._pinger);
387
+ this._socket._pinger = setTimeout(() => {
388
+ this._socket._ping();
389
+ }, (this.context.pingGap || 20) * 1000);
390
+
391
+ return callback();
392
+ }
393
+
394
+ let versionPos = task.msg.indexOf('3.4');
395
+ const cleanMsg = task.msg.slice(versionPos === -1 ? len - size + ((task.msg.readUInt32BE(16) & 0xFFFFFF00) ? 0 : 4) : 15 + versionPos, len - 0x24);
396
+
397
+ const expectedCrc = task.msg.slice(len - 0x24, task.msg.length - 4).toString('hex');
398
+ const computedCrc = hmac(task.msg.slice(0, len - 0x24), this.session_key ?? this.context.key).toString('hex');
399
+
400
+ if (expectedCrc !== computedCrc) {
401
+ throw new Error(`HMAC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${task.msg.toString('hex')}`);
402
+ }
403
+
404
+ let decryptedMsg;
405
+ const decipher = crypto.createDecipheriv('aes-128-ecb', this.session_key ?? this.context.key, null);
406
+ decipher.setAutoPadding(false);
407
+ decryptedMsg = decipher.update(cleanMsg);
408
+ decipher.final();
409
+ //remove padding
410
+ decryptedMsg = decryptedMsg.slice(0, (decryptedMsg.length - decryptedMsg[decryptedMsg.length-1]) );
411
+
412
+ let parsedPayload;
413
+ try {
414
+ if (decryptedMsg.indexOf(this.context.version) === 0) {
415
+ decryptedMsg = decryptedMsg.slice(15);
416
+ }
417
+ let res = JSON.parse(decryptedMsg);
418
+ if('data' in res) {
419
+ let resdata = res.data;
420
+ resdata.t = res.t;
421
+ parsedPayload = resdata;//res.data //for compatibility with tuya-mqtt
422
+ } else {
423
+ parsedPayload = res;
424
+ }
425
+ } catch (_) {
426
+ parsedPayload = decryptedMsg;
427
+ }
428
+
429
+ if (cmd === 4) { // CommandType.RENAME_GW
430
+ this._tmpRemoteKey = parsedPayload.subarray(0, 16);
431
+ const calcLocalHmac = hmac(this._tmpLocalKey, this.session_key ?? this.context.key).toString('hex');
432
+ const expLocalHmac = parsedPayload.slice(16, 16 + 32).toString('hex');
433
+ if (expLocalHmac !== calcLocalHmac) {
434
+ throw new Error(`HMAC mismatch(keys): expected ${expLocalHmac}, was ${calcLocalHmac}. ${parsedPayload.toString('hex')}`);
435
+ }
436
+ const payload = {
437
+ data: hmac(this._tmpRemoteKey, this.context.key),
438
+ encrypted: true,
439
+ cmd: 5 //CommandType.RENAME_DEVICE
440
+ };
441
+ this._send(payload);
442
+ clearTimeout(this._socket._connTimeout);
443
+
444
+ this.session_key = Buffer.from(this._tmpLocalKey);
445
+ for( let i=0; i<this._tmpLocalKey.length; i++) {
446
+ this.session_key[i] = this._tmpLocalKey[i] ^ this._tmpRemoteKey[i];
447
+ }
448
+
449
+ this.session_key = encrypt34(this.session_key, this.context.key);
450
+ clearTimeout(this._socket._connTimeout);
451
+
452
+ this.connected = true;
453
+ this.update();
454
+ this.emit('connect');
455
+ if (this._socket._pinger)
456
+ clearTimeout(this._socket._pinger);
457
+ this._socket._pinger = setTimeout(() => this._socket._ping(), 1000);
458
+
459
+ return callback();
460
+ }
461
+
462
+ if (cmd === 10 && parsedPayload === 'json obj data unvalid') {
463
+ this.log.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
464
+ this.emit('change', {}, this.state);
465
+ return callback();
466
+ }
467
+
468
+ switch (cmd) {
469
+ case 8:
470
+ case 10:
471
+ case 16:
472
+ if (parsedPayload) {
473
+ if (parsedPayload.dps) {
474
+ //this.log.info(`Heard back from ${this.context.name} with command ${cmd}`);
475
+ this._change(parsedPayload.dps);
476
+ } else {
477
+ this.log.info(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
478
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
479
+ }
480
+ }
481
+ break;
482
+
483
+ default:
484
+ this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
485
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
486
+ }
487
+
488
+ callback();
489
+ }
490
+
491
+ update(o) {
492
+ const dps = {};
493
+ let hasDataPoint = false;
494
+ o && Object.keys(o).forEach(key => {
495
+ if (!isNaN(key)) {
496
+ dps['' + key] = o[key];
497
+ hasDataPoint = true;
498
+ }
499
+ });
500
+
501
+ if (this.context.fake) {
502
+ if (hasDataPoint) this._fakeUpdate(dps);
503
+ return true;
504
+ }
505
+
506
+ let result = false;
507
+ if (hasDataPoint) {
508
+ //this.log.info(" Sending", this.context.name, JSON.stringify(dps));
509
+ const t = (Date.now() / 1000).toFixed(0);
510
+ const payload = {
511
+ devId: this.context.id,
512
+ uid: '',
513
+ t,
514
+ dps
515
+ };
516
+ const data = this.context.version === '3.4'
517
+ ? {
518
+ data: {
519
+ ...payload,
520
+ ctype: 0,
521
+ t: undefined
522
+ },
523
+ protocol:5,
524
+ t
525
+ }
526
+ : payload;
527
+ result = this._send({
528
+ data,
529
+ cmd: this.context.version === '3.4' ? 13 : 7
530
+ });
531
+ if (result !== true) this.log.info(' Result', result);
532
+ if (this.context.sendEmptyUpdate) {
533
+ //this.log.info(" Sending", this.context.name, 'empty signature');
534
+ this._send({cmd: this.context.version === '3.4' ? 13 : 7});
535
+ }
536
+ } else {
537
+ //this.log.info(`Sending first query to ${this.context.name} (${this.context.version})`);
538
+ result = this._send({
539
+ data: {
540
+ gwId: this.context.id,
541
+ devId: this.context.id
542
+ },
543
+ cmd: this.context.version === '3.4' ? 16 : 10
544
+ });
545
+ }
546
+
547
+ return result;
548
+ }
549
+
550
+ _change(data) {
551
+ if (!isNonEmptyPlainObject(data)) return;
552
+
553
+ const changes = {};
554
+ Object.keys(data).forEach(key => {
555
+ if (data[key] !== this.state[key]) {
556
+ changes[key] = data[key];
557
+ }
558
+ });
559
+
560
+ if (isNonEmptyPlainObject(changes)) {
561
+ this.state = {...this.state, ...data};
562
+ this.emit('change', changes, this.state);
563
+ }
564
+ }
565
+
566
+ _send(o) {
567
+ if (this.context.fake) return;
568
+ if (!this.connected) return false;
569
+
570
+ if (this.context.version < 3.2) return this._send_3_1(o);
571
+ if (this.context.version === '3.3') return this._send_3_3(o);
572
+ return this._send_3_4(o);
573
+ }
574
+
575
+ _send_3_1(o) {
576
+ const {cmd, data} = {...o};
577
+
578
+ let msg = '';
579
+
580
+ //data
581
+ if (data) {
582
+ switch (cmd) {
583
+ case 7:
584
+ const cipher = crypto.createCipheriv('aes-128-ecb', this.context.key, '');
585
+ let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'base64');
586
+ encrypted += cipher.final('base64');
587
+
588
+ const hash = crypto.createHash('md5').update(`data=${encrypted}||lpv=${this.context.version}||${this.context.key}`, 'utf8').digest('hex').substr(8, 16);
589
+
590
+ msg = this.context.version + hash + encrypted;
591
+ break;
592
+
593
+ case 10:
594
+ msg = JSON.stringify(data);
595
+ break;
596
+
597
+ }
598
+ }
599
+
600
+ const payload = Buffer.from(msg);
601
+ const prefix = Buffer.from('000055aa00000000000000' + cmd.toString(16).padStart(2, '0'), 'hex');
602
+ const suffix = Buffer.concat([payload, Buffer.from('000000000000aa55', 'hex')]);
603
+
604
+ const len = Buffer.allocUnsafe(4);
605
+ len.writeInt32BE(suffix.length, 0);
606
+
607
+ return this._socket.write(Buffer.concat([prefix, len, suffix]));
608
+ }
609
+
610
+ _send_3_3(o) {
611
+ const {cmd, data} = {...o};
612
+
613
+ // If sending empty dp-update command, we should not increment the sequence
614
+ if (cmd !== 7 || data) this._sendCounter++;
615
+
616
+ const hex = [
617
+ '000055aa', //header
618
+ this._sendCounter.toString(16).padStart(8, '0'), //sequence
619
+ cmd.toString(16).padStart(8, '0'), //command
620
+ '00000000' //size
621
+ ];
622
+ //version
623
+ if (cmd === 7 && !data) hex.push('00000000');
624
+ else if (cmd !== 9 && cmd !== 10) hex.push('332e33000000000000000000000000');
625
+ //data
626
+ if (data) {
627
+ const cipher = crypto.createCipheriv('aes-128-ecb', this.context.key, '');
628
+ let encrypted = cipher.update(Buffer.from(JSON.stringify(data)), 'utf8', 'hex');
629
+ encrypted += cipher.final('hex');
630
+ hex.push(encrypted);
631
+ }
632
+ //crc32
633
+ hex.push('00000000');
634
+ //tail
635
+ hex.push('0000aa55');
636
+
637
+ const payload = Buffer.from(hex.join(''), 'hex');
638
+ //length
639
+ payload.writeUInt32BE(payload.length - 16, 12);
640
+ //crc
641
+ payload.writeInt32BE(getCRC32(payload.slice(0, payload.length - 8)), payload.length - 8);
642
+
643
+ return this._socket.write(payload);
644
+ }
645
+
646
+ _fakeUpdate(dps) {
647
+ this.log.info('Fake update:', JSON.stringify(dps));
648
+ Object.keys(dps).forEach(dp => {
649
+ this.state[dp] = dps[dp];
650
+ });
651
+ setTimeout(() => {
652
+ this.emit('change', dps, this.state);
653
+ }, 1000);
654
+ }
655
+
656
+ _send_3_4(o) {
657
+ let {cmd, data} = {...o};
658
+
659
+ //data
660
+ if (!data) {
661
+ data = Buffer.allocUnsafe(0);
662
+ }
663
+ if (!(data instanceof Buffer)) {
664
+ if (typeof data !== 'string') {
665
+ data = JSON.stringify(data);
666
+ }
667
+
668
+ data = Buffer.from(data);
669
+ }
670
+
671
+ if (cmd !== 10 &&
672
+ cmd !== 9 &&
673
+ cmd !== 16 &&
674
+ cmd !== 3 &&
675
+ cmd !== 5 &&
676
+ cmd !== 18) {
677
+ // Add 3.4 header
678
+ // check this: mqc_very_pcmcd_mcd(int a1, unsigned int a2)
679
+ const buffer = Buffer.alloc(data.length + 15);
680
+ Buffer.from('3.4').copy(buffer, 0);
681
+ data.copy(buffer, 15);
682
+ data = buffer;
683
+ }
684
+
685
+ const padding=0x10 - (data.length & 0xf);
686
+ let buf34 = Buffer.alloc((data.length + padding), padding);
687
+ data.copy(buf34);
688
+ data = buf34;
689
+ const encrypted = encrypt34(data, this.session_key ?? this.context.key);
690
+
691
+ const encryptedBuffer = Buffer.from(encrypted);
692
+ // Allocate buffer with room for payload + 24 bytes for
693
+ // prefix, sequence, command, length, crc, and suffix
694
+ const buffer = Buffer.alloc(encryptedBuffer.length + 52);
695
+ // Add prefix, command, and length
696
+ buffer.writeUInt32BE(0x000055AA, 0);
697
+ buffer.writeUInt32BE(cmd, 8);
698
+ buffer.writeUInt32BE(encryptedBuffer.length + 0x24, 12);
699
+
700
+ // If sending empty dp-update command, we should not increment the sequence
701
+ if ((cmd !== 7 && cmd !== 13) || data) {
702
+ this._sendCounter++;
703
+ buffer.writeUInt32BE(this._sendCounter, 4);
704
+ }
705
+
706
+ // Add payload, crc, and suffix
707
+ encryptedBuffer.copy(buffer, 16);
708
+ const calculatedCrc = hmac(buffer.slice(0, encryptedBuffer.length + 16), this.session_key ?? this.context.key);// & 0xFFFFFFFF;
709
+ calculatedCrc.copy(buffer, encryptedBuffer.length + 16);
710
+ buffer.writeUInt32BE(0x0000AA55, encryptedBuffer.length + 48);
711
+
712
+ return this._socket.write(buffer);
713
+ }
714
+ }
715
+
716
+ const encrypt34 = (data, encryptKey) => {
717
+ const cipher = crypto.createCipheriv('aes-128-ecb', encryptKey, null);
718
+ cipher.setAutoPadding(false);
719
+ let encrypted = cipher.update(data);
720
+ cipher.final();
721
+ return encrypted;
722
+ };
723
+
724
+ const hmac = (data, hmacKey) => {
725
+ return crypto.createHmac('sha256',hmacKey).update(data, 'utf8').digest();
726
+ };
727
+
728
+ const crc32LookupTable = [];
729
+ (() => {
730
+ for (let i = 0; i < 256; i++) {
731
+ let crc = i;
732
+ for (let j = 8; j > 0; j--) crc = (crc & 1) ? (crc >>> 1) ^ 3988292384 : crc >>> 1;
733
+ crc32LookupTable.push(crc);
734
+ }
735
+ })();
736
+
737
+ const getCRC32 = buffer => {
738
+ let crc = 0xffffffff;
739
+ for (let i = 0, len = buffer.length; i < len; i++) crc = crc32LookupTable[buffer[i] ^ (crc & 0xff)] ^ (crc >>> 8);
740
+ return ~crc;
741
+ };
742
+
743
+
744
+ module.exports = TuyaAccessory;