• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codetheweb / tuyapi / 10869511015

15 Sep 2024 08:29AM UTC coverage: 58.841% (-0.03%) from 58.866%
10869511015

push

github

web-flow
Merge pull request #657 from Apollon77/35upd

Updates to make testing happy

200 of 352 branches covered (56.82%)

Branch coverage included in aggregate %.

21 of 55 new or added lines in 3 files covered. (38.18%)

8 existing lines in 3 files now uncovered.

379 of 632 relevant lines covered (59.97%)

114.3 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

58.39
/lib/message-parser.js
1
const Cipher = require('./cipher');
24✔
2
const crc = require('./crc');
24✔
3

4
const HEADER_SIZE = 16;
24✔
5
const HEADER_SIZE_3_5 = 4;
24✔
6

7
/**
8
 * Human-readable definitions
9
 * of command bytes.
10
 * See also https://github.com/tuya/tuya-iotos-embeded-sdk-wifi-ble-bk7231n/blob/master/sdk/include/lan_protocol.h
11
 * @readonly
12
 * @private
13
 */
14
const CommandType = {
24✔
15
  UDP: 0,
16
  AP_CONFIG: 1,
17
  ACTIVE: 2,
18
  BIND: 3, // ?? Leave in for backward compatibility
19
  SESS_KEY_NEG_START: 3, // Negotiate session key
20
  RENAME_GW: 4, // ?? Leave in for backward compatibility
21
  SESS_KEY_NEG_RES: 4, // Negotiate session key response
22
  RENAME_DEVICE: 5, // ?? Leave in for backward compatibility
23
  SESS_KEY_NEG_FINISH: 5, // Finalize session key negotiation
24
  UNBIND: 6,
25
  CONTROL: 7,
26
  STATUS: 8,
27
  HEART_BEAT: 9,
28
  DP_QUERY: 10,
29
  QUERY_WIFI: 11,
30
  TOKEN_BIND: 12,
31
  CONTROL_NEW: 13,
32
  ENABLE_WIFI: 14,
33
  DP_QUERY_NEW: 16,
34
  SCENE_EXECUTE: 17,
35
  DP_REFRESH: 18, // Request refresh of DPS  UPDATEDPS / LAN_QUERY_DP
36
  UDP_NEW: 19,
37
  AP_CONFIG_NEW: 20,
38
  BOARDCAST_LPV34: 35,
39
  LAN_EXT_STREAM: 40,
40
  LAN_GW_ACTIVE: 240,
41
  LAN_SUB_DEV_REQUEST: 241,
42
  LAN_DELETE_SUB_DEV: 242,
43
  LAN_REPORT_SUB_DEV: 243,
44
  LAN_SCENE: 244,
45
  LAN_PUBLISH_CLOUD_CONFIG: 245,
46
  LAN_PUBLISH_APP_CONFIG: 246,
47
  LAN_EXPORT_APP_CONFIG: 247,
48
  LAN_PUBLISH_SCENE_PANEL: 248,
49
  LAN_REMOVE_GW: 249,
50
  LAN_CHECK_GW_UPDATE: 250,
51
  LAN_GW_UPDATE: 251,
52
  LAN_SET_GW_CHANNEL: 252
53
};
54

55
/**
56
 * A complete packet.
57
 * @typedef {Object} Packet
58
 * @property {Buffer|Object|String} payload
59
 * Buffer if hasn't been decoded, object or
60
 * string if it has been
61
 * @property {Buffer} leftover
62
 * bytes adjacent to the parsed packet
63
 * @property {Number} commandByte
64
 * @property {Number} sequenceN
65
 */
66

67
/**
68
 * Low-level class for parsing packets.
69
 * @class
70
 * @param {Object} options Options
71
 * @param {String} options.key localKey of cipher
72
 * @param {Number} [options.version=3.1] protocol version
73
 * @example
74
 * const parser = new MessageParser({key: 'xxxxxxxxxxxxxxxx', version: 3.1})
75
 */
76
class MessageParser {
77
  constructor({key, version = 3.1} = {}) {
204✔
78
    // Ensure the version is a string
79
    version = version.toString();
240✔
80
    this.version = version;
240✔
81

82
    if (key) {
240✔
83
      if (key.length !== 16) {
150✔
84
        throw new TypeError('Incorrect key format');
6✔
85
      }
86

87
      // Create a Cipher if we have a valid key
88
      this.cipher = new Cipher({key, version});
144✔
89

90
      this.key = key;
144✔
91
    }
92
  }
93

94
  /**
95
   * Parses a Buffer of data containing at least
96
   * one complete packet at the beginning of the buffer.
97
   * Will return multiple packets if necessary.
98
   * @param {Buffer} buffer of data to parse
99
   * @returns {Packet} packet of data
100
   */
101
  parsePacket(buffer) {
102
    // Check for length
103
    // At minimum requires: prefix (4), sequence (4), command (4), length (4),
104
    // CRC (4), and suffix (4) for 24 total bytes
105
    // Messages from the device also include return code (4), for 28 total bytes
106
    if (buffer.length < 24) {
270✔
107
      throw new TypeError(`Packet too short. Length: ${buffer.length}.`);
12✔
108
    }
109

110
    // Check for prefix
111
    const prefix = buffer.readUInt32BE(0);
258✔
112

113
    // Only for 3.4 and 3.5 packets
114
    if (prefix !== 0x000055AA && prefix !== 0x00006699) {
258✔
115
      throw new TypeError(`Prefix does not match: ${buffer.toString('hex')}`);
12✔
116
    }
117

118
    // Check for extra data
119
    let leftover = false;
246✔
120

121
    let suffixLocation = buffer.indexOf('0000AA55', 0, 'hex');
246✔
122
    if (suffixLocation === -1) {// Couldn't find 0000AA55 during parse
246✔
123
      suffixLocation = buffer.indexOf('00009966', 0, 'hex');
18✔
124
    }
125

126
    if (suffixLocation !== buffer.length - 4) {
246✔
127
      leftover = buffer.slice(suffixLocation + 4);
90✔
128
      buffer = buffer.slice(0, suffixLocation + 4);
90✔
129
    }
130

131
    // Check for suffix
132
    const suffix = buffer.readUInt32BE(buffer.length - 4);
246✔
133

134
    if (suffix !== 0x0000AA55 && suffix !== 0x00009966) {
228!
135
      throw new TypeError(`Suffix does not match: ${buffer.toString('hex')}`);
×
136
    }
137

138
    let sequenceN;
139
    let commandByte;
140
    let payloadSize;
141

142
    if (suffix === 0x0000AA55) {
228!
143
      // Get sequence number
144
      sequenceN = buffer.readUInt32BE(4);
228✔
145

146
      // Get command byte
147
      commandByte = buffer.readUInt32BE(8);
228✔
148

149
      // Get payload size
150
      payloadSize = buffer.readUInt32BE(12);
228✔
151

152
      // Check for payload
153
      if (buffer.length - 8 < payloadSize) {
228!
154
        throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`);
×
155
      }
NEW
156
    } else if (suffix === 0x00009966) {
×
157
      // Get sequence number
158
      sequenceN = buffer.readUInt32BE(6);
×
159

160
      // Get command byte
161
      commandByte = buffer.readUInt32BE(10);
×
162

163
      // Get payload size
164
      payloadSize = buffer.readUInt32BE(14) + 14; // Add additional bytes for extras
×
165

166
      // Check for payload
167
      if (buffer.length - 8 < payloadSize) {
×
168
        throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`);
×
169
      }
170
    }
171

172
    const packageFromDiscovery = (
173
      commandByte === CommandType.UDP ||
228✔
174
      commandByte === CommandType.UDP_NEW ||
175
      commandByte === CommandType.BOARDCAST_LPV34
176
    );
177

178
    // Get the return code, 0 = success
179
    // This field is only present in messages from the devices
180
    // Absent in messages sent to device
181
    const returnCode = buffer.readUInt32BE(16);
228✔
182

183
    // Get the payload
184
    // Adjust for messages lacking a return code
185
    let payload;
186
    if (this.version === '3.5') {
228!
NEW
187
      payload = buffer.slice(HEADER_SIZE_3_5, HEADER_SIZE_3_5 + payloadSize);
×
NEW
188
      sequenceN = buffer.slice(6, 10).readUInt32BE();
×
NEW
189
      commandByte = buffer.slice(10, 14).readUInt32BE();
×
190
    } else {
191
      if (returnCode & 0xFFFFFF00) {
228!
192
        if (this.version === '3.4' && !packageFromDiscovery) {
228!
193
          payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 0x24);
×
194
        } else if (this.version === '3.5' && !packageFromDiscovery) {
228!
UNCOV
195
          payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 0x24);
×
196
        } else {
197
          payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 8);
228✔
198
        }
199
      } else if (this.version === '3.4' && !packageFromDiscovery) {
×
200
        payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 0x24);
×
201
      } else if (this.version === '3.5' && !packageFromDiscovery) {
×
202
        payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 0x24);
×
203
      } else {
204
        payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 8);
×
205
      }
206

207
      // Check CRC
208
      if (this.version === '3.4' && !packageFromDiscovery) {
228!
209
        const expectedCrc = buffer.slice(HEADER_SIZE + payloadSize - 0x24, buffer.length - 4).toString('hex');
×
210
        const computedCrc = this.cipher.hmac(buffer.slice(0, HEADER_SIZE + payloadSize - 0x24)).toString('hex');
×
211

212
        if (expectedCrc !== computedCrc) {
×
213
          throw new Error(`HMAC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`);
×
214
        }
215
      } else if (this.version !== '3.5') {
228!
216
        const expectedCrc = buffer.readInt32BE(HEADER_SIZE + payloadSize - 8);
228✔
217
        const computedCrc = crc(buffer.slice(0, payloadSize + 8));
228✔
218

219
        if (expectedCrc !== computedCrc) {
228✔
220
          throw new Error(`CRC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`);
6✔
221
        }
222
      }
223
    }
224

225
    return {payload, leftover, commandByte, sequenceN};
222✔
226
  }
227

228
  /**
229
   * Attempts to decode a given payload into
230
   * an object or string.
231
   * @param {Buffer} data to decode
232
   * @returns {Object|String}
233
   * object if payload is JSON, otherwise string
234
   */
235
  getPayload(data) {
236
    if (data.length === 0) {
222✔
237
      return false;
12✔
238
    }
239

240
    // Try to decrypt data first.
241
    try {
210✔
242
      if (!this.cipher) {
210✔
243
        throw new Error('Missing key or version in constructor.');
42✔
244
      }
245

246
      data = this.cipher.decrypt(data);
168✔
247
    } catch (_) {
248
      data = data.toString('utf8');
198✔
249
    }
250

251
    // Incoming 3.5 data isn't 0 because of iv and tag so check size after
252
    if (this.version === '3.5') {
210!
NEW
253
      if (data.length === 0) {
×
UNCOV
254
        return false;
×
255
      }
256
    }
257

258
    // Try to parse data as JSON.
259
    // If error, return as string.
260
    if (typeof data === 'string') {
210✔
261
      try {
198✔
262
        data = JSON.parse(data);
198✔
263
      } catch (_) { }
264
    }
265

266
    return data;
210✔
267
  }
268

269
  /**
270
   * Recursive function to parse
271
   * a series of packets. Perfer using
272
   * the parse() wrapper over using this
273
   * directly.
274
   * @private
275
   * @param {Buffer} buffer to parse
276
   * @param {Array} packets that have been parsed
277
   * @returns {Array.<Packet>} array of parsed packets
278
   */
279
  parseRecursive(buffer, packets) {
280
    const result = this.parsePacket(buffer);
270✔
281

282
    result.payload = this.getPayload(result.payload);
222✔
283

284
    packets.push(result);
222✔
285

286
    if (result.leftover) {
222✔
287
      return this.parseRecursive(result.leftover, packets);
72✔
288
    }
289

290
    return packets;
150✔
291
  }
292

293
  /**
294
   * Given a buffer potentially containing
295
   * multiple packets, this parses and returns
296
   * all of them.
297
   * @param {Buffer} buffer to parse
298
   * @returns {Array.<Packet>} parsed packets
299
   */
300
  parse(buffer) {
301
    return this.parseRecursive(buffer, []);
198✔
302
  }
303

304
  /**
305
   * Encodes a payload into a Tuya-protocol-compliant packet.
306
   * @param {Object} options Options for encoding
307
   * @param {Buffer|String|Object} options.data data to encode
308
   * @param {Boolean} options.encrypted whether or not to encrypt the data
309
   * @param {Number} options.commandByte
310
   * command byte of packet (use CommandType definitions)
311
   * @param {Number} [options.sequenceN] optional, sequence number
312
   * @returns {Buffer} Encoded Buffer
313
   */
314
  encode(options) {
315
    // Check command byte
316
    if (!Object.values(CommandType).includes(options.commandByte)) {
234✔
317
      throw new TypeError('Command byte not defined.');
6✔
318
    }
319

320
    // Convert Objects to Strings, Strings to Buffers
321
    if (!(options.data instanceof Buffer)) {
228✔
322
      if (typeof options.data !== 'string') {
198✔
323
        options.data = JSON.stringify(options.data);
180✔
324
      }
325

326
      options.data = Buffer.from(options.data);
198✔
327
    }
328

329
    if (this.version === '3.4') {
228!
330
      return this._encode34(options);
×
331
    }
332

333
    if (this.version === '3.5') {
228!
334
      return this._encode35(options);
×
335
    }
336

337
    return this._encodePre34(options);
228✔
338
  }
339

340
  /**
341
   * Encodes a payload into a Tuya-protocol-compliant packet for protocol version 3.3 and below.
342
   * @param {Object} options Options for encoding
343
   * @param {Buffer|String|Object} options.data data to encode
344
   * @param {Boolean} options.encrypted whether or not to encrypt the data
345
   * @param {Number} options.commandByte
346
   * command byte of packet (use CommandType definitions)
347
   * @param {Number} [options.sequenceN] optional, sequence number
348
   * @returns {Buffer} Encoded Buffer
349
   */
350
  _encodePre34(options) {
351
    // Construct payload
352
    let payload = options.data;
228✔
353

354
    // Protocol 3.3 and 3.2 is always encrypted
355
    if (this.version === '3.3' || this.version === '3.2') {
228✔
356
      // Encrypt data
357
      payload = this.cipher.encrypt({
12✔
358
        data: payload,
359
        base64: false
360
      });
361

362
      // Check if we need an extended header, only for certain CommandTypes
363
      if (options.commandByte !== CommandType.DP_QUERY &&
12✔
364
          options.commandByte !== CommandType.DP_REFRESH) {
365
        // Add 3.3 header
366
        const buffer = Buffer.alloc(payload.length + 15);
6✔
367
        Buffer.from('3.3').copy(buffer, 0);
6✔
368
        payload.copy(buffer, 15);
6✔
369
        payload = buffer;
6✔
370
      }
371
    } else if (options.encrypted) {
216✔
372
      // Protocol 3.1 and below, only encrypt data if necessary
373
      payload = this.cipher.encrypt({
18✔
374
        data: payload
375
      });
376

377
      // Create MD5 signature
378
      const md5 = this.cipher.md5('data=' + payload +
18✔
379
          '||lpv=' + this.version +
380
          '||' + this.key);
381

382
      // Create byte buffer from hex data
383
      payload = Buffer.from(this.version + md5 + payload);
18✔
384
    }
385

386
    // Allocate buffer with room for payload + 24 bytes for
387
    // prefix, sequence, command, length, crc, and suffix
388
    const buffer = Buffer.alloc(payload.length + 24);
228✔
389

390
    // Add prefix, command, and length
391
    buffer.writeUInt32BE(0x000055AA, 0);
228✔
392
    buffer.writeUInt32BE(options.commandByte, 8);
228✔
393
    buffer.writeUInt32BE(payload.length + 8, 12);
228✔
394

395
    if (options.sequenceN) {
228✔
396
      buffer.writeUInt32BE(options.sequenceN, 4);
144✔
397
    }
398

399
    // Add payload, crc, and suffix
400
    payload.copy(buffer, 16);
228✔
401
    const calculatedCrc = crc(buffer.slice(0, payload.length + 16)) & 0xFFFFFFFF;
228✔
402

403
    buffer.writeInt32BE(calculatedCrc, payload.length + 16);
228✔
404
    buffer.writeUInt32BE(0x0000AA55, payload.length + 20);
228✔
405

406
    return buffer;
228✔
407
  }
408

409
  /**
410
   * Encodes a payload into a Tuya-protocol-complient packet for protocol version 3.4
411
   * @param {Object} options Options for encoding
412
   * @param {Buffer|String|Object} options.data data to encode
413
   * @param {Boolean} options.encrypted whether or not to encrypt the data
414
   * @param {Number} options.commandByte
415
   * command byte of packet (use CommandType definitions)
416
   * @param {Number} [options.sequenceN] optional, sequence number
417
   * @returns {Buffer} Encoded Buffer
418
   */
419
  _encode34(options) {
420
    let payload = options.data;
×
421

422
    if (options.commandByte !== CommandType.DP_QUERY &&
×
423
        options.commandByte !== CommandType.HEART_BEAT &&
424
        options.commandByte !== CommandType.DP_QUERY_NEW &&
425
        options.commandByte !== CommandType.SESS_KEY_NEG_START &&
426
        options.commandByte !== CommandType.SESS_KEY_NEG_FINISH &&
427
        options.commandByte !== CommandType.DP_REFRESH) {
428
      // Add 3.4 header
429
      // check this: mqc_very_pcmcd_mcd(int a1, unsigned int a2)
430
      const buffer = Buffer.alloc(payload.length + 15);
×
431
      Buffer.from('3.4').copy(buffer, 0);
×
432
      payload.copy(buffer, 15);
×
433
      payload = buffer;
×
434
    }
435

436
    // ? if (payload.length > 0) { // is null messages need padding - PING work without
437
    const padding = 0x10 - (payload.length & 0xF);
×
438
    const buf34 = Buffer.alloc((payload.length + padding), padding);
×
439
    payload.copy(buf34);
×
440
    payload = buf34;
×
441
    // }
442

443
    payload = this.cipher.encrypt({
×
444
      data: payload
445
    });
446

447
    payload = Buffer.from(payload);
×
448

449
    // Allocate buffer with room for payload + 24 bytes for
450
    // prefix, sequence, command, length, crc, and suffix
451
    const buffer = Buffer.alloc(payload.length + 52);
×
452

453
    // Add prefix, command, and length
454
    buffer.writeUInt32BE(0x000055AA, 0);
×
455
    buffer.writeUInt32BE(options.commandByte, 8);
×
456
    buffer.writeUInt32BE(payload.length + 0x24, 12);
×
457

458
    if (options.sequenceN) {
×
459
      buffer.writeUInt32BE(options.sequenceN, 4);
×
460
    }
461

462
    // Add payload, crc, and suffix
463
    payload.copy(buffer, 16);
×
464
    const calculatedCrc = this.cipher.hmac(buffer.slice(0, payload.length + 16));// & 0xFFFFFFFF;
×
465
    calculatedCrc.copy(buffer, payload.length + 16);
×
466

467
    buffer.writeUInt32BE(0x0000AA55, payload.length + 48);
×
468
    return buffer;
×
469
  }
470

471
  /**
472
   * Encodes a payload into a Tuya-protocol-complient packet for protocol version 3.5
473
   * @param {Object} options Options for encoding
474
   * @param {Buffer|String|Object} options.data data to encode
475
   * @param {Boolean} options.encrypted whether or not to encrypt the data
476
   * @param {Number} options.commandByte
477
   * command byte of packet (use CommandType definitions)
478
   * @param {Number} [options.sequenceN] optional, sequence number
479
   * @returns {Buffer} Encoded Buffer
480
   */
481
  _encode35(options) {
482
    let payload = options.data;
×
483

484
    if (options.commandByte !== CommandType.DP_QUERY &&
×
485
        options.commandByte !== CommandType.HEART_BEAT &&
486
        options.commandByte !== CommandType.DP_QUERY_NEW &&
487
        options.commandByte !== CommandType.SESS_KEY_NEG_START &&
488
        options.commandByte !== CommandType.SESS_KEY_NEG_FINISH &&
489
        options.commandByte !== CommandType.DP_REFRESH) {
490
      // Add 3.5 header
491
      const buffer = Buffer.alloc(payload.length + 15);
×
492
      Buffer.from('3.5').copy(buffer, 0);
×
493
      payload.copy(buffer, 15);
×
494
      payload = buffer;
×
495
      // OO options.data = '3.5\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + options.data;
496
    }
497

498
    // Allocate buffer for prefix, unknown, sequence, command, length
499
    let buffer = Buffer.alloc(18);
×
500

501
    // Add prefix, command, and length
502
    buffer.writeUInt32BE(0x00006699, 0); // Prefix
×
503
    buffer.writeUInt16BE(0x0, 4); // Unknown
×
504
    buffer.writeUInt32BE(options.sequenceN, 6); // Sequence
×
505
    buffer.writeUInt32BE(options.commandByte, 10); // Command
×
NEW
506
    buffer.writeUInt32BE(payload.length + 28 /* 0x1c */, 14); // Length
×
507

508
    const encrypted = this.cipher.encrypt({
×
509
      data: payload,
510
      aad: buffer.slice(4, 18)
511
    });
512

513
    buffer = Buffer.concat([buffer, encrypted]);
×
514

515
    return buffer;
×
516
  }
517
}
518

519
module.exports = {MessageParser, CommandType};
24✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc