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

codetheweb / tuyapi / 4507622044

pending completion
4507622044

push

github

GitHub
add special handling for SET-as-GET requests that return DP_QUERY (#616)

174 of 276 branches covered (63.04%)

Branch coverage included in aggregate %.

16 of 46 new or added lines in 1 file covered. (34.78%)

2 existing lines in 1 file now uncovered.

364 of 545 relevant lines covered (66.79%)

86.66 hits per line

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

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

4
const HEADER_SIZE = 16;
16✔
5

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

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

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

81
    if (key) {
160✔
82
      if (key.length !== 16) {
100✔
83
        throw new TypeError('Incorrect key format');
4✔
84
      }
85

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

89
      this.key = key;
96✔
90
    }
91
  }
92

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

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

112
    if (prefix !== 0x000055AA) {
173✔
113
      throw new TypeError(`Prefix does not match: ${buffer.toString('hex')}`);
8✔
114
    }
115

116
    // Check for extra data
117
    let leftover = false;
165✔
118

119
    const suffixLocation = buffer.indexOf('0000AA55', 0, 'hex');
165✔
120

121
    if (suffixLocation !== buffer.length - 4) {
165✔
122
      leftover = buffer.slice(suffixLocation + 4);
60✔
123
      buffer = buffer.slice(0, suffixLocation + 4);
60✔
124
    }
125

126
    // Check for suffix
127
    const suffix = buffer.readUInt32BE(buffer.length - 4);
165✔
128

129
    if (suffix !== 0x0000AA55) {
153!
130
      throw new TypeError(`Suffix does not match: ${buffer.toString('hex')}`);
×
131
    }
132

133
    // Get sequence number
134
    const sequenceN = buffer.readUInt32BE(4);
153✔
135

136
    // Get command byte
137
    const commandByte = buffer.readUInt32BE(8);
153✔
138

139
    // Get payload size
140
    const payloadSize = buffer.readUInt32BE(12);
153✔
141

142
    // Check for payload
143
    if (buffer.length - 8 < payloadSize) {
153!
144
      throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`);
×
145
    }
146

147
    const packageFromDiscovery = (
148
      commandByte === CommandType.UDP ||
153✔
149
      commandByte === CommandType.UDP_NEW ||
150
      commandByte === CommandType.BOARDCAST_LPV34
151
    );
152

153
    // Get the return code, 0 = success
154
    // This field is only present in messages from the devices
155
    // Absent in messages sent to device
156
    const returnCode = buffer.readUInt32BE(16);
153✔
157

158
    // Get the payload
159
    // Adjust for messages lacking a return code
160
    let payload;
161
    if (returnCode & 0xFFFFFF00) {
153!
162
      if (this.version === '3.4' && !packageFromDiscovery) {
153!
163
        payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 0x24);
×
164
      } else {
165
        payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 8);
153✔
166
      }
167
    } else if (this.version === '3.4' && !packageFromDiscovery) {
×
168
      payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 0x24);
×
169
    } else {
170
      payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 8);
×
171
    }
172

173
    // Check CRC
174
    if (this.version === '3.4' && !packageFromDiscovery) {
153!
175
      const expectedCrc = buffer.slice(HEADER_SIZE + payloadSize - 0x24, buffer.length - 4).toString('hex');
×
176
      const computedCrc = this.cipher.hmac(buffer.slice(0, HEADER_SIZE + payloadSize - 0x24)).toString('hex');
×
177

178
      if (expectedCrc !== computedCrc) {
×
179
        throw new Error(`HMAC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`);
×
180
      }
181
    } else {
182
      const expectedCrc = buffer.readInt32BE(HEADER_SIZE + payloadSize - 8);
153✔
183
      const computedCrc = crc(buffer.slice(0, payloadSize + 8));
153✔
184

185
      if (expectedCrc !== computedCrc) {
153✔
186
        throw new Error(`CRC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`);
4✔
187
      }
188
    }
189

190
    return {payload, leftover, commandByte, sequenceN};
149✔
191
  }
192

193
  /**
194
   * Attempts to decode a given payload into
195
   * an object or string.
196
   * @param {Buffer} data to decode
197
   * @returns {Object|String}
198
   * object if payload is JSON, otherwise string
199
   */
200
  getPayload(data) {
201
    if (data.length === 0) {
149✔
202
      return false;
8✔
203
    }
204

205
    // Try to decrypt data first.
206
    try {
141✔
207
      if (!this.cipher) {
141✔
208
        throw new Error('Missing key or version in constructor.');
28✔
209
      }
210

211
      data = this.cipher.decrypt(data);
113✔
212
    } catch (_) {
213
      data = data.toString('utf8');
133✔
214
    }
215

216
    // Try to parse data as JSON.
217
    // If error, return as string.
218
    if (typeof data === 'string') {
141✔
219
      try {
133✔
220
        data = JSON.parse(data);
133✔
221
      } catch (_) { }
222
    }
223

224
    return data;
141✔
225
  }
226

227
  /**
228
   * Recursive function to parse
229
   * a series of packets. Perfer using
230
   * the parse() wrapper over using this
231
   * directly.
232
   * @private
233
   * @param {Buffer} buffer to parse
234
   * @param {Array} packets that have been parsed
235
   * @returns {Array.<Packet>} array of parsed packets
236
   */
237
  parseRecursive(buffer, packets) {
238
    const result = this.parsePacket(buffer);
181✔
239

240
    result.payload = this.getPayload(result.payload);
149✔
241

242
    packets.push(result);
149✔
243

244
    if (result.leftover) {
149✔
245
      return this.parseRecursive(result.leftover, packets);
48✔
246
    }
247

248
    return packets;
101✔
249
  }
250

251
  /**
252
   * Given a buffer potentially containing
253
   * multiple packets, this parses and returns
254
   * all of them.
255
   * @param {Buffer} buffer to parse
256
   * @returns {Array.<Packet>} parsed packets
257
   */
258
  parse(buffer) {
259
    return this.parseRecursive(buffer, []);
133✔
260
  }
261

262
  /**
263
   * Encodes a payload into a Tuya-protocol-compliant packet.
264
   * @param {Object} options Options for encoding
265
   * @param {Buffer|String|Object} options.data data to encode
266
   * @param {Boolean} options.encrypted whether or not to encrypt the data
267
   * @param {Number} options.commandByte
268
   * command byte of packet (use CommandType definitions)
269
   * @param {Number} [options.sequenceN] optional, sequence number
270
   * @returns {Buffer} Encoded Buffer
271
   */
272
  encode(options) {
273
    // Check command byte
274
    if (!Object.values(CommandType).includes(options.commandByte)) {
156✔
275
      throw new TypeError('Command byte not defined.');
4✔
276
    }
277

278
    // Convert Objects to Strings, Strings to Buffers
279
    if (!(options.data instanceof Buffer)) {
152✔
280
      if (typeof options.data !== 'string') {
132✔
281
        options.data = JSON.stringify(options.data);
120✔
282
      }
283

284
      options.data = Buffer.from(options.data);
132✔
285
    }
286

287
    if (this.version === '3.4') {
152!
288
      return this._encode34(options);
×
289
    }
290

291
    return this._encodePre34(options);
152✔
292
  }
293

294
  /**
295
   * Encodes a payload into a Tuya-protocol-compliant packet for protocol version 3.3 and below.
296
   * @param {Object} options Options for encoding
297
   * @param {Buffer|String|Object} options.data data to encode
298
   * @param {Boolean} options.encrypted whether or not to encrypt the data
299
   * @param {Number} options.commandByte
300
   * command byte of packet (use CommandType definitions)
301
   * @param {Number} [options.sequenceN] optional, sequence number
302
   * @returns {Buffer} Encoded Buffer
303
   */
304
  _encodePre34(options) {
305
    // Construct payload
306
    let payload = options.data;
152✔
307

308
    // Protocol 3.3 and 3.2 is always encrypted
309
    if (this.version === '3.3' || this.version === '3.2') {
152✔
310
      // Encrypt data
311
      payload = this.cipher.encrypt({
8✔
312
        data: payload,
313
        base64: false
314
      });
315

316
      // Check if we need an extended header, only for certain CommandTypes
317
      if (options.commandByte !== CommandType.DP_QUERY &&
8✔
318
          options.commandByte !== CommandType.DP_REFRESH) {
319
        // Add 3.3 header
320
        const buffer = Buffer.alloc(payload.length + 15);
4✔
321
        Buffer.from('3.3').copy(buffer, 0);
4✔
322
        payload.copy(buffer, 15);
4✔
323
        payload = buffer;
4✔
324
      }
325
    } else if (options.encrypted) {
144✔
326
      // Protocol 3.1 and below, only encrypt data if necessary
327
      payload = this.cipher.encrypt({
12✔
328
        data: payload
329
      });
330

331
      // Create MD5 signature
332
      const md5 = this.cipher.md5('data=' + payload +
12✔
333
          '||lpv=' + this.version +
334
          '||' + this.key);
335

336
      // Create byte buffer from hex data
337
      payload = Buffer.from(this.version + md5 + payload);
12✔
338
    }
339

340
    // Allocate buffer with room for payload + 24 bytes for
341
    // prefix, sequence, command, length, crc, and suffix
342
    const buffer = Buffer.alloc(payload.length + 24);
152✔
343

344
    // Add prefix, command, and length
345
    buffer.writeUInt32BE(0x000055AA, 0);
152✔
346
    buffer.writeUInt32BE(options.commandByte, 8);
152✔
347
    buffer.writeUInt32BE(payload.length + 8, 12);
152✔
348

349
    if (options.sequenceN) {
152✔
350
      buffer.writeUInt32BE(options.sequenceN, 4);
96✔
351
    }
352

353
    // Add payload, crc, and suffix
354
    payload.copy(buffer, 16);
152✔
355
    const calculatedCrc = crc(buffer.slice(0, payload.length + 16)) & 0xFFFFFFFF;
152✔
356

357
    buffer.writeInt32BE(calculatedCrc, payload.length + 16);
152✔
358
    buffer.writeUInt32BE(0x0000AA55, payload.length + 20);
152✔
359

360
    return buffer;
152✔
361
  }
362

363
  /**
364
   * Encodes a payload into a Tuya-protocol-complient packet for protocol version 3.4
365
   * @param {Object} options Options for encoding
366
   * @param {Buffer|String|Object} options.data data to encode
367
   * @param {Boolean} options.encrypted whether or not to encrypt the data
368
   * @param {Number} options.commandByte
369
   * command byte of packet (use CommandType definitions)
370
   * @param {Number} [options.sequenceN] optional, sequence number
371
   * @returns {Buffer} Encoded Buffer
372
   */
373
  _encode34(options) {
374
    let payload = options.data;
×
375

376
    if (options.commandByte !== CommandType.DP_QUERY &&
×
377
        options.commandByte !== CommandType.HEART_BEAT &&
378
        options.commandByte !== CommandType.DP_QUERY_NEW &&
379
        options.commandByte !== CommandType.SESS_KEY_NEG_START &&
380
        options.commandByte !== CommandType.SESS_KEY_NEG_FINISH &&
381
        options.commandByte !== CommandType.DP_REFRESH) {
382
      // Add 3.4 header
383
      // check this: mqc_very_pcmcd_mcd(int a1, unsigned int a2)
384
      const buffer = Buffer.alloc(payload.length + 15);
×
385
      Buffer.from('3.4').copy(buffer, 0);
×
386
      payload.copy(buffer, 15);
×
387
      payload = buffer;
×
388
    }
389

390
    // ? if (payload.length > 0) { // is null messages need padding - PING work without
391
    const padding = 0x10 - (payload.length & 0xF);
×
392
    const buf34 = Buffer.alloc((payload.length + padding), padding);
×
393
    payload.copy(buf34);
×
394
    payload = buf34;
×
395
    // }
396

397
    payload = this.cipher.encrypt({
×
398
      data: payload
399
    });
400

401
    payload = Buffer.from(payload);
×
402

403
    // Allocate buffer with room for payload + 24 bytes for
404
    // prefix, sequence, command, length, crc, and suffix
405
    const buffer = Buffer.alloc(payload.length + 52);
×
406

407
    // Add prefix, command, and length
408
    buffer.writeUInt32BE(0x000055AA, 0);
×
409
    buffer.writeUInt32BE(options.commandByte, 8);
×
410
    buffer.writeUInt32BE(payload.length + 0x24, 12);
×
411

412
    if (options.sequenceN) {
×
413
      buffer.writeUInt32BE(options.sequenceN, 4);
×
414
    }
415

416
    // Add payload, crc, and suffix
417
    payload.copy(buffer, 16);
×
418
    const calculatedCrc = this.cipher.hmac(buffer.slice(0, payload.length + 16));// & 0xFFFFFFFF;
×
419
    calculatedCrc.copy(buffer, payload.length + 16);
×
420

421
    buffer.writeUInt32BE(0x0000AA55, payload.length + 48);
×
422
    return buffer;
×
423
  }
424
}
425

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