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.
- package/.eslintrc.js +29 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
- package/.github/ISSUE_TEMPLATE/new-device.md +24 -0
- package/.github/workflows/codeql-analysis.yml +67 -0
- package/.github/workflows/eslint.yml +28 -0
- package/Changelog.md +87 -0
- package/LICENSE +21 -0
- package/Readme.MD +99 -0
- package/assets/Tuya-Plugin-Branding.png +0 -0
- package/bin/cli-decode.js +197 -0
- package/bin/cli-find.js +207 -0
- package/bin/cli.js +13 -0
- package/config-example.MD +43 -0
- package/config.schema.json +554 -0
- package/index.js +288 -0
- package/lib/AirConditionerAccessory.js +445 -0
- package/lib/AirPurifierAccessory.js +531 -0
- package/lib/BaseAccessory.js +292 -0
- package/lib/ConvectorAccessory.js +313 -0
- package/lib/CustomMultiLightAccessory.js +70 -0
- package/lib/CustomMultiOutletAccessory.js +111 -0
- package/lib/DehumidifierAccessory.js +301 -0
- package/lib/EnergyCharacteristics.js +86 -0
- package/lib/GarageDoorAccessory.js +307 -0
- package/lib/MultiLightAccessory.js +64 -0
- package/lib/MultiOutletAccessory.js +106 -0
- package/lib/OilDiffuserAccessory.js +480 -0
- package/lib/OutletAccessory.js +83 -0
- package/lib/RGBTWLightAccessory.js +234 -0
- package/lib/RGBTWOutletAccessory.js +296 -0
- package/lib/SimpleBlindsAccessory.js +298 -0
- package/lib/SimpleDimmer2Accessory.js +54 -0
- package/lib/SimpleDimmerAccessory.js +54 -0
- package/lib/SimpleFanAccessory.js +132 -0
- package/lib/SimpleFanLightAccessory.js +205 -0
- package/lib/SimpleHeaterAccessory.js +154 -0
- package/lib/SimpleLightAccessory.js +39 -0
- package/lib/SingleLightAccessory.js +45 -0
- package/lib/SwitchAccessory.js +106 -0
- package/lib/TWLightAccessory.js +91 -0
- package/lib/TuyaAccessory.js +744 -0
- package/lib/TuyaDiscovery.js +278 -0
- package/lib/ValveAccessory.js +150 -0
- 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;
|