homebridge-tuya-plus 3.3.0 → 3.4.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.
package/Readme.MD CHANGED
@@ -44,6 +44,7 @@ A community-maintained Homebridge plugin for controlling Tuya devices locally ov
44
44
  * Oil Diffusers<sup>[13](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md)</sup>
45
45
  * Outlets<sup>[14](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#outlets)</sup>
46
46
  * Switches<sup>[15](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md)</sup>
47
+ * Vertical Blinds<sup>[16](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#vertical-blinds-with-tilt)</sup>
47
48
 
48
49
  Note: Motion, and other sensor types don't behave well with responce requests, so they will not be added.
49
50
 
@@ -78,6 +78,10 @@
78
78
  "title": "Simple Blinds",
79
79
  "enum": ["SimpleBlinds"]
80
80
  },
81
+ {
82
+ "title": "Vertical Blinds with Tilt",
83
+ "enum": ["VerticalBlindsWithTilt"]
84
+ },
81
85
  {
82
86
  "title": "Smart Plug w/ White and Color Lights",
83
87
  "enum": ["RGBTWOutlet"]
@@ -498,6 +502,14 @@
498
502
  "functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleBlinds'].includes(model.devices[arrayIndices].type);"
499
503
  }
500
504
  },
505
+ "timeToClose": {
506
+ "type": "integer",
507
+ "placeholder": "30",
508
+ "description": "Time in seconds for blinds to fully open or close",
509
+ "condition": {
510
+ "functionBody": "return model.devices && model.devices[arrayIndices] && ['VerticalBlindsWithTilt'].includes(model.devices[arrayIndices].type);"
511
+ }
512
+ },
501
513
  "dpLight": {
502
514
  "type": "integer",
503
515
  "placeholder": 1,
package/index.js CHANGED
@@ -23,6 +23,7 @@ const SwitchAccessory = require('./lib/SwitchAccessory');
23
23
  const ValveAccessory = require('./lib/ValveAccessory');
24
24
  const OilDiffuserAccessory = require('./lib/OilDiffuserAccessory');
25
25
  const DoorbellAccessory = require('./lib/DoorbellAccessory');
26
+ const VerticalBlindsWithTilt = require('./lib/VerticalBlindsWithTilt');
26
27
 
27
28
  const PLUGIN_NAME = 'homebridge-tuya';
28
29
  const PLATFORM_NAME = 'TuyaLan';
@@ -50,7 +51,8 @@ const CLASS_DEF = {
50
51
  fanlight: SimpleFanLightAccessory,
51
52
  watervalve: ValveAccessory,
52
53
  oildiffuser: OilDiffuserAccessory,
53
- doorbell: DoorbellAccessory
54
+ doorbell: DoorbellAccessory,
55
+ verticalblindswithtilt: VerticalBlindsWithTilt
54
56
  };
55
57
 
56
58
  let Characteristic, PlatformAccessory, Service, Categories, AdaptiveLightingController, UUID;
@@ -17,12 +17,20 @@ class TuyaAccessory extends EventEmitter {
17
17
 
18
18
  this.log = props.log;
19
19
 
20
- this.context = {version: '3.1', port: 6668, ...props};
20
+ this.context = {
21
+ version: '3.5', // Every pre-3.5 device includes its version in its broadcast
22
+ port: 6668,
23
+ ...props,
24
+ };
21
25
 
22
26
  this.state = {};
23
27
  this._cachedBuffer = Buffer.allocUnsafe(0);
24
28
 
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);
29
+ this._msgQueue = async.queue(this[
30
+ this.context.version < 3.2 ? '_msgHandler_3_1' :
31
+ this.context.version === '3.3' ? '_msgHandler_3_3' :
32
+ this.context.version === '3.4' ? '_msgHandler_3_4' : '_msgHandler_3_5'
33
+ ].bind(this), 1);
26
34
 
27
35
  if (this.context.version >= 3.2) {
28
36
  this.context.pingGap = Math.min(this.context.pingGap || 9, 9);
@@ -102,7 +110,7 @@ class TuyaAccessory extends EventEmitter {
102
110
  };
103
111
 
104
112
  this._socket.on('connect', () => {
105
- if (this.context.version !== '3.4') {
113
+ if (this.context.version !== '3.4' && this.context.version !== '3.5') {
106
114
  clearTimeout(this._socket._connTimeout);
107
115
 
108
116
  this.connected = true;
@@ -122,13 +130,13 @@ class TuyaAccessory extends EventEmitter {
122
130
  if (this.context.intro === false) return;
123
131
  this.connected = true;
124
132
 
125
- if (this.context.version === '3.4') {
133
+ if (this.context.version === '3.4' || this.context.version === '3.5') {
126
134
  this._tmpLocalKey = crypto.randomBytes(16);
127
135
  const payload = {
128
136
  data: this._tmpLocalKey,
129
137
  encrypted: true,
130
138
  cmd: 3 //CommandType.BIND
131
- }
139
+ };
132
140
  this._send(payload);
133
141
  } else {
134
142
  this.update();
@@ -139,18 +147,28 @@ class TuyaAccessory extends EventEmitter {
139
147
  this._cachedBuffer = Buffer.concat([this._cachedBuffer, msg]);
140
148
 
141
149
  do {
142
- let startingIndex = this._cachedBuffer.indexOf('000055aa', 'hex');
150
+ let startingIndex;
151
+ if (this.context.version === '3.5') {
152
+ startingIndex = this._cachedBuffer.indexOf('00006699', 'hex');
153
+ } else {
154
+ startingIndex = this._cachedBuffer.indexOf('000055aa', 'hex');
155
+ }
143
156
  if (startingIndex === -1) {
144
157
  this._cachedBuffer = Buffer.allocUnsafe(0);
145
158
  break;
146
159
  }
147
160
  if (startingIndex !== 0) this._cachedBuffer = this._cachedBuffer.slice(startingIndex);
148
161
 
149
- let endingIndex = this._cachedBuffer.indexOf('0000aa55', 'hex');
162
+ let endingIndex;
163
+ if (this.context.version === '3.5') {
164
+ endingIndex = this._cachedBuffer.indexOf('00009966', 'hex');
165
+ if (endingIndex !== -1) endingIndex += 4;
166
+ } else {
167
+ endingIndex = this._cachedBuffer.indexOf('0000aa55', 'hex');
168
+ if (endingIndex !== -1) endingIndex += 4;
169
+ }
150
170
  if (endingIndex === -1) break;
151
171
 
152
- endingIndex += 4;
153
-
154
172
  this._msgQueue.push({msg: this._cachedBuffer.slice(0, endingIndex)});
155
173
 
156
174
  this._cachedBuffer = this._cachedBuffer.slice(endingIndex);
@@ -489,6 +507,125 @@ class TuyaAccessory extends EventEmitter {
489
507
  callback();
490
508
  }
491
509
 
510
+ /*
511
+ * 3.5 Protocol message handler
512
+ */
513
+ _msgHandler_3_5(task, callback) {
514
+ if (!(task.msg instanceof Buffer)) return callback();
515
+
516
+ const len = task.msg.length;
517
+ if (len < 22 ||
518
+ task.msg.readUInt32BE(0) !== 0x00006699 ||
519
+ task.msg.readUInt32BE(len - 4) !== 0x00009966) return callback();
520
+
521
+ const declaredLen = task.msg.readUInt32BE(14);
522
+ if ((len - 22) < declaredLen) return callback();
523
+
524
+ const cmd = task.msg.readUInt32BE(10);
525
+
526
+ if (cmd === 7 || cmd === 13) return callback(); // ignoring
527
+ if (cmd === 9) {
528
+ if (this._socket._pinger) clearTimeout(this._socket._pinger);
529
+ this._socket._pinger = setTimeout(() => {
530
+ this._socket._ping();
531
+ }, (this.context.pingGap || 20) * 1000);
532
+ return callback();
533
+ }
534
+
535
+ const iv = task.msg.slice(18, 30);
536
+ const encrypted = task.msg.slice(30, len - 20); // remove tag + footer
537
+ const tag = task.msg.slice(len - 20, len - 4);
538
+ const aad = task.msg.slice(4, 18); // UUUU + seq + cmd + len (14 bytes)
539
+
540
+ let decrypted;
541
+ try {
542
+ const decipher = crypto.createDecipheriv('aes-128-gcm', this.session_key ?? this.context.key, iv);
543
+ decipher.setAAD(aad);
544
+ decipher.setAuthTag(tag);
545
+ decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
546
+ } catch(ex) {
547
+ this.log.info(`Failed to decrypt message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, ex);
548
+ return callback();
549
+ }
550
+
551
+ // strip return code if present (first 4 bytes)
552
+ let payload = decrypted;
553
+ if (decrypted.length > 4) {
554
+ try {
555
+ JSON.parse(decrypted.toString('utf8'));
556
+ } catch(_) {
557
+ payload = decrypted.slice(4);
558
+ }
559
+ }
560
+
561
+ // remove leading version header
562
+ if (payload.indexOf(this.context.version) === 0) payload = payload.slice(15);
563
+
564
+ // Attempt JSON parse
565
+ let parsedPayload;
566
+ try {
567
+ parsedPayload = JSON.parse(payload.toString('utf8'));
568
+ if (parsedPayload && parsedPayload.data) {
569
+ const tmp = parsedPayload;
570
+ parsedPayload = tmp.data;
571
+ parsedPayload.t = tmp.t;
572
+ }
573
+ } catch(_) {
574
+ parsedPayload = payload; // may be buffer during key exchange
575
+ }
576
+
577
+ // ---- Handle 3‑way key exchange ----
578
+ if (cmd === 4 && Buffer.isBuffer(parsedPayload)) {
579
+ this._tmpRemoteKey = parsedPayload.subarray(0, 16);
580
+ const calcLocalHmac = hmac(this._tmpLocalKey, this.context.key).toString('hex');
581
+ const expLocalHmac = parsedPayload.slice(16, 48).toString('hex');
582
+ if (calcLocalHmac !== expLocalHmac) {
583
+ throw new Error(`HMAC mismatch(keys): expected ${expLocalHmac}, was ${calcLocalHmac}. ${parsedPayload.toString('hex')}`);
584
+ }
585
+
586
+ const payloadToSend = {
587
+ data: hmac(this._tmpRemoteKey, this.context.key),
588
+ encrypted: true,
589
+ cmd: 5 // CommandType.RENAME_DEVICE equivalent
590
+ };
591
+ this._send(payloadToSend);
592
+ clearTimeout(this._socket._connTimeout);
593
+
594
+ // derive session key
595
+ let tmpKey = xorBuffers(this._tmpLocalKey, this._tmpRemoteKey);
596
+ this.session_key = encrypt35(tmpKey, this.context.key, this._tmpLocalKey.slice(0, 12));
597
+
598
+ this.connected = true;
599
+ this.update();
600
+ this.emit('connect');
601
+ if (this._socket._pinger) clearTimeout(this._socket._pinger);
602
+ this._socket._pinger = setTimeout(() => this._socket._ping(), 1000);
603
+ return callback();
604
+ }
605
+
606
+ if (cmd === 10 && parsedPayload === 'json obj data unvalid') {
607
+ this.log.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
608
+ this.emit('change', {}, this.state);
609
+ return callback();
610
+ }
611
+
612
+ switch (cmd) {
613
+ case 8:
614
+ case 10:
615
+ case 16:
616
+ if (parsedPayload && parsedPayload.dps) {
617
+ this._change(parsedPayload.dps);
618
+ } else {
619
+ this.log.info(`Malformed message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
620
+ }
621
+ break;
622
+ default:
623
+ this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
624
+ }
625
+
626
+ callback();
627
+ }
628
+
492
629
  update(o) {
493
630
  const dps = {};
494
631
  let hasDataPoint = false;
@@ -514,25 +651,25 @@ class TuyaAccessory extends EventEmitter {
514
651
  t,
515
652
  dps
516
653
  };
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
654
+ const data = (this.context.version === '3.4' || this.context.version === '3.5')
655
+ ? {
656
+ data: {
657
+ ...payload,
658
+ ctype: 0,
659
+ t: undefined
660
+ },
661
+ protocol: 5,
662
+ t
663
+ }
664
+ : payload;
528
665
  result = this._send({
529
666
  data,
530
- cmd: this.context.version === '3.4' ? 13 : 7
667
+ cmd: (this.context.version === '3.4' || this.context.version === '3.5') ? 13 : 7
531
668
  });
532
669
  if (result !== true) this.log.info(" Result", result);
533
670
  if (this.context.sendEmptyUpdate) {
534
671
  //this.log.info(" Sending", this.context.name, 'empty signature');
535
- this._send({cmd: this.context.version === '3.4' ? 13 : 7});
672
+ this._send({cmd: (this.context.version === '3.4' || this.context.version === '3.5') ? 13 : 7});
536
673
  }
537
674
  } else {
538
675
  //this.log.info(`Sending first query to ${this.context.name} (${this.context.version})`);
@@ -541,7 +678,7 @@ class TuyaAccessory extends EventEmitter {
541
678
  gwId: this.context.id,
542
679
  devId: this.context.id
543
680
  },
544
- cmd: this.context.version === '3.4' ? 16 : 10
681
+ cmd: (this.context.version === '3.4' || this.context.version === '3.5') ? 16 : 10
545
682
  });
546
683
  }
547
684
 
@@ -570,7 +707,8 @@ class TuyaAccessory extends EventEmitter {
570
707
 
571
708
  if (this.context.version < 3.2) return this._send_3_1(o);
572
709
  if (this.context.version === '3.3') return this._send_3_3(o);
573
- return this._send_3_4(o);
710
+ if (this.context.version === '3.4') return this._send_3_4(o);
711
+ return this._send_3_5(o);
574
712
  }
575
713
 
576
714
  _send_3_1(o) {
@@ -645,16 +783,6 @@ class TuyaAccessory extends EventEmitter {
645
783
  return this._socket.write(payload);
646
784
  }
647
785
 
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
786
  _send_3_4(o) {
659
787
  let {cmd, data} = {...o};
660
788
 
@@ -713,19 +841,105 @@ class TuyaAccessory extends EventEmitter {
713
841
 
714
842
  return this._socket.write(buffer);
715
843
  }
844
+
845
+ /*
846
+ * 3.5 Protocol send
847
+ */
848
+ _send_3_5(o) {
849
+ let {cmd, data} = {...o};
850
+ // data processing similar to 3.4
851
+ if (!data) data = Buffer.allocUnsafe(0);
852
+ if (!(data instanceof Buffer)) {
853
+ if (typeof data !== 'string') data = JSON.stringify(data);
854
+ data = Buffer.from(data);
855
+ }
856
+
857
+ if (cmd !== 10 && cmd !== 9 && cmd !== 16 && cmd !== 3 && cmd !== 5 && cmd !== 18) {
858
+ const buffer = Buffer.alloc(data.length + 15);
859
+ Buffer.from('3.5').copy(buffer, 0);
860
+ data.copy(buffer, 15);
861
+ data = buffer;
862
+ }
863
+
864
+ const iv = crypto.randomBytes(12);
865
+
866
+ // Increment sequence counter unless empty dp-update
867
+ if ((cmd !== 7 && cmd !== 13) || data.length) this._sendCounter++;
868
+
869
+ const unknownBuf = Buffer.alloc(2); // always 0x0000
870
+ const seqBuf = Buffer.alloc(4);
871
+ seqBuf.writeUInt32BE(this._sendCounter, 0);
872
+ const cmdBuf = Buffer.alloc(4);
873
+ cmdBuf.writeUInt32BE(cmd, 0);
874
+ const lenField = 12 + data.length + 16; // iv + payload + tag
875
+ const lenBuf = Buffer.alloc(4);
876
+ lenBuf.writeUInt32BE(lenField, 0);
877
+
878
+ const aad = Buffer.concat([unknownBuf, seqBuf, cmdBuf, lenBuf]);
879
+
880
+ const key = this.session_key ?? this.context.key;
881
+ const cipher = crypto.createCipheriv('aes-128-gcm', key, iv);
882
+ cipher.setAAD(aad);
883
+ const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
884
+ const tag = cipher.getAuthTag();
885
+
886
+ const prefixBuf = Buffer.from('00006699', 'hex');
887
+ const footerBuf = Buffer.from('00009966', 'hex');
888
+
889
+ const packet = Buffer.concat([
890
+ prefixBuf,
891
+ unknownBuf,
892
+ seqBuf,
893
+ cmdBuf,
894
+ lenBuf,
895
+ iv,
896
+ encrypted,
897
+ tag,
898
+ footerBuf
899
+ ]);
900
+
901
+ return this._socket.write(packet);
902
+ }
903
+
904
+ _fakeUpdate(dps) {
905
+ this.log.info('Fake update:', JSON.stringify(dps));
906
+ Object.keys(dps).forEach(dp => {
907
+ this.state[dp] = dps[dp];
908
+ });
909
+ setTimeout(() => {
910
+ this.emit('change', dps, this.state);
911
+ }, 1000);
912
+ }
716
913
  }
717
914
 
915
+ /* ------------------ Helper utilities ------------------- */
718
916
  const encrypt34 = (data, encryptKey) => {
719
917
  const cipher = crypto.createCipheriv('aes-128-ecb', encryptKey, null);
720
918
  cipher.setAutoPadding(false);
721
919
  let encrypted = cipher.update(data);
722
920
  cipher.final();
723
921
  return encrypted;
724
- }
922
+ };
923
+
924
+ // 3.5 encryption helper (AES‑128‑GCM). Returns ciphertext buffer.
925
+ const encrypt35 = (data, encryptKey, iv) => {
926
+ const cipher = crypto.createCipheriv('aes-128-gcm', encryptKey, iv);
927
+ cipher.setAAD(Buffer.alloc(0));
928
+ const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
929
+ /* auth tag is cipher.getAuthTag() but not required here */
930
+ return encrypted;
931
+ };
725
932
 
726
933
  const hmac = (data, hmacKey) => {
727
- return crypto.createHmac('sha256',hmacKey).update(data, 'utf8').digest();
728
- }
934
+ return crypto.createHmac('sha256', hmacKey).update(data, 'utf8').digest();
935
+ };
936
+
937
+ const xorBuffers = (a, b) => {
938
+ const len = Math.min(a.length, b.length);
939
+ const out = Buffer.alloc(len);
940
+ for (let i = 0; i < len; i++) out[i] = a[i] ^ b[i];
941
+ return out;
942
+ };
729
943
 
730
944
  const crc32LookupTable = [];
731
945
  (() => {
@@ -742,5 +956,4 @@ const getCRC32 = buffer => {
742
956
  return ~crc;
743
957
  };
744
958
 
745
-
746
959
  module.exports = TuyaAccessory;