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.
@@ -23,16 +23,35 @@ class TuyaAccessory extends EventEmitter {
23
23
  ...props,
24
24
  };
25
25
 
26
+ // The version can arrive as a string ('3.4', from broadcasts) or a number (3.4,
27
+ // from config.json). Keep a canonical string for protocol headers and a float for
28
+ // capability checks so devices reporting newer versions (e.g. a future 3.6) are
29
+ // handled by the newest stack instead of falling into a broken legacy path.
30
+ if (this.context.version === undefined || this.context.version === null || String(this.context.version).trim() === '') {
31
+ this.context.version = '3.5';
32
+ } else {
33
+ this.context.version = String(this.context.version).trim();
34
+ }
35
+ this._protocolVersion = parseFloat(this.context.version);
36
+ if (isNaN(this._protocolVersion)) {
37
+ if (this.log) this.log.warn(`Unrecognized protocol version "${this.context.version}" for ${this.context.name}; assuming 3.5.`);
38
+ this.context.version = '3.5';
39
+ this._protocolVersion = 3.5;
40
+ }
41
+ if (this._protocolVersion > 3.5 && this.log) {
42
+ this.log.info(`${this.context.name} reports protocol ${this.context.version}, newer than the newest known Tuya LAN protocol (3.5); using the 3.5 (GCM) stack with ${this.context.version} headers.`);
43
+ }
44
+
26
45
  this.state = {};
27
46
  this._cachedBuffer = Buffer.allocUnsafe(0);
28
47
 
29
48
  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'
49
+ this._protocolVersion < 3.2 ? '_msgHandler_3_1' :
50
+ this._protocolVersion < 3.4 ? '_msgHandler_3_3' :
51
+ this._protocolVersion < 3.5 ? '_msgHandler_3_4' : '_msgHandler_3_5'
33
52
  ].bind(this), 1);
34
53
 
35
- if (this.context.version >= 3.2) {
54
+ if (this._protocolVersion >= 3.2) {
36
55
  this.context.pingGap = Math.min(this.context.pingGap || 9, 9);
37
56
  //this.log.info(`Changing ping gap for ${this.context.name} to ${this.context.pingGap}s`);
38
57
  }
@@ -110,7 +129,7 @@ class TuyaAccessory extends EventEmitter {
110
129
  };
111
130
 
112
131
  this._socket.on('connect', () => {
113
- if (this.context.version !== '3.4' && this.context.version !== '3.5') {
132
+ if (this._protocolVersion < 3.4) {
114
133
  clearTimeout(this._socket._connTimeout);
115
134
 
116
135
  this.connected = true;
@@ -130,7 +149,7 @@ class TuyaAccessory extends EventEmitter {
130
149
  if (this.context.intro === false) return;
131
150
  this.connected = true;
132
151
 
133
- if (this.context.version === '3.4' || this.context.version === '3.5') {
152
+ if (this._protocolVersion >= 3.4) {
134
153
  this._tmpLocalKey = crypto.randomBytes(16);
135
154
  const payload = {
136
155
  data: this._tmpLocalKey,
@@ -148,7 +167,7 @@ class TuyaAccessory extends EventEmitter {
148
167
 
149
168
  do {
150
169
  let startingIndex;
151
- if (this.context.version === '3.5') {
170
+ if (this._protocolVersion >= 3.5) {
152
171
  startingIndex = this._cachedBuffer.indexOf('00006699', 'hex');
153
172
  } else {
154
173
  startingIndex = this._cachedBuffer.indexOf('000055aa', 'hex');
@@ -160,7 +179,7 @@ class TuyaAccessory extends EventEmitter {
160
179
  if (startingIndex !== 0) this._cachedBuffer = this._cachedBuffer.slice(startingIndex);
161
180
 
162
181
  let endingIndex;
163
- if (this.context.version === '3.5') {
182
+ if (this._protocolVersion >= 3.5) {
164
183
  endingIndex = this._cachedBuffer.indexOf('00009966', 'hex');
165
184
  if (endingIndex !== -1) endingIndex += 4;
166
185
  } else {
@@ -207,9 +226,21 @@ class TuyaAccessory extends EventEmitter {
207
226
  }
208
227
  });
209
228
 
210
- this._socket.on('close', err => {
229
+ this._socket.on('close', () => {
211
230
  this.connected = false;
212
231
  this.session_key = null;
232
+
233
+ // A heartbeat timer must never outlive its socket. The pinger
234
+ // callbacks act on `this._socket`, so a stale timer left over from a
235
+ // dead socket would later fire against a freshly reconnected one and
236
+ // trip a spurious ERR_PING_TIMED_OUT. Clearing it here covers every
237
+ // teardown path (error-driven destroy as well as the graceful end
238
+ // below).
239
+ if (this._socket._pinger) {
240
+ clearTimeout(this._socket._pinger);
241
+ this._socket._pinger = null;
242
+ }
243
+
213
244
  //this.log.info('Closed connection with', this.context.name);
214
245
  });
215
246
 
@@ -217,6 +248,32 @@ class TuyaAccessory extends EventEmitter {
217
248
  this.connected = false;
218
249
  this.session_key = null;
219
250
  this.log.info('Disconnected from', this.context.name);
251
+
252
+ // The device closed the connection on its own. This is routine for
253
+ // many Tuya devices (e.g. LED ceiling lights) that recycle their
254
+ // long-lived LAN sockets every few minutes. Previously nothing here
255
+ // tore the socket down or reconnected: the heartbeat timer kept
256
+ // running and, because `connected` is now false, its pings went
257
+ // nowhere until the full ping timeout elapsed and emitted a
258
+ // misleading "ERR_PING_TIMED_OUT" ~30s later - which was the only
259
+ // thing that ultimately triggered a reconnect. Tear down and
260
+ // reconnect promptly instead, so recovery is quick and the logs
261
+ // reflect what actually happened (a clean disconnect, not a ping
262
+ // failure).
263
+ if (this._socket._pinger) {
264
+ clearTimeout(this._socket._pinger);
265
+ this._socket._pinger = null;
266
+ }
267
+
268
+ // Destroying first means a trailing RST can't resurface as an
269
+ // 'error' and schedule a second, competing reconnect.
270
+ this._socket.destroy();
271
+
272
+ if (!this._socket._errorReconnect) {
273
+ this._socket._errorReconnect = setTimeout(() => {
274
+ process.nextTick(this._connect.bind(this));
275
+ }, 5000);
276
+ }
220
277
  });
221
278
  }
222
279
 
@@ -336,7 +393,8 @@ class TuyaAccessory extends EventEmitter {
336
393
  return callback();
337
394
  }
338
395
 
339
- let versionPos = task.msg.indexOf('3.3');
396
+ let versionPos = task.msg.indexOf(this.context.version);
397
+ if (versionPos === -1) versionPos = task.msg.indexOf('3.3');
340
398
  if (versionPos === -1) versionPos = task.msg.indexOf('3.2');
341
399
  const cleanMsg = task.msg.slice(versionPos === -1 ? len - size + ((task.msg.readUInt32BE(16) & 0xFFFFFF00) ? 0 : 4) : 15 + versionPos, len - 8);
342
400
 
@@ -410,7 +468,7 @@ class TuyaAccessory extends EventEmitter {
410
468
  return callback();
411
469
  }
412
470
 
413
- let versionPos = task.msg.indexOf('3.4');
471
+ let versionPos = task.msg.indexOf(this.context.version);
414
472
  const cleanMsg = task.msg.slice(versionPos === -1 ? len - size + ((task.msg.readUInt32BE(16) & 0xFFFFFF00) ? 0 : 4) : 15 + versionPos, len - 0x24);
415
473
 
416
474
  const expectedCrc = task.msg.slice(len - 0x24, task.msg.length - 4).toString('hex');
@@ -430,7 +488,7 @@ class TuyaAccessory extends EventEmitter {
430
488
 
431
489
  let parsedPayload;
432
490
  try {
433
- if (decryptedMsg.indexOf(this.context.version) === 0) {
491
+ if (hasVersionHeader(decryptedMsg)) {
434
492
  decryptedMsg = decryptedMsg.slice(15)
435
493
  }
436
494
  let res = JSON.parse(decryptedMsg)
@@ -508,7 +566,8 @@ class TuyaAccessory extends EventEmitter {
508
566
  }
509
567
 
510
568
  /*
511
- * 3.5 Protocol message handler
569
+ * 3.5+ Protocol message handler (0x6699 frames, AES-128-GCM). Also used for any
570
+ * newer version a device may report (e.g. 3.6) until a format change is documented.
512
571
  */
513
572
  _msgHandler_3_5(task, callback) {
514
573
  if (!(task.msg instanceof Buffer)) return callback();
@@ -558,8 +617,8 @@ class TuyaAccessory extends EventEmitter {
558
617
  }
559
618
  }
560
619
 
561
- // remove leading version header
562
- if (payload.indexOf(this.context.version) === 0) payload = payload.slice(15);
620
+ // remove leading version header ("3.x" + 12 bytes), whichever 3.x the device used
621
+ if (hasVersionHeader(payload)) payload = payload.slice(15);
563
622
 
564
623
  // Attempt JSON parse
565
624
  let parsedPayload;
@@ -651,7 +710,7 @@ class TuyaAccessory extends EventEmitter {
651
710
  t,
652
711
  dps
653
712
  };
654
- const data = (this.context.version === '3.4' || this.context.version === '3.5')
713
+ const data = this._protocolVersion >= 3.4
655
714
  ? {
656
715
  data: {
657
716
  ...payload,
@@ -664,12 +723,12 @@ class TuyaAccessory extends EventEmitter {
664
723
  : payload;
665
724
  result = this._send({
666
725
  data,
667
- cmd: (this.context.version === '3.4' || this.context.version === '3.5') ? 13 : 7
726
+ cmd: this._protocolVersion >= 3.4 ? 13 : 7
668
727
  });
669
728
  if (result !== true) this.log.info(" Result", result);
670
729
  if (this.context.sendEmptyUpdate) {
671
730
  //this.log.info(" Sending", this.context.name, 'empty signature');
672
- this._send({cmd: (this.context.version === '3.4' || this.context.version === '3.5') ? 13 : 7});
731
+ this._send({cmd: this._protocolVersion >= 3.4 ? 13 : 7});
673
732
  }
674
733
  } else {
675
734
  //this.log.info(`Sending first query to ${this.context.name} (${this.context.version})`);
@@ -678,7 +737,7 @@ class TuyaAccessory extends EventEmitter {
678
737
  gwId: this.context.id,
679
738
  devId: this.context.id
680
739
  },
681
- cmd: (this.context.version === '3.4' || this.context.version === '3.5') ? 16 : 10
740
+ cmd: this._protocolVersion >= 3.4 ? 16 : 10
682
741
  });
683
742
  }
684
743
 
@@ -705,9 +764,9 @@ class TuyaAccessory extends EventEmitter {
705
764
  if (this.context.fake) return;
706
765
  if (!this.connected) return false;
707
766
 
708
- if (this.context.version < 3.2) return this._send_3_1(o);
709
- if (this.context.version === '3.3') return this._send_3_3(o);
710
- if (this.context.version === '3.4') return this._send_3_4(o);
767
+ if (this._protocolVersion < 3.2) return this._send_3_1(o);
768
+ if (this._protocolVersion < 3.4) return this._send_3_3(o);
769
+ if (this._protocolVersion < 3.5) return this._send_3_4(o);
711
770
  return this._send_3_5(o);
712
771
  }
713
772
 
@@ -759,9 +818,9 @@ class TuyaAccessory extends EventEmitter {
759
818
  cmd.toString(16).padStart(8, '0'), //command
760
819
  '00000000' //size
761
820
  ];
762
- //version
821
+ //version ("3.x" + 12 null bytes, e.g. '332e33...' for 3.3)
763
822
  if (cmd === 7 && !data) hex.push('00000000');
764
- else if (cmd !== 9 && cmd !== 10) hex.push('332e33000000000000000000000000');
823
+ else if (cmd !== 9 && cmd !== 10) hex.push(Buffer.concat([Buffer.from(this.context.version), Buffer.alloc(12)]).toString('hex'));
765
824
  //data
766
825
  if (data) {
767
826
  const cipher = crypto.createCipheriv('aes-128-ecb', this.context.key, '');
@@ -804,10 +863,10 @@ class TuyaAccessory extends EventEmitter {
804
863
  cmd !== 3 &&
805
864
  cmd !== 5 &&
806
865
  cmd !== 18) {
807
- // Add 3.4 header
866
+ // Add version header (the version the device reported, e.g. '3.4')
808
867
  // check this: mqc_very_pcmcd_mcd(int a1, unsigned int a2)
809
868
  const buffer = Buffer.alloc(data.length + 15);
810
- Buffer.from('3.4').copy(buffer, 0);
869
+ Buffer.from(this.context.version).copy(buffer, 0);
811
870
  data.copy(buffer, 15);
812
871
  data = buffer;
813
872
  }
@@ -843,7 +902,8 @@ class TuyaAccessory extends EventEmitter {
843
902
  }
844
903
 
845
904
  /*
846
- * 3.5 Protocol send
905
+ * 3.5+ Protocol send (0x6699 frames, AES-128-GCM). Also used for any newer version
906
+ * a device may report (e.g. 3.6) until a format change is documented.
847
907
  */
848
908
  _send_3_5(o) {
849
909
  let {cmd, data} = {...o};
@@ -855,8 +915,9 @@ class TuyaAccessory extends EventEmitter {
855
915
  }
856
916
 
857
917
  if (cmd !== 10 && cmd !== 9 && cmd !== 16 && cmd !== 3 && cmd !== 5 && cmd !== 18) {
918
+ // Version header uses the version the device reported (3.5, or newer like 3.6)
858
919
  const buffer = Buffer.alloc(data.length + 15);
859
- Buffer.from('3.5').copy(buffer, 0);
920
+ Buffer.from(this.context.version).copy(buffer, 0);
860
921
  data.copy(buffer, 15);
861
922
  data = buffer;
862
923
  }
@@ -934,6 +995,12 @@ const hmac = (data, hmacKey) => {
934
995
  return crypto.createHmac('sha256', hmacKey).update(data, 'utf8').digest();
935
996
  };
936
997
 
998
+ // Payloads of protocol 3.2+ may start with a 15-byte version header: "3.x" followed by
999
+ // 12 bytes. Match any "3.<digit>" so replies still parse when the device answers with a
1000
+ // different minor version than the one we connected with.
1001
+ const hasVersionHeader = buf =>
1002
+ buf.length >= 15 && buf[0] === 0x33 && buf[1] === 0x2e && buf[2] >= 0x30 && buf[2] <= 0x39;
1003
+
937
1004
  const xorBuffers = (a, b) => {
938
1005
  const len = Math.min(a.length, b.length);
939
1006
  const out = Buffer.alloc(len);
@@ -4,11 +4,12 @@ const EventEmitter = require('events');
4
4
 
5
5
  const UDP_KEY = Buffer.from('6c1ec8e2bb9bb59ab50b0daf649b410a', 'hex');
6
6
 
7
- const GCM_DISCOVERY_KEY = crypto.createHash('md5').update('yGAdlopoPVldABfn').digest(); // v3.5 UDP/GCM key
7
+ const GCM_DISCOVERY_KEY = crypto.createHash('md5').update('yGAdlopoPVldABfn').digest(); // v3.5+ UDP/GCM key
8
8
  const PREFIX_55AA = 0x000055aa; // v3.1-3.4
9
- const PREFIX_6699 = 0x00006699; // v3.5
9
+ const PREFIX_6699 = 0x00006699; // v3.5+
10
10
  const SUFFIX_AA55 = 0x0000aa55;
11
11
  const SUFFIX_9966 = 0x00009966;
12
+ const REQ_DEVINFO = 0x25; // v3.5+ broadcast probe command (FR_TYPE_BOARDCAST_LPV34/REQ_DEVINFO)
12
13
 
13
14
  class TuyaDiscovery extends EventEmitter {
14
15
  constructor() {
@@ -36,8 +37,15 @@ class TuyaDiscovery extends EventEmitter {
36
37
  this._running = true;
37
38
  this._start(6666);
38
39
  this._start(6667);
39
- this._start(7000); // v3.5 direct-reply port
40
- this._sendV35Probe(); // broadcast a probe so silent 3.5 devices answer us
40
+ this._start(7000); // v3.5+ direct-reply port
41
+
42
+ // v3.5+ devices stay silent until solicited. One probe usually suffices,
43
+ // but UDP is lossy and the device may have been mid-boot — repeat every
44
+ // 6s (matches tinytuya's BROADCASTTIME) so a single dropped packet
45
+ // doesn't burn the entire discovery window.
46
+ this._sendV35Probe();
47
+ if (this._v35ProbeTimer) clearInterval(this._v35ProbeTimer);
48
+ this._v35ProbeTimer = setInterval(() => this._sendV35Probe(), 6000);
41
49
 
42
50
  return this;
43
51
  }
@@ -48,6 +56,11 @@ class TuyaDiscovery extends EventEmitter {
48
56
  this._stop(6667);
49
57
  this._stop(7000);
50
58
 
59
+ if (this._v35ProbeTimer) {
60
+ clearInterval(this._v35ProbeTimer);
61
+ this._v35ProbeTimer = null;
62
+ }
63
+
51
64
  return this;
52
65
  }
53
66
 
@@ -114,7 +127,9 @@ class TuyaDiscovery extends EventEmitter {
114
127
  const suffix = msg.readUInt32BE(len - 4);
115
128
 
116
129
  /* 3.1-3.4 packets: 0x55AA … 0xAA55
117
- 3.5 packets: 0x6699 … 0x9966 */
130
+ 3.5+ packets: 0x6699 … 0x9966
131
+ The 6699 payload carries the device's "version" field (3.5, or newer like
132
+ 3.6), which flows into TuyaAccessory and selects the protocol stack. */
118
133
  const isV34Frame = prefix === 0x000055aa && suffix === 0x0000aa55;
119
134
  const isV35Frame = prefix === 0x00006699 && suffix === 0x00009966;
120
135
 
@@ -243,25 +258,43 @@ class TuyaDiscovery extends EventEmitter {
243
258
  }
244
259
  }
245
260
 
261
+ // Broadcast a v3.5+ REQ_DEVINFO probe to UDP/7000. v3.5+ devices do not
262
+ // emit periodic plaintext broadcasts like 3.1-3.3, so they are invisible
263
+ // to passive listeners until something solicits them. Each correctly-formed
264
+ // probe causes the device to unicast its discovery JSON back to us, which
265
+ // _handleV35 then decodes.
266
+ //
267
+ // Frame layout (big-endian):
268
+ // prefix(4) + reserved(2) + seq(4) + cmd(4) + length(4) [14B header after prefix]
269
+ // + iv(12) + ciphertext(payloadLen) + tag(16) + suffix(4)
270
+ //
271
+ // AAD for GCM must be the 14 header bytes after the prefix exactly as
272
+ // they appear on the wire. Anything else and the receiver's auth tag
273
+ // verification fails and the packet is silently dropped.
246
274
  _sendV35Probe() {
247
- const socket = dgram.createSocket('udp4');
248
275
  const payload = Buffer.from('{"from":"app","ip":"255.255.255.255"}');
249
- const iv = crypto.randomBytes(12);
276
+
277
+ const prefix = Buffer.from('00006699', 'hex');
278
+ const suffix = Buffer.from('00009966', 'hex');
279
+ const reserved = Buffer.alloc(2); // 0x0000
280
+ const seq = Buffer.alloc(4); // 0
281
+ const cmd = Buffer.alloc(4); cmd.writeUInt32BE(REQ_DEVINFO, 0);
282
+ const iv = crypto.randomBytes(12);
283
+ const len = Buffer.alloc(4); len.writeUInt32BE(iv.length + payload.length + 16, 0);
284
+ const aad = Buffer.concat([reserved, seq, cmd, len]);
285
+
250
286
  const cipher = crypto.createCipheriv('aes-128-gcm', GCM_DISCOVERY_KEY, iv);
251
- cipher.setAAD(Buffer.alloc(14)); // UUUU+seq+cmd+len = zeros OK for probe
287
+ cipher.setAAD(aad);
252
288
  const enc = Buffer.concat([cipher.update(payload), cipher.final()]);
253
289
  const tag = cipher.getAuthTag();
254
- const len = Buffer.alloc(4); len.writeUInt32BE(iv.length + enc.length + tag.length, 0);
255
- const frame = Buffer.concat([
256
- Buffer.from([0,0,0,0x66,0x99].slice(0,4)), // 6699 prefix
257
- Buffer.alloc(14), // UUUU + seq + cmd (0) + len placeholder
258
- len,
259
- iv,
260
- enc,
261
- tag,
262
- Buffer.from('00009966','hex')
263
- ]);
264
- socket.send(frame, 7000, '255.255.255.255', () => socket.close());
290
+
291
+ const frame = Buffer.concat([prefix, reserved, seq, cmd, len, iv, enc, tag, suffix]);
292
+
293
+ const socket = dgram.createSocket('udp4');
294
+ socket.bind(0, () => {
295
+ socket.setBroadcast(true);
296
+ socket.send(frame, 7000, '255.255.255.255', () => socket.close());
297
+ });
265
298
  }
266
299
  }
267
300