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

codetheweb / tuyapi / 14993741621

13 May 2025 09:59AM UTC coverage: 58.014% (-0.3%) from 58.291%
14993741621

push

github

web-flow
Merge pull request #690 from codetheweb/optimizations2701

Optimize 3.5 detection and prevent crashes

208 of 364 branches covered (57.14%)

Branch coverage included in aggregate %.

10 of 23 new or added lines in 3 files covered. (43.48%)

1 existing line in 1 file now uncovered.

382 of 653 relevant lines covered (58.5%)

129.76 hits per line

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

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

4
const HEADER_SIZE = 16;
28✔
5
const HEADER_SIZE_3_5 = 4;
28✔
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 = {
28✔
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} = {}) {
238✔
78
    // Ensure the version is a string
79
    version = version.toString();
280✔
80
    this.version = version;
280✔
81

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

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

90
      this.key = key;
168✔
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) {
316✔
107
      throw new TypeError(`Packet too short. Length: ${buffer.length}.`);
14✔
108
    }
109

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

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

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

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

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

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

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

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

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

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

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

153
      // Check for payload
154
      if (buffer.length - 8 < payloadSize) {
267!
155
        throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`);
×
156
      }
157
    } else if (suffix === 0x00009966) {
×
158
      // When this suffix comes in we should have 3.5 version
NEW
159
      overwriteVersion = '3.5';
×
160

161
      // Get sequence number
162
      sequenceN = buffer.readUInt32BE(6);
×
163

164
      // Get command byte
165
      commandByte = buffer.readUInt32BE(10);
×
166

167
      // Get payload size
168
      payloadSize = buffer.readUInt32BE(14) + 14; // Add additional bytes for extras
×
169

170
      // Check for payload
171
      if (buffer.length - 8 < payloadSize) {
×
172
        throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`);
×
173
      }
174
    } else {
NEW
175
      throw new TypeError(`Suffix does not match: ${buffer.toString('hex')}`); // Should never happen
×
176
    }
177

178
    const packageFromDiscovery = (
179
      commandByte === CommandType.UDP ||
267✔
180
      commandByte === CommandType.UDP_NEW ||
181
      commandByte === CommandType.BOARDCAST_LPV34
182
    );
183

184
    // Get the return code, 0 = success
185
    // This field is only present in messages from the devices
186
    // Absent in messages sent to device
187
    const returnCode = buffer.readUInt32BE(16);
267✔
188

189
    // Get the payload
190
    // Adjust for messages lacking a return code
191
    let payload;
192
    if (overwriteVersion === '3.5' || this.version === '3.5') {
267!
193
      payload = buffer.slice(HEADER_SIZE_3_5, HEADER_SIZE_3_5 + payloadSize);
×
194
      sequenceN = buffer.slice(6, 10).readUInt32BE();
×
195
      commandByte = buffer.slice(10, 14).readUInt32BE();
×
196
    } else {
197
      if (returnCode & 0xFFFFFF00) {
267!
198
        if (this.version === '3.4' && !packageFromDiscovery) {
267!
199
          payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 0x24);
×
200
        } else if (this.version === '3.5' && !packageFromDiscovery) {
267!
201
          payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 0x24);
×
202
        } else {
203
          payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 8);
267✔
204
        }
205
      } else if (this.version === '3.4' && !packageFromDiscovery) {
×
206
        payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 0x24);
×
207
      } else if (this.version === '3.5' && !packageFromDiscovery) {
×
208
        payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 0x24);
×
209
      } else {
210
        payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 8);
×
211
      }
212

213
      // Check CRC
214
      if (this.version === '3.4' && !packageFromDiscovery) {
267!
215
        const expectedCrc = buffer.slice(HEADER_SIZE + payloadSize - 0x24, buffer.length - 4).toString('hex');
×
216
        const computedCrc = this.cipher.hmac(buffer.slice(0, HEADER_SIZE + payloadSize - 0x24)).toString('hex');
×
217

218
        if (expectedCrc !== computedCrc) {
×
219
          throw new Error(`HMAC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`);
×
220
        }
221
      } else if (this.version !== '3.5') {
267!
222
        const expectedCrc = buffer.readInt32BE(HEADER_SIZE + payloadSize - 8);
267✔
223
        const computedCrc = crc(buffer.slice(0, payloadSize + 8));
267✔
224

225
        if (expectedCrc !== computedCrc) {
267✔
226
          throw new Error(`CRC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`);
7✔
227
        }
228
      }
229
    }
230

231
    return {payload, leftover, commandByte, sequenceN, version: overwriteVersion || this.version};
260✔
232
  }
233

234
  /**
235
   * Attempts to decode a given payload into
236
   * an object or string.
237
   * @param {Buffer} data to decode
238
   * @param {String} version of protocol
239
   * @returns {Object|String}
240
   * object if payload is JSON, otherwise string
241
   */
242
  getPayload(data, version) {
243
    if (data.length === 0) {
260✔
244
      return false;
14✔
245
    }
246

247
    // Try to decrypt data first.
248
    try {
246✔
249
      if (!this.cipher) {
246✔
250
        throw new Error('Missing key or version in constructor.');
49✔
251
      }
252

253
      data = this.cipher.decrypt(data, version);
197✔
254
    } catch (_) {
255
      data = data.toString('utf8');
232✔
256
    }
257

258
    // Incoming 3.5 data isn't 0 because of iv and tag so check size after
259
    if (version === '3.5') {
246!
260
      if (data.length === 0) {
×
261
        return false;
×
262
      }
263
    }
264

265
    // Try to parse data as JSON.
266
    // If error, return as string.
267
    if (typeof data === 'string') {
246✔
268
      try {
232✔
269
        data = JSON.parse(data);
232✔
270
      } catch (_) { }
271
    }
272

273
    return data;
246✔
274
  }
275

276
  /**
277
   * Recursive function to parse
278
   * a series of packets. Perfer using
279
   * the parse() wrapper over using this
280
   * directly.
281
   * @private
282
   * @param {Buffer} buffer to parse
283
   * @param {Array} packets that have been parsed
284
   * @returns {Array.<Packet>} array of parsed packets
285
   */
286
  parseRecursive(buffer, packets) {
287
    const result = this.parsePacket(buffer);
316✔
288

289
    result.payload = this.getPayload(result.payload, result.version);
260✔
290

291
    packets.push(result);
260✔
292

293
    if (result.leftover) {
260✔
294
      return this.parseRecursive(result.leftover, packets);
84✔
295
    }
296

297
    return packets;
176✔
298
  }
299

300
  /**
301
   * Given a buffer potentially containing
302
   * multiple packets, this parses and returns
303
   * all of them.
304
   * @param {Buffer} buffer to parse
305
   * @returns {Array.<Packet>} parsed packets
306
   */
307
  parse(buffer) {
308
    return this.parseRecursive(buffer, []);
232✔
309
  }
310

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

327
    // Convert Objects to Strings, Strings to Buffers
328
    if (!(options.data instanceof Buffer)) {
266✔
329
      if (typeof options.data !== 'string') {
231✔
330
        options.data = JSON.stringify(options.data);
210✔
331
      }
332

333
      options.data = Buffer.from(options.data);
231✔
334
    }
335

336
    if (this.version === '3.4') {
266!
337
      return this._encode34(options);
×
338
    }
339

340
    if (this.version === '3.5') {
266!
341
      return this._encode35(options);
×
342
    }
343

344
    return this._encodePre34(options);
266✔
345
  }
346

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

361
    // Protocol 3.3 and 3.2 is always encrypted
362
    if (this.version === '3.3' || this.version === '3.2') {
266✔
363
      // Encrypt data
364
      payload = this.cipher.encrypt({
14✔
365
        data: payload,
366
        base64: false
367
      });
368

369
      // Check if we need an extended header, only for certain CommandTypes
370
      if (options.commandByte !== CommandType.DP_QUERY &&
14✔
371
          options.commandByte !== CommandType.DP_REFRESH) {
372
        // Add 3.3 header
373
        const buffer = Buffer.alloc(payload.length + 15);
7✔
374
        Buffer.from('3.3').copy(buffer, 0);
7✔
375
        payload.copy(buffer, 15);
7✔
376
        payload = buffer;
7✔
377
      }
378
    } else if (options.encrypted) {
252✔
379
      // Protocol 3.1 and below, only encrypt data if necessary
380
      payload = this.cipher.encrypt({
21✔
381
        data: payload
382
      });
383

384
      // Create MD5 signature
385
      const md5 = this.cipher.md5('data=' + payload +
21✔
386
          '||lpv=' + this.version +
387
          '||' + this.key);
388

389
      // Create byte buffer from hex data
390
      payload = Buffer.from(this.version + md5 + payload);
21✔
391
    }
392

393
    // Allocate buffer with room for payload + 24 bytes for
394
    // prefix, sequence, command, length, crc, and suffix
395
    const buffer = Buffer.alloc(payload.length + 24);
266✔
396

397
    // Add prefix, command, and length
398
    buffer.writeUInt32BE(0x000055AA, 0);
266✔
399
    buffer.writeUInt32BE(options.commandByte, 8);
266✔
400
    buffer.writeUInt32BE(payload.length + 8, 12);
266✔
401

402
    if (options.sequenceN) {
266✔
403
      buffer.writeUInt32BE(options.sequenceN, 4);
168✔
404
    }
405

406
    // Add payload, crc, and suffix
407
    payload.copy(buffer, 16);
266✔
408
    const calculatedCrc = crc(buffer.slice(0, payload.length + 16)) & 0xFFFFFFFF;
266✔
409

410
    buffer.writeInt32BE(calculatedCrc, payload.length + 16);
266✔
411
    buffer.writeUInt32BE(0x0000AA55, payload.length + 20);
266✔
412

413
    return buffer;
266✔
414
  }
415

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

429
    if (options.commandByte !== CommandType.DP_QUERY &&
×
430
        options.commandByte !== CommandType.HEART_BEAT &&
431
        options.commandByte !== CommandType.DP_QUERY_NEW &&
432
        options.commandByte !== CommandType.SESS_KEY_NEG_START &&
433
        options.commandByte !== CommandType.SESS_KEY_NEG_FINISH &&
434
        options.commandByte !== CommandType.DP_REFRESH) {
435
      // Add 3.4 header
436
      // check this: mqc_very_pcmcd_mcd(int a1, unsigned int a2)
437
      const buffer = Buffer.alloc(payload.length + 15);
×
438
      Buffer.from('3.4').copy(buffer, 0);
×
439
      payload.copy(buffer, 15);
×
440
      payload = buffer;
×
441
    }
442

443
    // ? if (payload.length > 0) { // is null messages need padding - PING work without
444
    const padding = 0x10 - (payload.length & 0xF);
×
445
    const buf34 = Buffer.alloc((payload.length + padding), padding);
×
446
    payload.copy(buf34);
×
447
    payload = buf34;
×
448
    // }
449

450
    payload = this.cipher.encrypt({
×
451
      data: payload
452
    });
453

454
    payload = Buffer.from(payload);
×
455

456
    // Allocate buffer with room for payload + 24 bytes for
457
    // prefix, sequence, command, length, crc, and suffix
458
    const buffer = Buffer.alloc(payload.length + 52);
×
459

460
    // Add prefix, command, and length
461
    buffer.writeUInt32BE(0x000055AA, 0);
×
462
    buffer.writeUInt32BE(options.commandByte, 8);
×
463
    buffer.writeUInt32BE(payload.length + 0x24, 12);
×
464

465
    if (options.sequenceN) {
×
466
      buffer.writeUInt32BE(options.sequenceN, 4);
×
467
    }
468

469
    // Add payload, crc, and suffix
470
    payload.copy(buffer, 16);
×
471
    const calculatedCrc = this.cipher.hmac(buffer.slice(0, payload.length + 16));// & 0xFFFFFFFF;
×
472
    calculatedCrc.copy(buffer, payload.length + 16);
×
473

474
    buffer.writeUInt32BE(0x0000AA55, payload.length + 48);
×
475
    return buffer;
×
476
  }
477

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

491
    if (options.commandByte !== CommandType.DP_QUERY &&
×
492
        options.commandByte !== CommandType.HEART_BEAT &&
493
        options.commandByte !== CommandType.DP_QUERY_NEW &&
494
        options.commandByte !== CommandType.SESS_KEY_NEG_START &&
495
        options.commandByte !== CommandType.SESS_KEY_NEG_FINISH &&
496
        options.commandByte !== CommandType.DP_REFRESH) {
497
      // Add 3.5 header
498
      const buffer = Buffer.alloc(payload.length + 15);
×
499
      Buffer.from('3.5').copy(buffer, 0);
×
500
      payload.copy(buffer, 15);
×
501
      payload = buffer;
×
502
      // OO options.data = '3.5\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + options.data;
503
    }
504

505
    // Allocate buffer for prefix, unknown, sequence, command, length
506
    let buffer = Buffer.alloc(18);
×
507

508
    // Add prefix, command, and length
509
    buffer.writeUInt32BE(0x00006699, 0); // Prefix
×
510
    buffer.writeUInt16BE(0x0, 4); // Unknown
×
511
    buffer.writeUInt32BE(options.sequenceN, 6); // Sequence
×
512
    buffer.writeUInt32BE(options.commandByte, 10); // Command
×
513
    buffer.writeUInt32BE(payload.length + 28 /* 0x1c */, 14); // Length
×
514

515
    const encrypted = this.cipher.encrypt({
×
516
      data: payload,
517
      aad: buffer.slice(4, 18)
518
    });
519

520
    buffer = Buffer.concat([buffer, encrypted]);
×
521

522
    return buffer;
×
523
  }
524
}
525

526
module.exports = {MessageParser, CommandType};
28✔
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