homebridge-tuya-plus 3.1.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.
Files changed (42) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
  2. package/.github/ISSUE_TEMPLATE/new-device.md +24 -0
  3. package/.github/workflows/codeql-analysis.yml +67 -0
  4. package/.github/workflows/lint.yml +23 -0
  5. package/Changelog.md +63 -0
  6. package/LICENSE +21 -0
  7. package/Readme.MD +106 -0
  8. package/assets/Tuya-Plugin-Branding.png +0 -0
  9. package/bin/cli-decode.js +197 -0
  10. package/bin/cli-find.js +207 -0
  11. package/bin/cli.js +13 -0
  12. package/config-example.MD +43 -0
  13. package/config.schema.json +538 -0
  14. package/eslint.config.mjs +15 -0
  15. package/index.js +242 -0
  16. package/lib/AirConditionerAccessory.js +445 -0
  17. package/lib/AirPurifierAccessory.js +532 -0
  18. package/lib/BaseAccessory.js +290 -0
  19. package/lib/ConvectorAccessory.js +313 -0
  20. package/lib/CustomMultiOutletAccessory.js +111 -0
  21. package/lib/DehumidifierAccessory.js +301 -0
  22. package/lib/DoorbellAccessory.js +208 -0
  23. package/lib/EnergyCharacteristics.js +86 -0
  24. package/lib/GarageDoorAccessory.js +307 -0
  25. package/lib/MultiOutletAccessory.js +106 -0
  26. package/lib/OilDiffuserAccessory.js +480 -0
  27. package/lib/OutletAccessory.js +83 -0
  28. package/lib/RGBTWLightAccessory.js +234 -0
  29. package/lib/RGBTWOutletAccessory.js +296 -0
  30. package/lib/SimpleBlindsAccessory.js +299 -0
  31. package/lib/SimpleDimmer2Accessory.js +54 -0
  32. package/lib/SimpleDimmerAccessory.js +54 -0
  33. package/lib/SimpleFanAccessory.js +137 -0
  34. package/lib/SimpleFanLightAccessory.js +201 -0
  35. package/lib/SimpleHeaterAccessory.js +154 -0
  36. package/lib/SimpleLightAccessory.js +39 -0
  37. package/lib/SwitchAccessory.js +106 -0
  38. package/lib/TWLightAccessory.js +91 -0
  39. package/lib/TuyaAccessory.js +746 -0
  40. package/lib/TuyaDiscovery.js +165 -0
  41. package/lib/ValveAccessory.js +150 -0
  42. package/package.json +49 -0
@@ -0,0 +1,746 @@
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
+
269
+ case 10:
270
+ if (data) {
271
+ if (data === 'json obj data unvalid') {
272
+ this.log.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
273
+ this.emit('change', {}, this.state);
274
+ break;
275
+ }
276
+
277
+ try {
278
+ data = JSON.parse(data);
279
+ } catch (ex) {
280
+ this.log.info(`Malformed update from ${this.context.name} with command ${cmd}:`, data);
281
+ this.log.info(`Raw update from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
282
+ break;
283
+ }
284
+
285
+ if (data && data.dps) this._change(data.dps);
286
+ }
287
+ break;
288
+
289
+ default:
290
+ this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, data);
291
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
292
+ }
293
+
294
+ callback();
295
+ }
296
+
297
+ _msgHandler_3_3(task, callback) {
298
+ if (!(task.msg instanceof Buffer)) return callback;
299
+
300
+ const len = task.msg.length;
301
+ if (len < 16 ||
302
+ task.msg.readUInt32BE(0) !== 0x000055aa ||
303
+ task.msg.readUInt32BE(len - 4) !== 0x0000aa55
304
+ ) return callback();
305
+
306
+ const size = task.msg.readUInt32BE(12);
307
+ if (len - 8 < size) return callback();
308
+
309
+ const cmd = task.msg.readUInt32BE(8);
310
+
311
+ if (cmd === 7) return callback(); // ignoring
312
+ if (cmd === 9) {
313
+ if (this._socket._pinger) clearTimeout(this._socket._pinger);
314
+ this._socket._pinger = setTimeout(() => {
315
+ this._socket._ping();
316
+ }, (this.context.pingGap || 20) * 1000);
317
+
318
+ return callback();
319
+ }
320
+
321
+ let versionPos = task.msg.indexOf('3.3');
322
+ if (versionPos === -1) versionPos = task.msg.indexOf('3.2');
323
+ const cleanMsg = task.msg.slice(versionPos === -1 ? len - size + ((task.msg.readUInt32BE(16) & 0xFFFFFF00) ? 0 : 4) : 15 + versionPos, len - 8);
324
+
325
+ let decryptedMsg;
326
+ try {
327
+ const decipher = crypto.createDecipheriv('aes-128-ecb', this.context.key, '');
328
+ decryptedMsg = decipher.update(cleanMsg, 'buffer', 'utf8');
329
+ decryptedMsg += decipher.final('utf8');
330
+ } catch (ex) {
331
+ decryptedMsg = cleanMsg.toString('utf8');
332
+ }
333
+
334
+ if (cmd === 10 && decryptedMsg === 'json obj data unvalid') {
335
+ this.log.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
336
+ this.emit('change', {}, this.state);
337
+ return callback();
338
+ }
339
+
340
+ let data;
341
+ try {
342
+ data = JSON.parse(decryptedMsg);
343
+ } catch(ex) {
344
+ this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
345
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
346
+ return callback();
347
+ }
348
+
349
+ switch (cmd) {
350
+ case 8:
351
+ case 10:
352
+ if (data) {
353
+ if (data.dps) {
354
+ //this.log.info(`Heard back from ${this.context.name} with command ${cmd}`);
355
+ this._change(data.dps);
356
+ } else {
357
+ this.log.info(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
358
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
359
+ }
360
+ }
361
+ break;
362
+
363
+ default:
364
+ this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
365
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
366
+ }
367
+
368
+ callback();
369
+ }
370
+
371
+ _msgHandler_3_4(task, callback) {
372
+ if (!(task.msg instanceof Buffer)) return callback;
373
+
374
+ const len = task.msg.length;
375
+ if (len < 16 ||
376
+ task.msg.readUInt32BE(0) !== 0x000055aa ||
377
+ task.msg.readUInt32BE(len - 4) !== 0x0000aa55
378
+ ) return callback();
379
+
380
+ const size = task.msg.readUInt32BE(12);
381
+ if (len - 8 < size) return callback();
382
+
383
+ const cmd = task.msg.readUInt32BE(8);
384
+
385
+ if (cmd === 7 || cmd === 13) return callback(); // ignoring
386
+ if (cmd === 9) {
387
+ if (this._socket._pinger) clearTimeout(this._socket._pinger);
388
+ this._socket._pinger = setTimeout(() => {
389
+ this._socket._ping();
390
+ }, (this.context.pingGap || 20) * 1000);
391
+
392
+ return callback();
393
+ }
394
+
395
+ let versionPos = task.msg.indexOf('3.4');
396
+ const cleanMsg = task.msg.slice(versionPos === -1 ? len - size + ((task.msg.readUInt32BE(16) & 0xFFFFFF00) ? 0 : 4) : 15 + versionPos, len - 0x24);
397
+
398
+ const expectedCrc = task.msg.slice(len - 0x24, task.msg.length - 4).toString('hex');
399
+ const computedCrc = hmac(task.msg.slice(0, len - 0x24), this.session_key ?? this.context.key).toString('hex');
400
+
401
+ if (expectedCrc !== computedCrc) {
402
+ throw new Error(`HMAC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${task.msg.toString('hex')}`);
403
+ }
404
+
405
+ let decryptedMsg;
406
+ const decipher = crypto.createDecipheriv('aes-128-ecb', this.session_key ?? this.context.key, null);
407
+ decipher.setAutoPadding(false)
408
+ decryptedMsg = decipher.update(cleanMsg);
409
+ decipher.final();
410
+ //remove padding
411
+ decryptedMsg = decryptedMsg.slice(0, (decryptedMsg.length - decryptedMsg[decryptedMsg.length-1]) )
412
+
413
+ let parsedPayload;
414
+ try {
415
+ if (decryptedMsg.indexOf(this.context.version) === 0) {
416
+ decryptedMsg = decryptedMsg.slice(15)
417
+ }
418
+ let res = JSON.parse(decryptedMsg)
419
+ if('data' in res) {
420
+ let resdata = res.data
421
+ resdata.t = res.t
422
+ parsedPayload = resdata//res.data //for compatibility with tuya-mqtt
423
+ } else {
424
+ parsedPayload = res;
425
+ }
426
+ } catch (_) {
427
+ parsedPayload = decryptedMsg;
428
+ }
429
+
430
+ if (cmd === 4) { // CommandType.RENAME_GW
431
+ this._tmpRemoteKey = parsedPayload.subarray(0, 16);
432
+ const calcLocalHmac = hmac(this._tmpLocalKey, this.session_key ?? this.context.key).toString('hex')
433
+ const expLocalHmac = parsedPayload.slice(16, 16 + 32).toString('hex')
434
+ if (expLocalHmac !== calcLocalHmac) {
435
+ throw new Error(`HMAC mismatch(keys): expected ${expLocalHmac}, was ${calcLocalHmac}. ${parsedPayload.toString('hex')}`);
436
+ }
437
+ const payload = {
438
+ data: hmac(this._tmpRemoteKey, this.context.key),
439
+ encrypted: true,
440
+ cmd: 5 //CommandType.RENAME_DEVICE
441
+ }
442
+ this._send(payload);
443
+ clearTimeout(this._socket._connTimeout);
444
+
445
+ this.session_key = Buffer.from(this._tmpLocalKey)
446
+ for( let i=0; i<this._tmpLocalKey.length; i++) {
447
+ this.session_key[i] = this._tmpLocalKey[i] ^ this._tmpRemoteKey[i]
448
+ }
449
+
450
+ this.session_key = encrypt34(this.session_key, this.context.key);
451
+ clearTimeout(this._socket._connTimeout);
452
+
453
+ this.connected = true;
454
+ this.update();
455
+ this.emit('connect');
456
+ if (this._socket._pinger)
457
+ clearTimeout(this._socket._pinger);
458
+ this._socket._pinger = setTimeout(() => this._socket._ping(), 1000);
459
+
460
+ return callback();
461
+ }
462
+
463
+ if (cmd === 10 && parsedPayload === 'json obj data unvalid') {
464
+ this.log.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
465
+ this.emit('change', {}, this.state);
466
+ return callback();
467
+ }
468
+
469
+ switch (cmd) {
470
+ case 8:
471
+ case 10:
472
+ case 16:
473
+ if (parsedPayload) {
474
+ if (parsedPayload.dps) {
475
+ //this.log.info(`Heard back from ${this.context.name} with command ${cmd}`);
476
+ this._change(parsedPayload.dps);
477
+ } else {
478
+ this.log.info(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
479
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
480
+ }
481
+ }
482
+ break;
483
+
484
+ default:
485
+ this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
486
+ this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
487
+ }
488
+
489
+ callback();
490
+ }
491
+
492
+ update(o) {
493
+ const dps = {};
494
+ let hasDataPoint = false;
495
+ o && Object.keys(o).forEach(key => {
496
+ if (!isNaN(key)) {
497
+ dps['' + key] = o[key];
498
+ hasDataPoint = true;
499
+ }
500
+ });
501
+
502
+ if (this.context.fake) {
503
+ if (hasDataPoint) this._fakeUpdate(dps);
504
+ return true;
505
+ }
506
+
507
+ let result = false;
508
+ if (hasDataPoint) {
509
+ //this.log.info(" Sending", this.context.name, JSON.stringify(dps));
510
+ const t = (Date.now() / 1000).toFixed(0);
511
+ const payload = {
512
+ devId: this.context.id,
513
+ uid: '',
514
+ t,
515
+ dps
516
+ };
517
+ const data = this.context.version === '3.4'
518
+ ? {
519
+ data: {
520
+ ...payload,
521
+ ctype: 0,
522
+ t: undefined
523
+ },
524
+ protocol:5,
525
+ t
526
+ }
527
+ : payload
528
+ result = this._send({
529
+ data,
530
+ cmd: this.context.version === '3.4' ? 13 : 7
531
+ });
532
+ if (result !== true) this.log.info(" Result", result);
533
+ if (this.context.sendEmptyUpdate) {
534
+ //this.log.info(" Sending", this.context.name, 'empty signature');
535
+ this._send({cmd: this.context.version === '3.4' ? 13 : 7});
536
+ }
537
+ } else {
538
+ //this.log.info(`Sending first query to ${this.context.name} (${this.context.version})`);
539
+ result = this._send({
540
+ data: {
541
+ gwId: this.context.id,
542
+ devId: this.context.id
543
+ },
544
+ cmd: this.context.version === '3.4' ? 16 : 10
545
+ });
546
+ }
547
+
548
+ return result;
549
+ }
550
+
551
+ _change(data) {
552
+ if (!isNonEmptyPlainObject(data)) return;
553
+
554
+ const changes = {};
555
+ Object.keys(data).forEach(key => {
556
+ if (data[key] !== this.state[key]) {
557
+ changes[key] = data[key];
558
+ }
559
+ });
560
+
561
+ if (isNonEmptyPlainObject(changes)) {
562
+ this.state = {...this.state, ...data};
563
+ this.emit('change', changes, this.state);
564
+ }
565
+ }
566
+
567
+ _send(o) {
568
+ if (this.context.fake) return;
569
+ if (!this.connected) return false;
570
+
571
+ if (this.context.version < 3.2) return this._send_3_1(o);
572
+ if (this.context.version === '3.3') return this._send_3_3(o);
573
+ return this._send_3_4(o);
574
+ }
575
+
576
+ _send_3_1(o) {
577
+ const {cmd, data} = {...o};
578
+
579
+ let msg = '';
580
+
581
+ //data
582
+ if (data) {
583
+ switch (cmd) {
584
+ case 7: {
585
+ const cipher = crypto.createCipheriv('aes-128-ecb', this.context.key, '');
586
+ let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'base64');
587
+ encrypted += cipher.final('base64');
588
+
589
+ const hash = crypto.createHash('md5').update(`data=${encrypted}||lpv=${this.context.version}||${this.context.key}`, 'utf8').digest('hex').substr(8, 16);
590
+
591
+ msg = this.context.version + hash + encrypted;
592
+ break;
593
+ }
594
+
595
+ case 10:
596
+ msg = JSON.stringify(data);
597
+ break;
598
+
599
+ }
600
+ }
601
+
602
+ const payload = Buffer.from(msg);
603
+ const prefix = Buffer.from('000055aa00000000000000' + cmd.toString(16).padStart(2, '0'), 'hex');
604
+ const suffix = Buffer.concat([payload, Buffer.from('000000000000aa55', 'hex')]);
605
+
606
+ const len = Buffer.allocUnsafe(4);
607
+ len.writeInt32BE(suffix.length, 0);
608
+
609
+ return this._socket.write(Buffer.concat([prefix, len, suffix]));
610
+ }
611
+
612
+ _send_3_3(o) {
613
+ const {cmd, data} = {...o};
614
+
615
+ // If sending empty dp-update command, we should not increment the sequence
616
+ if (cmd !== 7 || data) this._sendCounter++;
617
+
618
+ const hex = [
619
+ '000055aa', //header
620
+ this._sendCounter.toString(16).padStart(8, '0'), //sequence
621
+ cmd.toString(16).padStart(8, '0'), //command
622
+ '00000000' //size
623
+ ];
624
+ //version
625
+ if (cmd === 7 && !data) hex.push('00000000');
626
+ else if (cmd !== 9 && cmd !== 10) hex.push('332e33000000000000000000000000');
627
+ //data
628
+ if (data) {
629
+ const cipher = crypto.createCipheriv('aes-128-ecb', this.context.key, '');
630
+ let encrypted = cipher.update(Buffer.from(JSON.stringify(data)), 'utf8', 'hex');
631
+ encrypted += cipher.final('hex');
632
+ hex.push(encrypted);
633
+ }
634
+ //crc32
635
+ hex.push('00000000');
636
+ //tail
637
+ hex.push('0000aa55');
638
+
639
+ const payload = Buffer.from(hex.join(''), 'hex');
640
+ //length
641
+ payload.writeUInt32BE(payload.length - 16, 12);
642
+ //crc
643
+ payload.writeInt32BE(getCRC32(payload.slice(0, payload.length - 8)), payload.length - 8);
644
+
645
+ return this._socket.write(payload);
646
+ }
647
+
648
+ _fakeUpdate(dps) {
649
+ this.log.info('Fake update:', JSON.stringify(dps));
650
+ Object.keys(dps).forEach(dp => {
651
+ this.state[dp] = dps[dp];
652
+ });
653
+ setTimeout(() => {
654
+ this.emit('change', dps, this.state);
655
+ }, 1000);
656
+ }
657
+
658
+ _send_3_4(o) {
659
+ let {cmd, data} = {...o};
660
+
661
+ //data
662
+ if (!data) {
663
+ data = Buffer.allocUnsafe(0);
664
+ }
665
+ if (!(data instanceof Buffer)) {
666
+ if (typeof data !== 'string') {
667
+ data = JSON.stringify(data);
668
+ }
669
+
670
+ data = Buffer.from(data);
671
+ }
672
+
673
+ if (cmd !== 10 &&
674
+ cmd !== 9 &&
675
+ cmd !== 16 &&
676
+ cmd !== 3 &&
677
+ cmd !== 5 &&
678
+ cmd !== 18) {
679
+ // Add 3.4 header
680
+ // check this: mqc_very_pcmcd_mcd(int a1, unsigned int a2)
681
+ const buffer = Buffer.alloc(data.length + 15);
682
+ Buffer.from('3.4').copy(buffer, 0);
683
+ data.copy(buffer, 15);
684
+ data = buffer;
685
+ }
686
+
687
+ const padding=0x10 - (data.length & 0xf);
688
+ let buf34 = Buffer.alloc((data.length + padding), padding);
689
+ data.copy(buf34);
690
+ data = buf34
691
+ const encrypted = encrypt34(data, this.session_key ?? this.context.key)
692
+
693
+ const encryptedBuffer = Buffer.from(encrypted);
694
+ // Allocate buffer with room for payload + 24 bytes for
695
+ // prefix, sequence, command, length, crc, and suffix
696
+ const buffer = Buffer.alloc(encryptedBuffer.length + 52);
697
+ // Add prefix, command, and length
698
+ buffer.writeUInt32BE(0x000055AA, 0);
699
+ buffer.writeUInt32BE(cmd, 8);
700
+ buffer.writeUInt32BE(encryptedBuffer.length + 0x24, 12);
701
+
702
+ // If sending empty dp-update command, we should not increment the sequence
703
+ if ((cmd !== 7 && cmd !== 13) || data) {
704
+ this._sendCounter++;
705
+ buffer.writeUInt32BE(this._sendCounter, 4);
706
+ }
707
+
708
+ // Add payload, crc, and suffix
709
+ encryptedBuffer.copy(buffer, 16);
710
+ const calculatedCrc = hmac(buffer.slice(0, encryptedBuffer.length + 16), this.session_key ?? this.context.key);// & 0xFFFFFFFF;
711
+ calculatedCrc.copy(buffer, encryptedBuffer.length + 16);
712
+ buffer.writeUInt32BE(0x0000AA55, encryptedBuffer.length + 48);
713
+
714
+ return this._socket.write(buffer);
715
+ }
716
+ }
717
+
718
+ const encrypt34 = (data, encryptKey) => {
719
+ const cipher = crypto.createCipheriv('aes-128-ecb', encryptKey, null);
720
+ cipher.setAutoPadding(false);
721
+ let encrypted = cipher.update(data);
722
+ cipher.final();
723
+ return encrypted;
724
+ }
725
+
726
+ const hmac = (data, hmacKey) => {
727
+ return crypto.createHmac('sha256',hmacKey).update(data, 'utf8').digest();
728
+ }
729
+
730
+ const crc32LookupTable = [];
731
+ (() => {
732
+ for (let i = 0; i < 256; i++) {
733
+ let crc = i;
734
+ for (let j = 8; j > 0; j--) crc = (crc & 1) ? (crc >>> 1) ^ 3988292384 : crc >>> 1;
735
+ crc32LookupTable.push(crc);
736
+ }
737
+ })();
738
+
739
+ const getCRC32 = buffer => {
740
+ let crc = 0xffffffff;
741
+ for (let i = 0, len = buffer.length; i < len; i++) crc = crc32LookupTable[buffer[i] ^ (crc & 0xff)] ^ (crc >>> 8);
742
+ return ~crc;
743
+ };
744
+
745
+
746
+ module.exports = TuyaAccessory;