homebridge-tuya-plus 3.2.0 → 3.3.0-beta.1
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/lib/TuyaAccessory.js +246 -37
- package/lib/TuyaDiscovery.js +112 -16
- package/package.json +1 -1
package/lib/TuyaAccessory.js
CHANGED
|
@@ -22,7 +22,11 @@ class TuyaAccessory extends EventEmitter {
|
|
|
22
22
|
this.state = {};
|
|
23
23
|
this._cachedBuffer = Buffer.allocUnsafe(0);
|
|
24
24
|
|
|
25
|
-
this._msgQueue = async.queue(this[
|
|
25
|
+
this._msgQueue = async.queue(this[
|
|
26
|
+
this.context.version < 3.2 ? '_msgHandler_3_1' :
|
|
27
|
+
this.context.version === '3.3' ? '_msgHandler_3_3' :
|
|
28
|
+
this.context.version === '3.4' ? '_msgHandler_3_4' : '_msgHandler_3_5'
|
|
29
|
+
].bind(this), 1);
|
|
26
30
|
|
|
27
31
|
if (this.context.version >= 3.2) {
|
|
28
32
|
this.context.pingGap = Math.min(this.context.pingGap || 9, 9);
|
|
@@ -102,7 +106,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
102
106
|
};
|
|
103
107
|
|
|
104
108
|
this._socket.on('connect', () => {
|
|
105
|
-
if (this.context.version !== '3.4') {
|
|
109
|
+
if (this.context.version !== '3.4' && this.context.version !== '3.5') {
|
|
106
110
|
clearTimeout(this._socket._connTimeout);
|
|
107
111
|
|
|
108
112
|
this.connected = true;
|
|
@@ -122,13 +126,13 @@ class TuyaAccessory extends EventEmitter {
|
|
|
122
126
|
if (this.context.intro === false) return;
|
|
123
127
|
this.connected = true;
|
|
124
128
|
|
|
125
|
-
if (this.context.version === '3.4') {
|
|
129
|
+
if (this.context.version === '3.4' || this.context.version === '3.5') {
|
|
126
130
|
this._tmpLocalKey = crypto.randomBytes(16);
|
|
127
131
|
const payload = {
|
|
128
132
|
data: this._tmpLocalKey,
|
|
129
133
|
encrypted: true,
|
|
130
134
|
cmd: 3 //CommandType.BIND
|
|
131
|
-
}
|
|
135
|
+
};
|
|
132
136
|
this._send(payload);
|
|
133
137
|
} else {
|
|
134
138
|
this.update();
|
|
@@ -139,18 +143,28 @@ class TuyaAccessory extends EventEmitter {
|
|
|
139
143
|
this._cachedBuffer = Buffer.concat([this._cachedBuffer, msg]);
|
|
140
144
|
|
|
141
145
|
do {
|
|
142
|
-
let startingIndex
|
|
146
|
+
let startingIndex;
|
|
147
|
+
if (this.context.version === '3.5') {
|
|
148
|
+
startingIndex = this._cachedBuffer.indexOf('00006699', 'hex');
|
|
149
|
+
} else {
|
|
150
|
+
startingIndex = this._cachedBuffer.indexOf('000055aa', 'hex');
|
|
151
|
+
}
|
|
143
152
|
if (startingIndex === -1) {
|
|
144
153
|
this._cachedBuffer = Buffer.allocUnsafe(0);
|
|
145
154
|
break;
|
|
146
155
|
}
|
|
147
156
|
if (startingIndex !== 0) this._cachedBuffer = this._cachedBuffer.slice(startingIndex);
|
|
148
157
|
|
|
149
|
-
let endingIndex
|
|
158
|
+
let endingIndex;
|
|
159
|
+
if (this.context.version === '3.5') {
|
|
160
|
+
endingIndex = this._cachedBuffer.indexOf('00009966', 'hex');
|
|
161
|
+
if (endingIndex !== -1) endingIndex += 4;
|
|
162
|
+
} else {
|
|
163
|
+
endingIndex = this._cachedBuffer.indexOf('0000aa55', 'hex');
|
|
164
|
+
if (endingIndex !== -1) endingIndex += 4;
|
|
165
|
+
}
|
|
150
166
|
if (endingIndex === -1) break;
|
|
151
167
|
|
|
152
|
-
endingIndex += 4;
|
|
153
|
-
|
|
154
168
|
this._msgQueue.push({msg: this._cachedBuffer.slice(0, endingIndex)});
|
|
155
169
|
|
|
156
170
|
this._cachedBuffer = this._cachedBuffer.slice(endingIndex);
|
|
@@ -489,6 +503,125 @@ class TuyaAccessory extends EventEmitter {
|
|
|
489
503
|
callback();
|
|
490
504
|
}
|
|
491
505
|
|
|
506
|
+
/*
|
|
507
|
+
* 3.5 Protocol message handler
|
|
508
|
+
*/
|
|
509
|
+
_msgHandler_3_5(task, callback) {
|
|
510
|
+
if (!(task.msg instanceof Buffer)) return callback();
|
|
511
|
+
|
|
512
|
+
const len = task.msg.length;
|
|
513
|
+
if (len < 22 ||
|
|
514
|
+
task.msg.readUInt32BE(0) !== 0x00006699 ||
|
|
515
|
+
task.msg.readUInt32BE(len - 4) !== 0x00009966) return callback();
|
|
516
|
+
|
|
517
|
+
const declaredLen = task.msg.readUInt32BE(14);
|
|
518
|
+
if ((len - 22) < declaredLen) return callback();
|
|
519
|
+
|
|
520
|
+
const cmd = task.msg.readUInt32BE(10);
|
|
521
|
+
|
|
522
|
+
if (cmd === 7 || cmd === 13) return callback(); // ignoring
|
|
523
|
+
if (cmd === 9) {
|
|
524
|
+
if (this._socket._pinger) clearTimeout(this._socket._pinger);
|
|
525
|
+
this._socket._pinger = setTimeout(() => {
|
|
526
|
+
this._socket._ping();
|
|
527
|
+
}, (this.context.pingGap || 20) * 1000);
|
|
528
|
+
return callback();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const iv = task.msg.slice(18, 30);
|
|
532
|
+
const encrypted = task.msg.slice(30, len - 20); // remove tag + footer
|
|
533
|
+
const tag = task.msg.slice(len - 20, len - 4);
|
|
534
|
+
const aad = task.msg.slice(4, 18); // UUUU + seq + cmd + len (14 bytes)
|
|
535
|
+
|
|
536
|
+
let decrypted;
|
|
537
|
+
try {
|
|
538
|
+
const decipher = crypto.createDecipheriv('aes-128-gcm', this.session_key ?? this.context.key, iv);
|
|
539
|
+
decipher.setAAD(aad);
|
|
540
|
+
decipher.setAuthTag(tag);
|
|
541
|
+
decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
542
|
+
} catch(ex) {
|
|
543
|
+
this.log.info(`Failed to decrypt message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, ex);
|
|
544
|
+
return callback();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// strip return code if present (first 4 bytes)
|
|
548
|
+
let payload = decrypted;
|
|
549
|
+
if (decrypted.length > 4) {
|
|
550
|
+
try {
|
|
551
|
+
JSON.parse(decrypted.toString('utf8'));
|
|
552
|
+
} catch(_) {
|
|
553
|
+
payload = decrypted.slice(4);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// remove leading version header
|
|
558
|
+
if (payload.indexOf(this.context.version) === 0) payload = payload.slice(15);
|
|
559
|
+
|
|
560
|
+
// Attempt JSON parse
|
|
561
|
+
let parsedPayload;
|
|
562
|
+
try {
|
|
563
|
+
parsedPayload = JSON.parse(payload.toString('utf8'));
|
|
564
|
+
if (parsedPayload && parsedPayload.data) {
|
|
565
|
+
const tmp = parsedPayload;
|
|
566
|
+
parsedPayload = tmp.data;
|
|
567
|
+
parsedPayload.t = tmp.t;
|
|
568
|
+
}
|
|
569
|
+
} catch(_) {
|
|
570
|
+
parsedPayload = payload; // may be buffer during key exchange
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---- Handle 3‑way key exchange ----
|
|
574
|
+
if (cmd === 4 && Buffer.isBuffer(parsedPayload)) {
|
|
575
|
+
this._tmpRemoteKey = parsedPayload.subarray(0, 16);
|
|
576
|
+
const calcLocalHmac = hmac(this._tmpLocalKey, this.context.key).toString('hex');
|
|
577
|
+
const expLocalHmac = parsedPayload.slice(16, 48).toString('hex');
|
|
578
|
+
if (calcLocalHmac !== expLocalHmac) {
|
|
579
|
+
throw new Error(`HMAC mismatch(keys): expected ${expLocalHmac}, was ${calcLocalHmac}. ${parsedPayload.toString('hex')}`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const payloadToSend = {
|
|
583
|
+
data: hmac(this._tmpRemoteKey, this.context.key),
|
|
584
|
+
encrypted: true,
|
|
585
|
+
cmd: 5 // CommandType.RENAME_DEVICE equivalent
|
|
586
|
+
};
|
|
587
|
+
this._send(payloadToSend);
|
|
588
|
+
clearTimeout(this._socket._connTimeout);
|
|
589
|
+
|
|
590
|
+
// derive session key
|
|
591
|
+
let tmpKey = xorBuffers(this._tmpLocalKey, this._tmpRemoteKey);
|
|
592
|
+
this.session_key = encrypt35(tmpKey, this.context.key, this._tmpLocalKey.slice(0, 12));
|
|
593
|
+
|
|
594
|
+
this.connected = true;
|
|
595
|
+
this.update();
|
|
596
|
+
this.emit('connect');
|
|
597
|
+
if (this._socket._pinger) clearTimeout(this._socket._pinger);
|
|
598
|
+
this._socket._pinger = setTimeout(() => this._socket._ping(), 1000);
|
|
599
|
+
return callback();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (cmd === 10 && parsedPayload === 'json obj data unvalid') {
|
|
603
|
+
this.log.info(`${this.context.name} (${this.context.version}) didn't respond with its current state.`);
|
|
604
|
+
this.emit('change', {}, this.state);
|
|
605
|
+
return callback();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
switch (cmd) {
|
|
609
|
+
case 8:
|
|
610
|
+
case 10:
|
|
611
|
+
case 16:
|
|
612
|
+
if (parsedPayload && parsedPayload.dps) {
|
|
613
|
+
this._change(parsedPayload.dps);
|
|
614
|
+
} else {
|
|
615
|
+
this.log.info(`Malformed message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
|
|
616
|
+
}
|
|
617
|
+
break;
|
|
618
|
+
default:
|
|
619
|
+
this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
callback();
|
|
623
|
+
}
|
|
624
|
+
|
|
492
625
|
update(o) {
|
|
493
626
|
const dps = {};
|
|
494
627
|
let hasDataPoint = false;
|
|
@@ -514,25 +647,25 @@ class TuyaAccessory extends EventEmitter {
|
|
|
514
647
|
t,
|
|
515
648
|
dps
|
|
516
649
|
};
|
|
517
|
-
const data = this.context.version === '3.4'
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
650
|
+
const data = (this.context.version === '3.4' || this.context.version === '3.5')
|
|
651
|
+
? {
|
|
652
|
+
data: {
|
|
653
|
+
...payload,
|
|
654
|
+
ctype: 0,
|
|
655
|
+
t: undefined
|
|
656
|
+
},
|
|
657
|
+
protocol: 5,
|
|
658
|
+
t
|
|
659
|
+
}
|
|
660
|
+
: payload;
|
|
528
661
|
result = this._send({
|
|
529
662
|
data,
|
|
530
|
-
cmd: this.context.version === '3.4' ? 13 : 7
|
|
663
|
+
cmd: (this.context.version === '3.4' || this.context.version === '3.5') ? 13 : 7
|
|
531
664
|
});
|
|
532
665
|
if (result !== true) this.log.info(" Result", result);
|
|
533
666
|
if (this.context.sendEmptyUpdate) {
|
|
534
667
|
//this.log.info(" Sending", this.context.name, 'empty signature');
|
|
535
|
-
this._send({cmd: this.context.version === '3.4' ? 13 : 7});
|
|
668
|
+
this._send({cmd: (this.context.version === '3.4' || this.context.version === '3.5') ? 13 : 7});
|
|
536
669
|
}
|
|
537
670
|
} else {
|
|
538
671
|
//this.log.info(`Sending first query to ${this.context.name} (${this.context.version})`);
|
|
@@ -541,7 +674,7 @@ class TuyaAccessory extends EventEmitter {
|
|
|
541
674
|
gwId: this.context.id,
|
|
542
675
|
devId: this.context.id
|
|
543
676
|
},
|
|
544
|
-
cmd: this.context.version === '3.4' ? 16 : 10
|
|
677
|
+
cmd: (this.context.version === '3.4' || this.context.version === '3.5') ? 16 : 10
|
|
545
678
|
});
|
|
546
679
|
}
|
|
547
680
|
|
|
@@ -570,7 +703,8 @@ class TuyaAccessory extends EventEmitter {
|
|
|
570
703
|
|
|
571
704
|
if (this.context.version < 3.2) return this._send_3_1(o);
|
|
572
705
|
if (this.context.version === '3.3') return this._send_3_3(o);
|
|
573
|
-
return this._send_3_4(o);
|
|
706
|
+
if (this.context.version === '3.4') return this._send_3_4(o);
|
|
707
|
+
return this._send_3_5(o);
|
|
574
708
|
}
|
|
575
709
|
|
|
576
710
|
_send_3_1(o) {
|
|
@@ -645,16 +779,6 @@ class TuyaAccessory extends EventEmitter {
|
|
|
645
779
|
return this._socket.write(payload);
|
|
646
780
|
}
|
|
647
781
|
|
|
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
782
|
_send_3_4(o) {
|
|
659
783
|
let {cmd, data} = {...o};
|
|
660
784
|
|
|
@@ -713,19 +837,105 @@ class TuyaAccessory extends EventEmitter {
|
|
|
713
837
|
|
|
714
838
|
return this._socket.write(buffer);
|
|
715
839
|
}
|
|
840
|
+
|
|
841
|
+
/*
|
|
842
|
+
* 3.5 Protocol send
|
|
843
|
+
*/
|
|
844
|
+
_send_3_5(o) {
|
|
845
|
+
let {cmd, data} = {...o};
|
|
846
|
+
// data processing similar to 3.4
|
|
847
|
+
if (!data) data = Buffer.allocUnsafe(0);
|
|
848
|
+
if (!(data instanceof Buffer)) {
|
|
849
|
+
if (typeof data !== 'string') data = JSON.stringify(data);
|
|
850
|
+
data = Buffer.from(data);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (cmd !== 10 && cmd !== 9 && cmd !== 16 && cmd !== 3 && cmd !== 5 && cmd !== 18) {
|
|
854
|
+
const buffer = Buffer.alloc(data.length + 15);
|
|
855
|
+
Buffer.from('3.5').copy(buffer, 0);
|
|
856
|
+
data.copy(buffer, 15);
|
|
857
|
+
data = buffer;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const iv = crypto.randomBytes(12);
|
|
861
|
+
|
|
862
|
+
// Increment sequence counter unless empty dp-update
|
|
863
|
+
if ((cmd !== 7 && cmd !== 13) || data.length) this._sendCounter++;
|
|
864
|
+
|
|
865
|
+
const unknownBuf = Buffer.alloc(2); // always 0x0000
|
|
866
|
+
const seqBuf = Buffer.alloc(4);
|
|
867
|
+
seqBuf.writeUInt32BE(this._sendCounter, 0);
|
|
868
|
+
const cmdBuf = Buffer.alloc(4);
|
|
869
|
+
cmdBuf.writeUInt32BE(cmd, 0);
|
|
870
|
+
const lenField = 12 + data.length + 16; // iv + payload + tag
|
|
871
|
+
const lenBuf = Buffer.alloc(4);
|
|
872
|
+
lenBuf.writeUInt32BE(lenField, 0);
|
|
873
|
+
|
|
874
|
+
const aad = Buffer.concat([unknownBuf, seqBuf, cmdBuf, lenBuf]);
|
|
875
|
+
|
|
876
|
+
const key = this.session_key ?? this.context.key;
|
|
877
|
+
const cipher = crypto.createCipheriv('aes-128-gcm', key, iv);
|
|
878
|
+
cipher.setAAD(aad);
|
|
879
|
+
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
880
|
+
const tag = cipher.getAuthTag();
|
|
881
|
+
|
|
882
|
+
const prefixBuf = Buffer.from('00006699', 'hex');
|
|
883
|
+
const footerBuf = Buffer.from('00009966', 'hex');
|
|
884
|
+
|
|
885
|
+
const packet = Buffer.concat([
|
|
886
|
+
prefixBuf,
|
|
887
|
+
unknownBuf,
|
|
888
|
+
seqBuf,
|
|
889
|
+
cmdBuf,
|
|
890
|
+
lenBuf,
|
|
891
|
+
iv,
|
|
892
|
+
encrypted,
|
|
893
|
+
tag,
|
|
894
|
+
footerBuf
|
|
895
|
+
]);
|
|
896
|
+
|
|
897
|
+
return this._socket.write(packet);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
_fakeUpdate(dps) {
|
|
901
|
+
this.log.info('Fake update:', JSON.stringify(dps));
|
|
902
|
+
Object.keys(dps).forEach(dp => {
|
|
903
|
+
this.state[dp] = dps[dp];
|
|
904
|
+
});
|
|
905
|
+
setTimeout(() => {
|
|
906
|
+
this.emit('change', dps, this.state);
|
|
907
|
+
}, 1000);
|
|
908
|
+
}
|
|
716
909
|
}
|
|
717
910
|
|
|
911
|
+
/* ------------------ Helper utilities ------------------- */
|
|
718
912
|
const encrypt34 = (data, encryptKey) => {
|
|
719
913
|
const cipher = crypto.createCipheriv('aes-128-ecb', encryptKey, null);
|
|
720
914
|
cipher.setAutoPadding(false);
|
|
721
915
|
let encrypted = cipher.update(data);
|
|
722
916
|
cipher.final();
|
|
723
917
|
return encrypted;
|
|
724
|
-
}
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
// 3.5 encryption helper (AES‑128‑GCM). Returns ciphertext buffer.
|
|
921
|
+
const encrypt35 = (data, encryptKey, iv) => {
|
|
922
|
+
const cipher = crypto.createCipheriv('aes-128-gcm', encryptKey, iv);
|
|
923
|
+
cipher.setAAD(Buffer.alloc(0));
|
|
924
|
+
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
925
|
+
/* auth tag is cipher.getAuthTag() but not required here */
|
|
926
|
+
return encrypted;
|
|
927
|
+
};
|
|
725
928
|
|
|
726
929
|
const hmac = (data, hmacKey) => {
|
|
727
|
-
return crypto.createHmac('sha256',hmacKey).update(data, 'utf8').digest();
|
|
728
|
-
}
|
|
930
|
+
return crypto.createHmac('sha256', hmacKey).update(data, 'utf8').digest();
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const xorBuffers = (a, b) => {
|
|
934
|
+
const len = Math.min(a.length, b.length);
|
|
935
|
+
const out = Buffer.alloc(len);
|
|
936
|
+
for (let i = 0; i < len; i++) out[i] = a[i] ^ b[i];
|
|
937
|
+
return out;
|
|
938
|
+
};
|
|
729
939
|
|
|
730
940
|
const crc32LookupTable = [];
|
|
731
941
|
(() => {
|
|
@@ -742,5 +952,4 @@ const getCRC32 = buffer => {
|
|
|
742
952
|
return ~crc;
|
|
743
953
|
};
|
|
744
954
|
|
|
745
|
-
|
|
746
955
|
module.exports = TuyaAccessory;
|
package/lib/TuyaDiscovery.js
CHANGED
|
@@ -4,6 +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
|
|
8
|
+
const PREFIX_55AA = 0x000055aa; // v3.1-3.4
|
|
9
|
+
const PREFIX_6699 = 0x00006699; // v3.5
|
|
10
|
+
const SUFFIX_AA55 = 0x0000aa55;
|
|
11
|
+
const SUFFIX_9966 = 0x00009966;
|
|
12
|
+
|
|
7
13
|
class TuyaDiscovery extends EventEmitter {
|
|
8
14
|
constructor() {
|
|
9
15
|
super();
|
|
@@ -30,6 +36,8 @@ class TuyaDiscovery extends EventEmitter {
|
|
|
30
36
|
this._running = true;
|
|
31
37
|
this._start(6666);
|
|
32
38
|
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
|
|
33
41
|
|
|
34
42
|
return this;
|
|
35
43
|
}
|
|
@@ -101,42 +109,107 @@ class TuyaDiscovery extends EventEmitter {
|
|
|
101
109
|
|
|
102
110
|
_onDgramMessage(port, msg, info) {
|
|
103
111
|
const len = msg.length;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
const prefix = msg.readUInt32BE(0);
|
|
113
|
+
const suffix = msg.readUInt32BE(len - 4);
|
|
114
|
+
|
|
115
|
+
/* 3.1-3.4 packets: 0x55AA … 0xAA55
|
|
116
|
+
3.5 packets: 0x6699 … 0x9966 */
|
|
117
|
+
const isV34Frame = prefix === 0x000055aa && suffix === 0x0000aa55;
|
|
118
|
+
const isV35Frame = prefix === 0x00006699 && suffix === 0x00009966;
|
|
119
|
+
|
|
120
|
+
if (!isV34Frame && !isV35Frame) {
|
|
121
|
+
// Not a Tuya discovery frame – ignore.
|
|
110
122
|
return;
|
|
111
123
|
}
|
|
112
124
|
|
|
113
|
-
|
|
125
|
+
if (isV34Frame) {
|
|
126
|
+
// original logic v3.1-3.4 devices
|
|
127
|
+
return this._handleV34(msg, port, info);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* v3.5 handling */
|
|
131
|
+
return this._handleV35(msg, port, info);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_handleV34(pkt, port, info) {
|
|
135
|
+
const len = pkt.length;
|
|
136
|
+
const size = pkt.readUInt32BE(12);
|
|
137
|
+
|
|
114
138
|
if (len - size < 8) {
|
|
115
139
|
this.log.error(`Discovery - UDP from ${info.address}:${port} size ${len - size}`);
|
|
116
140
|
return;
|
|
117
141
|
}
|
|
118
142
|
|
|
119
|
-
|
|
120
|
-
const cleanMsg = msg.slice(len - size + 4, len - 8);
|
|
143
|
+
const cleanMsg = pkt.slice(len - size + 4, len - 8);
|
|
121
144
|
|
|
122
145
|
let decryptedMsg;
|
|
123
|
-
if (port === 6667) {
|
|
146
|
+
if (port === 6667) { // encrypted replies
|
|
124
147
|
try {
|
|
125
148
|
const decipher = crypto.createDecipheriv('aes-128-ecb', UDP_KEY, '');
|
|
126
|
-
decryptedMsg
|
|
127
|
-
decryptedMsg
|
|
128
|
-
} catch (
|
|
149
|
+
decryptedMsg = decipher.update(cleanMsg, 'utf8', 'utf8');
|
|
150
|
+
decryptedMsg += decipher.final('utf8');
|
|
151
|
+
} catch (_) { /* ignore */ }
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
if (!decryptedMsg) decryptedMsg = cleanMsg.toString('utf8');
|
|
132
155
|
|
|
133
156
|
try {
|
|
134
157
|
const result = JSON.parse(decryptedMsg);
|
|
135
|
-
if (result && result.gwId && result.ip)
|
|
136
|
-
|
|
158
|
+
if (result && result.gwId && result.ip) {
|
|
159
|
+
this._onDiscover(result);
|
|
160
|
+
} else {
|
|
161
|
+
this.log.error(`Discovery - UDP from ${info.address}:${port} decrypted`, cleanMsg.toString('hex'));
|
|
162
|
+
}
|
|
137
163
|
} catch (ex) {
|
|
138
164
|
this.log.error(`Discovery - Failed to parse discovery response on port ${port}: ${decryptedMsg}`);
|
|
139
|
-
this.log.error(`Discovery - Failed to parse discovery raw message on port ${port}: ${
|
|
165
|
+
this.log.error(`Discovery - Failed to parse discovery raw message on port ${port}: ${pkt.toString('hex')}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
/*
|
|
171
|
+
* Handle protocol-3.5 discovery replies (0x6699 … 0x9966, AES-GCM).
|
|
172
|
+
*
|
|
173
|
+
* Packet layout (offsets after the 4-byte prefix):
|
|
174
|
+
* 0-3 sequence (uint32)
|
|
175
|
+
* 4-7 command (0 for discovery)
|
|
176
|
+
* 8-11 length (uint32 – bytes that follow up to but NOT incl. suffix)
|
|
177
|
+
* 12-23 IV (12 bytes – GCM nonce)
|
|
178
|
+
* 24-(n-17) cipher (variable)
|
|
179
|
+
* n-16 tag (16-byte GCM auth tag)
|
|
180
|
+
* n-13 … n-1 0x00
|
|
181
|
+
* n n+3 suffix 0x9966
|
|
182
|
+
*/
|
|
183
|
+
_handleV35(pkt, srcPort, info) {
|
|
184
|
+
try {
|
|
185
|
+
const len = pkt.length;
|
|
186
|
+
const iv = pkt.slice(16, 28); // 12-byte nonce
|
|
187
|
+
const cipher = pkt.slice(28, len - 20); // ciphertext
|
|
188
|
+
const tag = pkt.slice(len - 20, len - 4); // 16-byte tag
|
|
189
|
+
const aad = pkt.slice(4, 28); // seq+cmd+len+IV
|
|
190
|
+
|
|
191
|
+
const decipher = crypto.createDecipheriv(
|
|
192
|
+
'aes-128-gcm',
|
|
193
|
+
GCM_DISCOVERY_KEY, // static key defined at top of file
|
|
194
|
+
iv
|
|
195
|
+
);
|
|
196
|
+
decipher.setAuthTag(tag);
|
|
197
|
+
decipher.setAAD(aad);
|
|
198
|
+
|
|
199
|
+
const jsonStr = Buffer.concat([
|
|
200
|
+
decipher.update(cipher),
|
|
201
|
+
decipher.final()
|
|
202
|
+
]).toString('utf8');
|
|
203
|
+
|
|
204
|
+
const payload = JSON.parse(jsonStr);
|
|
205
|
+
if (payload && payload.gwId && payload.ip) {
|
|
206
|
+
this._onDiscover(payload);
|
|
207
|
+
}
|
|
208
|
+
} catch (ex) {
|
|
209
|
+
this.log.error(
|
|
210
|
+
`Discovery v3.5 – failed to decrypt packet from ${info.address}:${srcPort}:`,
|
|
211
|
+
ex.message
|
|
212
|
+
);
|
|
140
213
|
}
|
|
141
214
|
}
|
|
142
215
|
|
|
@@ -146,6 +219,8 @@ class TuyaDiscovery extends EventEmitter {
|
|
|
146
219
|
data.id = data.gwId;
|
|
147
220
|
delete data.gwId;
|
|
148
221
|
|
|
222
|
+
data.version = data.version || '3.5' // Every pre-3.5 device does include version in its broadcast
|
|
223
|
+
|
|
149
224
|
this.discovered.set(data.id, data.ip);
|
|
150
225
|
|
|
151
226
|
this.emit('discover', data);
|
|
@@ -160,6 +235,27 @@ class TuyaDiscovery extends EventEmitter {
|
|
|
160
235
|
});
|
|
161
236
|
}
|
|
162
237
|
}
|
|
238
|
+
|
|
239
|
+
_sendV35Probe() {
|
|
240
|
+
const socket = dgram.createSocket('udp4');
|
|
241
|
+
const payload = Buffer.from('{"from":"app","ip":"255.255.255.255"}');
|
|
242
|
+
const iv = crypto.randomBytes(12);
|
|
243
|
+
const cipher = crypto.createCipheriv('aes-128-gcm', GCM_DISCOVERY_KEY, iv);
|
|
244
|
+
cipher.setAAD(Buffer.alloc(14)); // UUUU+seq+cmd+len = zeros OK for probe
|
|
245
|
+
const enc = Buffer.concat([cipher.update(payload), cipher.final()]);
|
|
246
|
+
const tag = cipher.getAuthTag();
|
|
247
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(iv.length + enc.length + tag.length, 0);
|
|
248
|
+
const frame = Buffer.concat([
|
|
249
|
+
Buffer.from([0,0,0,0x66,0x99].slice(0,4)), // 6699 prefix
|
|
250
|
+
Buffer.alloc(14), // UUUU + seq + cmd (0) + len placeholder
|
|
251
|
+
len,
|
|
252
|
+
iv,
|
|
253
|
+
enc,
|
|
254
|
+
tag,
|
|
255
|
+
Buffer.from('00009966','hex')
|
|
256
|
+
]);
|
|
257
|
+
socket.send(frame, 7000, '255.255.255.255', () => socket.close());
|
|
258
|
+
}
|
|
163
259
|
}
|
|
164
260
|
|
|
165
261
|
module.exports = new TuyaDiscovery();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0-beta.1",
|
|
4
4
|
"description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Includes new features, fixes, and updated device support.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|