• 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

63.98
/index.js
1
// Import packages
2
const dgram = require('dgram');
12✔
3
const net = require('net');
12✔
4
const {EventEmitter} = require('events');
12✔
5
const pTimeout = require('p-timeout');
12✔
6
const pRetry = require('p-retry');
12✔
7
const {default: PQueue} = require('p-queue');
12✔
8
const debug = require('debug')('TuyAPI');
12✔
9

10
// Helpers
11
const {isValidString} = require('./lib/utils');
12✔
12
const {MessageParser, CommandType} = require('./lib/message-parser');
12✔
13
const {UDP_KEY} = require('./lib/config');
12✔
14

15
/**
16
 * Represents a Tuya device.
17
 *
18
 * You *must* pass either an IP or an ID. If
19
 * you're experiencing problems when only passing
20
 * one, try passing both if possible.
21
 * @class
22
 * @param {Object} options Options object
23
 * @param {String} [options.ip] IP of device
24
 * @param {Number} [options.port=6668] port of device
25
 * @param {String} [options.id] ID of device (also called `devId`)
26
 * @param {String} [options.gwID=''] gateway ID (not needed for most devices),
27
 * if omitted assumed to be the same as `options.id`
28
 * @param {String} options.key encryption key of device (also called `localKey`)
29
 * @param {String} [options.productKey] product key of device (currently unused)
30
 * @param {Number} [options.version=3.1] protocol version
31
 * @param {Boolean} [options.nullPayloadOnJSONError=false] if true, emits a data event
32
 * containing a payload of null values for on-device JSON parsing errors
33
 * @param {Boolean} [options.issueGetOnConnect=true] if true, sends GET request after
34
 * connection is established. This should probably be `false` in synchronous usage.
35
 * @param {Boolean} [options.issueRefreshOnConnect=false] if true, sends DP_REFRESH request after
36
 * connection is established. This should probably be `false` in synchronous usage.
37
 * @param {Boolean} [options.issueRefreshOnPing=false] if true, sends DP_REFRESH and GET request after
38
 * every ping. This should probably be `false` in synchronous usage.
39
 * @example
40
 * const tuya = new TuyaDevice({id: 'xxxxxxxxxxxxxxxxxxxx',
41
 *                              key: 'xxxxxxxxxxxxxxxx'})
42
 */
43
class TuyaDevice extends EventEmitter {
44
  constructor({
4✔
45
    ip,
46
    port = 6668,
68✔
47
    id,
48
    gwID = id,
68✔
49
    key,
50
    productKey,
51
    version = 3.1,
68✔
52
    nullPayloadOnJSONError = false,
68✔
53
    issueGetOnConnect = true,
68✔
54
    issueRefreshOnConnect = false,
68✔
55
    issueRefreshOnPing = false
68✔
56
  } = {}) {
57
    super();
68✔
58

59
    // Set device to user-passed options
60
    version = version.toString();
68✔
61
    this.device = {ip, port, id, gwID, key, productKey, version};
68✔
62
    this.globalOptions = {
68✔
63
      issueGetOnConnect,
64
      issueRefreshOnConnect,
65
      issueRefreshOnPing
66
    };
67

68
    this.nullPayloadOnJSONError = nullPayloadOnJSONError;
68✔
69

70
    // Check arguments
71
    if (!(isValidString(id) ||
68✔
72
        isValidString(ip))) {
73
      throw new TypeError('ID and IP are missing from device.');
4✔
74
    }
75

76
    // Check key
77
    if (!isValidString(this.device.key) || this.device.key.length !== 16) {
64✔
78
      throw new TypeError('Key is missing or incorrect.');
4✔
79
    }
80

81
    // Handles encoding/decoding, encrypting/decrypting messages
82
    this.device.parser = new MessageParser({
60✔
83
      key: this.device.key,
84
      version: this.device.version
85
    });
86

87
    // Contains array of found devices when calling .find()
88
    this.foundDevices = [];
60✔
89

90
    // Private instance variables
91

92
    // Socket connected state
93
    this._connected = false;
60✔
94

95
    this._responseTimeout = 2; // Seconds
60✔
96
    this._connectTimeout = 5; // Seconds
60✔
97
    this._pingPongPeriod = 10; // Seconds
60✔
98
    this._pingPongTimeout = null;
60✔
99
    this._lastPingAt = new Date();
60✔
100

101
    this._currentSequenceN = 0;
60✔
102
    this._resolvers = {};
60✔
103
    this._setQueue = new PQueue({
60✔
104
      concurrency: 1
105
    });
106

107
    // List of dps which needed CommandType.DP_REFRESH (command 18) to force refresh their values.
108
    // Power data - DP 19 on some 3.1/3.3 devices, DP 5 for some 3.1 devices.
109
    this._dpRefreshIds = [4, 5, 6, 18, 19, 20];
60✔
110
    this._tmpLocalKey = null;
60✔
111
    this._tmpRemoteKey = null;
60✔
112
    this.sessionKey = null;
60✔
113
  }
114

115
  /**
116
   * Gets a device's current status.
117
   * Defaults to returning only the value of the first DPS index.
118
   * @param {Object} [options] Options object
119
   * @param {Boolean} [options.schema]
120
   * true to return entire list of properties from device
121
   * @param {Number} [options.dps=1]
122
   * DPS index to return
123
   * @param {String} [options.cid]
124
   * if specified, use device id of zigbee gateway and cid of subdevice to get its status
125
   * @example
126
   * // get first, default property from device
127
   * tuya.get().then(status => console.log(status))
128
   * @example
129
   * // get second property from device
130
   * tuya.get({dps: 2}).then(status => console.log(status))
131
   * @example
132
   * // get all available data from device
133
   * tuya.get({schema: true}).then(data => console.log(data))
134
   * @returns {Promise<Boolean|Object>}
135
   * returns boolean if single property is requested, otherwise returns object of results
136
   */
137
  async get(options = {}) {
40✔
138
    const payload = {
56✔
139
      gwId: this.device.gwID,
140
      devId: this.device.id,
141
      t: Math.round(new Date().getTime() / 1000).toString(),
142
      dps: {},
143
      uid: this.device.id
144
    };
145

146
    if (options.cid) {
56!
147
      payload.cid = options.cid;
×
148
    }
149

150
    const commandByte = this.device.version === '3.4' ? CommandType.DP_QUERY_NEW : CommandType.DP_QUERY;
56!
151

152
    // Create byte buffer
153
    const buffer = this.device.parser.encode({
56✔
154
      data: payload,
155
      commandByte,
156
      sequenceN: ++this._currentSequenceN
157
    });
158

159
    let data;
160
    // Send request to read data - should work in most cases beside Protocol 3.2
161
    if (this.device.version !== '3.2') {
56!
162
      debug('GET Payload:');
56✔
163
      debug(payload);
56✔
164

165
      data = await this._send(buffer);
56✔
166
    }
167

168
    // If data read failed with defined error messages or device uses Protocol 3.2 we need to read differently
169
    if (
45!
170
      this.device.version === '3.2' ||
135✔
171
      data === 'json obj data unvalid' || data === 'data format error' /* || data === 'devid not found' */
172
    ) {
173
      // Some devices don't respond to DP_QUERY so, for DPS get commands, fall
174
      // back to using SEND with null value. This appears to always work as
175
      // long as the DPS key exist on the device.
176
      // For schema there's currently no fallback options
177
      debug('GET needs to use SEND instead of DP_QUERY to get data');
×
178
      const setOptions = {
×
179
        dps: options.dps ? options.dps : 1,
×
180
        set: null,
181
        isSetCallToGetData: true
182
      };
183
      data = await this.set(setOptions);
×
184
    }
185

186
    if (typeof data !== 'object' || options.schema === true) {
45✔
187
      // Return whole response
188
      return data;
4✔
189
    }
190

191
    if (options.dps) {
41✔
192
      // Return specific property
193
      return data.dps[options.dps];
12✔
194
    }
195

196
    // Return first property by default
197
    return data.dps['1'];
29✔
198
  }
199

200
  /**
201
   * Refresh a device's current status.
202
   * Defaults to returning all values.
203
   * @param {Object} [options] Options object
204
   * @param {Boolean} [options.schema]
205
   * true to return entire list of properties from device
206
   * @param {Number} [options.dps=1]
207
   * DPS index to return
208
   * @param {String} [options.cid]
209
   * if specified, use device id of zigbee gateway and cid of subdevice to refresh its status
210
   * @param {Array.Number} [options.requestedDPS=[4,5,6,18,19,20]]
211
   * only set this if you know what you're doing
212
   * @example
213
   * // get first, default property from device
214
   * tuya.refresh().then(status => console.log(status))
215
   * @example
216
   * // get second property from device
217
   * tuya.refresh({dps: 2}).then(status => console.log(status))
218
   * @example
219
   * // get all available data from device
220
   * tuya.refresh({schema: true}).then(data => console.log(data))
221
   * @returns {Promise<Object>}
222
   * returns object of results
223
   */
224
  refresh(options = {}) {
×
225
    const payload = {
×
226
      gwId: this.device.gwID,
227
      devId: this.device.id,
228
      t: Math.round(new Date().getTime() / 1000).toString(),
229
      dpId: options.requestedDPS ? options.requestedDPS : this._dpRefreshIds,
×
230
      uid: this.device.id
231
    };
232

233
    if (options.cid) {
×
234
      payload.cid = options.cid;
×
235
    }
236

NEW
237
    debug('GET Payload (refresh):');
×
238
    debug(payload);
×
239

NEW
240
    const sequenceN = ++this._currentSequenceN;
×
241
    // Create byte buffer
242
    const buffer = this.device.parser.encode({
×
243
      data: payload,
244
      commandByte: CommandType.DP_REFRESH,
245
      sequenceN
246
    });
247

248
    // Send request and parse response
249
    return new Promise((resolve, reject) => {
×
NEW
250
      this._expectRefreshResponseForSequenceN = sequenceN;
×
251
      // Send request
252
      this._send(buffer).then(async data => {
×
253
        if (data === 'json obj data unvalid') {
×
254
          // Some devices don't respond to DP_QUERY so, for DPS get commands, fall
255
          // back to using SEND with null value. This appears to always work as
256
          // long as the DPS key exist on the device.
257
          // For schema there's currently no fallback options
258
          const setOptions = {
×
259
            dps: options.requestedDPS ? options.requestedDPS : this._dpRefreshIds,
×
260
            set: null,
261
            isSetCallToGetData: true
262
          };
263
          data = await this.set(setOptions);
×
264
        }
265

266
        if (typeof data !== 'object' || options.schema === true) {
×
267
          // Return whole response
268
          resolve(data);
×
269
        } else if (options.dps) {
×
270
          // Return specific property
271
          resolve(data.dps[options.dps]);
×
272
        } else {
273
          // Return all dps by default
274
          resolve(data.dps);
×
275
        }
276
      })
277
        .catch(reject);
278
    });
279
  }
280

281
  /**
282
   * Sets a property on a device.
283
   * @param {Object} options Options object
284
   * @param {Number} [options.dps=1] DPS index to set
285
   * @param {*} [options.set] value to set
286
   * @param {String} [options.cid]
287
   * if specified, use device id of zigbee gateway and cid of subdevice to set its property
288
   * @param {Boolean} [options.multiple=false]
289
   * Whether or not multiple properties should be set with options.data
290
   * @param {Boolean} [options.isSetCallToGetData=false]
291
   * Wether or not the set command is used to get data
292
   * @param {Object} [options.data={}] Multiple properties to set at once. See above.
293
   * @param {Boolean} [options.shouldWaitForResponse=true] see
294
   * [#420](https://github.com/codetheweb/tuyapi/issues/420) and
295
   * [#421](https://github.com/codetheweb/tuyapi/pull/421) for details
296
   * @example
297
   * // set default property
298
   * tuya.set({set: true}).then(() => console.log('device was turned on'))
299
   * @example
300
   * // set custom property
301
   * tuya.set({dps: 2, set: false}).then(() => console.log('device was turned off'))
302
   * @example
303
   * // set multiple properties
304
   * tuya.set({
305
   *           multiple: true,
306
   *           data: {
307
   *             '1': true,
308
   *             '2': 'white'
309
   *          }}).then(() => console.log('device was changed'))
310
   * @example
311
   * // set custom property for a specific (virtual) deviceId
312
   * tuya.set({
313
   *           dps: 2,
314
   *           set: false,
315
   *           devId: '04314116cc50e346566e'
316
   *          }).then(() => console.log('device was turned off'))
317
   * @returns {Promise<Object>} - returns response from device
318
   */
319
  set(options) {
320
    // Check arguments
321
    if (options === undefined || Object.entries(options).length === 0) {
16✔
322
      throw new TypeError('No arguments were passed.');
4✔
323
    }
324

325
    // Defaults
326
    let dps;
327

328
    if (options.multiple === true) {
12✔
329
      dps = options.data;
4✔
330
    } else if (options.dps === undefined) {
8✔
331
      dps = {
4✔
332
        1: options.set
333
      };
334
    } else {
335
      dps = {
4✔
336
        [options.dps.toString()]: options.set
337
      };
338
    }
339

340
    options.shouldWaitForResponse = typeof options.shouldWaitForResponse === 'undefined' ? true : options.shouldWaitForResponse;
12!
341

342
    // When set has only null values then it is used to get data
343
    if (!options.isSetCallToGetData) {
12!
344
      options.isSetCallToGetData = true;
12✔
345
      Object.keys(dps).forEach(key => {
12✔
346
        options.isSetCallToGetData = options.isSetCallToGetData && dps[key] === null;
16✔
347
      });
348
    }
349

350
    // Get time
351
    const timeStamp = parseInt(Date.now() / 1000, 10);
12✔
352

353
    // Construct payload
354
    let payload = {
12✔
355
      t: timeStamp,
356
      dps
357
    };
358

359
    if (options.cid) {
12!
360
      payload.cid = options.cid;
×
361
    } else {
362
      payload = {
12✔
363
        devId: options.devId || this.device.id,
24✔
364
        gwId: this.device.gwID,
365
        uid: '',
366
        ...payload
367
      };
368
    }
369

370
    if (this.device.version === '3.4') {
12!
371
      /*
372
      {
373
        "data": {
374
          "cid": "xxxxxxxxxxxxxxxx",
375
          "ctype": 0,
376
          "dps": {
377
            "1": "manual"
378
          }
379
        },
380
        "protocol": 5,
381
        "t": 1633243332
382
      }
383
      */
384
      payload = {
×
385
        data: {
386
          ctype: 0,
387
          ...payload
388
        },
389
        protocol: 5,
390
        t: timeStamp
391
      };
392
      delete payload.data.t;
×
393
    }
394

395
    if (options.shouldWaitForResponse && this._setResolver) {
12!
NEW
396
      throw new Error('A set command is already in progress. Can not issue a second one that also should return a response.');
×
397
    }
398

399
    debug('SET Payload:');
12✔
400
    debug(payload);
12✔
401

402
    const commandByte = this.device.version === '3.4' ? CommandType.CONTROL_NEW : CommandType.CONTROL;
12!
403
    const sequenceN = ++this._currentSequenceN;
12✔
404
    // Encode into packet
405
    const buffer = this.device.parser.encode({
12✔
406
      data: payload,
407
      encrypted: true, // Set commands must be encrypted
408
      commandByte,
409
      sequenceN
410
    });
411

412
    // Queue this request and limit concurrent set requests to one
413
    return this._setQueue.add(() => pTimeout(new Promise((resolve, reject) => {
12✔
414
      // Send request and wait for response
415
      try {
12✔
416
        // Send request
417
        this._send(buffer);
12✔
418
        if (options.shouldWaitForResponse) {
12!
419
          this._setResolver = resolve;
12✔
420
          this._setResolveAllowGet = options.isSetCallToGetData;
12✔
421
        } else {
422
          resolve();
×
423
        }
424
      } catch (error) {
425
        reject(error);
×
426
      }
427
    }), this._responseTimeout * 2500, () => {
428
      // Only gets here on timeout so clear resolver function and emit error
429
      this._setResolver = undefined;
×
NEW
430
      this._setResolveAllowGet = undefined;
×
NEW
431
      delete this._resolvers[sequenceN];
×
NEW
432
      this._expectRefreshResponseForSequenceN = undefined;
×
433

434
      this.emit(
×
435
        'error',
436
        'Timeout waiting for status response from device id: ' + this.device.id
437
      );
438
    }));
439
  }
440

441
  /**
442
   * Sends a query to a device. Helper function
443
   * that connects to a device if necessary and
444
   * wraps the entire operation in a retry.
445
   * @private
446
   * @param {Buffer} buffer buffer of data
447
   * @returns {Promise<Any>} returned data for request
448
   */
449
  _send(buffer) {
450
    const sequenceNo = this._currentSequenceN;
68✔
451
    // Retry up to 5 times
452
    return pRetry(() => {
68✔
453
      return new Promise((resolve, reject) => {
68✔
454
        // Send data
455
        this.connect().then(() => {
68✔
456
          try {
68✔
457
            this.client.write(buffer);
68✔
458

459
            // Add resolver function
460
            this._resolvers[sequenceNo] = data => resolve(data);
68✔
461
          } catch (error) {
462
            reject(error);
×
463
          }
464
        })
465
          .catch(error => reject(error));
×
466
      });
467
    }, {
468
      onFailedAttempt: error => {
469
        debug(`Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left.`);
×
470
      }, retries: 5});
471
  }
472

473
  /**
474
   * Sends a heartbeat ping to the device
475
   * @private
476
   */
477
  async _sendPing() {
478
    debug(`Pinging ${this.device.ip}`);
20✔
479

480
    // Create byte buffer
481
    const buffer = this.device.parser.encode({
20✔
482
      data: Buffer.allocUnsafe(0),
483
      commandByte: CommandType.HEART_BEAT,
484
      sequenceN: ++this._currentSequenceN
485
    });
486

487
    // Check for response
488
    const now = new Date();
20✔
489

490
    this._pingPongTimeout = setTimeout(() => {
20✔
491
      if (this._lastPingAt < now) {
8!
492
        this.disconnect();
8✔
493
      }
494
    }, this._responseTimeout * 1000);
495

496
    // Send ping
497
    this.client.write(buffer);
20✔
498
    if (this.globalOptions.issueRefreshOnPing) {
20!
499
      this.refresh();
×
500
      this.get();
×
501
    }
502
  }
503

504
  /**
505
   * Create a deferred promise that resolves as soon as the connection is established.
506
   */
507
  createDeferredConnectPromise() {
508
    let res;
509
    let rej;
510

511
    this.connectPromise = new Promise((resolve, reject) => {
36✔
512
      res = resolve;
36✔
513
      rej = reject;
36✔
514
    });
515

516
    this.connectPromise.resolve = res;
36✔
517
    this.connectPromise.reject = rej;
36✔
518
  }
519

520
  /**
521
   * Finish connecting and resolve
522
   */
523
  _finishConnect() {
524
    this._connected = true;
36✔
525

526
    /**
527
     * Emitted when socket is connected
528
     * to device. This event may be emitted
529
     * multiple times within the same script,
530
     * so don't use this as a trigger for your
531
     * initialization code.
532
     * @event TuyaDevice#connected
533
     */
534
    this.emit('connected');
36✔
535

536
    // Periodically send heartbeat ping
537
    this._pingPongInterval = setInterval(async () => {
36✔
538
      await this._sendPing();
20✔
539
    }, this._pingPongPeriod * 1000);
540

541
    // Automatically ask for dp_refresh so we
542
    // can emit a `dp_refresh` event as soon as possible
543
    if (this.globalOptions.issueRefreshOnConnect) {
36!
544
      this.refresh();
×
545
    }
546

547
    // Automatically ask for current state so we
548
    // can emit a `data` event as soon as possible
549
    if (this.globalOptions.issueGetOnConnect) {
36!
550
      this.get();
36✔
551
    }
552

553
    // Resolve
554
    if (this.connectPromise) {
36!
555
      this.connectPromise.resolve(true);
36✔
556
      delete this.connectPromise;
36✔
557
    }
558
  }
559

560
  /**
561
   * Connects to the device. Can be called even
562
   * if device is already connected.
563
   * @returns {Promise<Boolean>} `true` if connect succeeds
564
   * @emits TuyaDevice#connected
565
   * @emits TuyaDevice#disconnected
566
   * @emits TuyaDevice#data
567
   * @emits TuyaDevice#error
568
   */
569
  connect() {
570
    if (this.isConnected()) {
104✔
571
      // Return if already connected
572
      return Promise.resolve(true);
68✔
573
    }
574

575
    if (this.connectPromise) {
36!
576
      // If a connect approach still in progress simply return same Promise
577
      return this.connectPromise;
×
578
    }
579

580
    this.createDeferredConnectPromise();
36✔
581

582
    this.client = new net.Socket();
36✔
583

584
    // Default connect timeout is ~1 minute,
585
    // 5 seconds is a more reasonable default
586
    // since `retry` is used.
587
    this.client.setTimeout(this._connectTimeout * 1000, () => {
36✔
588
      /**
589
       * Emitted on socket error, usually a
590
       * result of a connection timeout.
591
       * Also emitted on parsing errors.
592
       * @event TuyaDevice#error
593
       * @property {Error} error error event
594
       */
595
      // this.emit('error', new Error('connection timed out'));
596
      this.client.destroy();
×
597
      this.emit('error', new Error('connection timed out'));
×
598
      if (this.connectPromise) {
×
599
        this.connectPromise.reject(new Error('connection timed out'));
×
600
        delete this.connectPromise;
×
601
      }
602
    });
603

604
    // Add event listeners to socket
605

606
    // Parse response data
607
    this.client.on('data', data => {
36✔
608
      debug(`Received data: ${data.toString('hex')}`);
53✔
609

610
      let packets;
611

612
      try {
53✔
613
        packets = this.device.parser.parse(data);
53✔
614

615
        if (this.nullPayloadOnJSONError) {
53!
616
          for (const packet of packets) {
×
617
            if (packet.payload && packet.payload === 'json obj data unvalid') {
×
618
              this.emit('error', packet.payload);
×
619

620
              packet.payload = {
×
621
                dps: {
622
                  1: null,
623
                  2: null,
624
                  3: null,
625
                  101: null,
626
                  102: null,
627
                  103: null
628
                }
629
              };
630
            }
631
          }
632
        }
633
      } catch (error) {
634
        debug(error);
×
635
        this.emit('error', error);
×
636
        return;
×
637
      }
638

639
      packets.forEach(packet => {
53✔
640
        debug('Parsed:');
93✔
641
        debug(packet);
93✔
642

643
        this._packetHandler.bind(this)(packet);
93✔
644
      });
645
    });
646

647
    // Handle errors
648
    this.client.on('error', err => {
36✔
649
      debug('Error event from socket.', this.device.ip, err);
4✔
650

651
      this.emit('error', new Error('Error from socket: ' + err.message));
4✔
652

653
      if (!this._connected && this.connectPromise) {
4!
654
        this.connectPromise.reject(err);
×
655
        delete this.connectPromise;
×
656
      }
657

658
      this.client.destroy();
4✔
659
    });
660

661
    // Handle socket closure
662
    this.client.on('close', () => {
36✔
663
      debug(`Socket closed: ${this.device.ip}`);
32✔
664

665
      this.disconnect();
32✔
666
    });
667

668
    this.client.on('connect', async () => {
36✔
669
      debug('Socket connected.');
36✔
670

671
      // Remove connect timeout
672
      this.client.setTimeout(0);
36✔
673

674
      if (this.device.version === '3.4') {
36!
675
        // Negotiate session key then emit 'connected'
676
        // 16 bytes random + 32 bytes hmac
677
        try {
×
678
          this._tmpLocalKey = this.device.parser.cipher.random();
×
679
          const buffer = this.device.parser.encode({
×
680
            data: this._tmpLocalKey,
681
            encrypted: true,
682
            commandByte: CommandType.SESS_KEY_NEG_START,
683
            sequenceN: ++this._currentSequenceN
684
          });
685

686
          debug('Protocol 3.4: Negotiate Session Key - Send Msg 0x03');
×
687
          this.client.write(buffer);
×
688
        } catch (error) {
689
          debug('Error binding key for protocol 3.4: ' + error);
×
690
        }
691

692
        return;
×
693
      }
694

695
      this._finishConnect();
36✔
696
    });
697

698
    debug(`Connecting to ${this.device.ip}...`);
36✔
699
    this.client.connect(this.device.port, this.device.ip);
36✔
700

701
    return this.connectPromise;
36✔
702
  }
703

704
  _packetHandler(packet) {
705
    // Response was received, so stop waiting
706
    clearTimeout(this._sendTimeout);
93✔
707

708
    // Protocol 3.4 - Response to Msg 0x03
709
    if (packet.commandByte === CommandType.SESS_KEY_NEG_RES) {
93!
710
      if (!this.connectPromise) {
×
711
        debug('Protocol 3.4: Ignore Key exchange message because no connection in progress.');
×
712
        return;
×
713
      }
714

715
      // 16 bytes _tmpRemoteKey and hmac on _tmpLocalKey
716
      this._tmpRemoteKey = packet.payload.subarray(0, 16);
×
717
      debug('Protocol 3.4: Local Random Key: ' + this._tmpLocalKey.toString('hex'));
×
718
      debug('Protocol 3.4: Remote Random Key: ' + this._tmpRemoteKey.toString('hex'));
×
719

720
      const calcLocalHmac = this.device.parser.cipher.hmac(this._tmpLocalKey).toString('hex');
×
721
      const expLocalHmac = packet.payload.slice(16, 16 + 32).toString('hex');
×
722
      if (expLocalHmac !== calcLocalHmac) {
×
723
        const err = new Error(`HMAC mismatch(keys): expected ${expLocalHmac}, was ${calcLocalHmac}. ${packet.payload.toString('hex')}`);
×
724
        if (this.connectPromise) {
×
725
          this.connectPromise.reject(err);
×
726
          delete this.connectPromise;
×
727
        }
728

729
        this.emit('error', err);
×
730
        return;
×
731
      }
732

733
      // Send response 0x05
734
      const buffer = this.device.parser.encode({
×
735
        data: this.device.parser.cipher.hmac(this._tmpRemoteKey),
736
        encrypted: true,
737
        commandByte: CommandType.SESS_KEY_NEG_FINISH,
738
        sequenceN: ++this._currentSequenceN
739
      });
740

741
      this.client.write(buffer);
×
742

743
      // Calculate session key
744
      this.sessionKey = Buffer.from(this._tmpLocalKey);
×
745
      for (let i = 0; i < this._tmpLocalKey.length; i++) {
×
746
        this.sessionKey[i] = this._tmpLocalKey[i] ^ this._tmpRemoteKey[i];
×
747
      }
748

749
      this.sessionKey = this.device.parser.cipher._encrypt34({data: this.sessionKey});
×
750
      debug('Protocol 3.4: Session Key: ' + this.sessionKey.toString('hex'));
×
751
      debug('Protocol 3.4: Initialization done');
×
752

753
      this.device.parser.cipher.setSessionKey(this.sessionKey);
×
754
      this.device.key = this.sessionKey;
×
755

756
      return this._finishConnect();
×
757
    }
758

759
    if (packet.commandByte === CommandType.HEART_BEAT) {
93✔
760
      debug(`Pong from ${this.device.ip}`);
4✔
761
      /**
762
       * Emitted when a heartbeat ping is returned.
763
       * @event TuyaDevice#heartbeat
764
       */
765
      this.emit('heartbeat');
4✔
766

767
      this._lastPingAt = new Date();
4✔
768

769
      return;
4✔
770
    }
771

772
    if (
89!
773
      (
178✔
774
        packet.commandByte === CommandType.CONTROL ||
775
        packet.commandByte === CommandType.CONTROL_NEW
776
      ) && packet.payload === false) {
777
      debug('Got SET ack.');
×
778
      return;
×
779
    }
780

781
    // Returned DP refresh response is always empty. Device respond with command 8 without dps 1 instead.
782
    if (packet.commandByte === CommandType.DP_REFRESH) {
89!
783
      // If we did not get any STATUS packet, we need to resolve the promise.
NEW
784
      if (typeof this._setResolver === 'function') {
×
NEW
785
        debug('Received DP_REFRESH empty response packet without STATUS packet from set command - resolve');
×
NEW
786
        this._setResolver(packet.payload);
×
787

788
        // Remove resolver
NEW
789
        this._setResolver = undefined;
×
NEW
790
        this._setResolveAllowGet = undefined;
×
NEW
791
        delete this._resolvers[packet.sequenceN];
×
NEW
792
        this._expectRefreshResponseForSequenceN = undefined;
×
793
      } else {
794
        // Call data resolver for sequence number
NEW
795
        if (packet.sequenceN in this._resolvers) {
×
NEW
796
          debug('Received DP_REFRESH response packet - resolve');
×
NEW
797
          this._resolvers[packet.sequenceN](packet.payload);
×
798

799
          // Remove resolver
NEW
800
          delete this._resolvers[packet.sequenceN];
×
NEW
801
          this._expectRefreshResponseForSequenceN = undefined;
×
NEW
802
        } else if (this._expectRefreshResponseForSequenceN && this._expectRefreshResponseForSequenceN in this._resolvers) {
×
NEW
803
          debug('Received DP_REFRESH response packet without data - resolve');
×
NEW
804
          this._resolvers[this._expectRefreshResponseForSequenceN](packet.payload);
×
805

806
          // Remove resolver
NEW
807
          delete this._resolvers[this._expectRefreshResponseForSequenceN];
×
NEW
808
          this._expectRefreshResponseForSequenceN = undefined;
×
809
        } else {
NEW
810
          debug('Received DP_REFRESH response packet - no resolver found for sequence number' + packet.sequenceN);
×
811
        }
812
      }
UNCOV
813
      return;
×
814
    }
815

816
    if (packet.commandByte === CommandType.STATUS && packet.payload && packet.payload.dps && typeof packet.payload.dps[1] === 'undefined') {
89!
817
      debug('Received DP_REFRESH packet.');
×
818
      /**
819
       * Emitted when dp_refresh data is proactive returned from device, omitting dps 1
820
       * Only changed dps are returned.
821
       * @event TuyaDevice#dp-refresh
822
       * @property {Object} data received data
823
       * @property {Number} commandByte
824
       * commandByte of result( 8=proactive update from device)
825
       * @property {Number} sequenceN the packet sequence number
826
       */
827
      this.emit('dp-refresh', packet.payload, packet.commandByte, packet.sequenceN);
×
828
    } else {
829
      debug('Received DATA packet');
89✔
830
      debug('data: ' + packet.commandByte + ' : ' + (Buffer.isBuffer(packet.payload) ? packet.payload.toString('hex') : JSON.stringify(packet.payload)));
89!
831
      /**
832
       * Emitted when data is returned from device.
833
       * @event TuyaDevice#data
834
       * @property {Object} data received data
835
       * @property {Number} commandByte
836
       * commandByte of result
837
       * (e.g. 7=requested response, 8=proactive update from device)
838
       * @property {Number} sequenceN the packet sequence number
839
       */
840
      this.emit('data', packet.payload, packet.commandByte, packet.sequenceN);
89✔
841
    }
842

843
    // Status response to SET command
844
    if (
89✔
845
      packet.commandByte === CommandType.STATUS &&
101✔
846
      typeof this._setResolver === 'function'
847
    ) {
848
      this._setResolver(packet.payload);
12✔
849

850
      // Remove resolver
851
      this._setResolver = undefined;
12✔
852
      this._setResolveAllowGet = undefined;
12✔
853
      delete this._resolvers[packet.sequenceN];
12✔
854
      this._expectRefreshResponseForSequenceN = undefined;
12✔
855
      return;
12✔
856
    }
857

858
    // Status response to SET command which was used to GET data and returns DP_QUERY response
859
    if (
77!
860
      packet.commandByte === CommandType.DP_QUERY &&
130✔
861
      typeof this._setResolver === 'function' &&
862
      this._setResolveAllowGet === true
863
    ) {
NEW
864
      this._setResolver(packet.payload);
×
865

866
      // Remove resolver
NEW
867
      this._setResolver = undefined;
×
NEW
868
      this._setResolveAllowGet = undefined;
×
NEW
869
      delete this._resolvers[packet.sequenceN];
×
NEW
870
      this._expectRefreshResponseForSequenceN = undefined;
×
UNCOV
871
      return;
×
872
    }
873

874
    // Call data resolver for sequence number
875
    if (packet.sequenceN in this._resolvers) {
77✔
876
      this._resolvers[packet.sequenceN](packet.payload);
57✔
877

878
      // Remove resolver
879
      delete this._resolvers[packet.sequenceN];
57✔
880
      this._expectRefreshResponseForSequenceN = undefined;
57✔
881
    }
882
  }
883

884
  /**
885
   * Disconnects from the device, use to
886
   * close the socket and exit gracefully.
887
   */
888
  disconnect() {
889
    if (!this._connected) {
92✔
890
      return;
60✔
891
    }
892

893
    debug('Disconnect');
32✔
894

895
    this._connected = false;
32✔
896
    this.device.parser.cipher.setSessionKey(null);
32✔
897

898
    // Clear timeouts
899
    clearTimeout(this._sendTimeout);
32✔
900
    clearTimeout(this._connectTimeout);
32✔
901
    clearTimeout(this._responseTimeout);
32✔
902
    clearInterval(this._pingPongInterval);
32✔
903
    clearTimeout(this._pingPongTimeout);
32✔
904

905
    if (this.client) {
32!
906
      this.client.destroy();
32✔
907
    }
908

909
    /**
910
     * Emitted when a socket is disconnected
911
     * from device. Not an exclusive event:
912
     * `error` and `disconnected` may be emitted
913
     * at the same time if, for example, the device
914
     * goes off the network.
915
     * @event TuyaDevice#disconnected
916
     */
917
    this.emit('disconnected');
32✔
918
  }
919

920
  /**
921
   * Returns current connection status to device.
922
   * @returns {Boolean}
923
   * (`true` if connected, `false` otherwise.)
924
   */
925
  isConnected() {
926
    return this._connected;
104✔
927
  }
928

929
  /**
930
   * @deprecated since v3.0.0. Will be removed in v4.0.0. Use find() instead.
931
   * @param {Object} options Options object
932
   * @returns {Promise<Boolean|Array>} Promise that resolves to `true` if device is found, `false` otherwise.
933
   */
934
  resolveId(options) {
935
    console.warn('resolveId() is deprecated since v4.0.0. Will be removed in v5.0.0. Use find() instead.');
4✔
936
    return this.find(options);
4✔
937
  }
938

939
  /**
940
   * Finds an ID or IP, depending on what's missing.
941
   * If you didn't pass an ID or IP to the constructor,
942
   * you must call this before anything else.
943
   * @param {Object} [options] Options object
944
   * @param {Boolean} [options.all]
945
   * true to return array of all found devices
946
   * @param {Number} [options.timeout=10]
947
   * how long, in seconds, to wait for device
948
   * to be resolved before timeout error is thrown
949
   * @example
950
   * tuya.find().then(() => console.log('ready!'))
951
   * @returns {Promise<Boolean|Array>}
952
   * true if ID/IP was found and device is ready to be used
953
   */
954
  find({timeout = 10, all = false} = {}) {
52✔
955
    if (isValidString(this.device.id) &&
24✔
956
        isValidString(this.device.ip)) {
957
      // Don't need to do anything
958
      debug('IP and ID are already both resolved.');
4✔
959
      return Promise.resolve(true);
4✔
960
    }
961

962
    // Create new listeners
963
    const listener = dgram.createSocket({type: 'udp4', reuseAddr: true});
20✔
964
    listener.bind(6666);
20✔
965

966
    const listenerEncrypted = dgram.createSocket({type: 'udp4', reuseAddr: true});
20✔
967
    listenerEncrypted.bind(6667);
20✔
968

969
    const broadcastHandler = (resolve, reject) => message => {
40✔
970
      debug('Received UDP message.');
16✔
971

972
      const parser = new MessageParser({key: UDP_KEY, version: this.device.version});
16✔
973

974
      let dataRes;
975
      try {
16✔
976
        dataRes = parser.parse(message)[0];
16✔
977
      } catch (error) {
978
        debug(error);
×
979
        reject(error);
×
980
      }
981

982
      debug('UDP data:');
16✔
983
      debug(dataRes);
16✔
984

985
      const thisID = dataRes.payload.gwId;
16✔
986
      const thisIP = dataRes.payload.ip;
16✔
987

988
      // Try auto determine power data - DP 19 on some 3.1/3.3 devices, DP 5 for some 3.1 devices
989
      const thisDPS = dataRes.payload.dps;
16✔
990
      if (thisDPS && typeof thisDPS[19] === 'undefined') {
16!
991
        this._dpRefreshIds = [4, 5, 6];
×
992
      } else {
993
        this._dpRefreshIds = [18, 19, 20];
16✔
994
      }
995

996
      // Add to array if it doesn't exist
997
      if (!this.foundDevices.some(e => (e.id === thisID && e.ip === thisIP))) {
16!
998
        this.foundDevices.push({id: thisID, ip: thisIP});
16✔
999
      }
1000

1001
      if (!all &&
16✔
1002
          (this.device.id === thisID || this.device.ip === thisIP) &&
1003
          dataRes.payload) {
1004
        // Add IP
1005
        this.device.ip = dataRes.payload.ip;
12✔
1006

1007
        // Add ID and gwID
1008
        this.device.id = dataRes.payload.gwId;
12✔
1009
        this.device.gwID = dataRes.payload.gwId;
12✔
1010

1011
        // Change product key if neccessary
1012
        this.device.productKey = dataRes.payload.productKey;
12✔
1013

1014
        // Change protocol version if necessary
1015
        if (this.device.version !== dataRes.payload.version) {
12!
1016
          this.device.version = dataRes.payload.version;
12✔
1017

1018
          // Update the parser
1019
          this.device.parser = new MessageParser({
12✔
1020
            key: this.device.key,
1021
            version: this.device.version
1022
          });
1023
        }
1024

1025
        // Cleanup
1026
        listener.close();
12✔
1027
        listener.removeAllListeners();
12✔
1028
        listenerEncrypted.close();
12✔
1029
        listenerEncrypted.removeAllListeners();
12✔
1030
        resolve(true);
12✔
1031
      }
1032
    };
1033

1034
    debug(`Finding missing IP ${this.device.ip} or ID ${this.device.id}`);
20✔
1035

1036
    // Find IP for device
1037
    return pTimeout(new Promise((resolve, reject) => { // Timeout
20✔
1038
      listener.on('message', broadcastHandler(resolve, reject));
20✔
1039

1040
      listener.on('error', err => {
20✔
1041
        reject(err);
×
1042
      });
1043

1044
      listenerEncrypted.on('message', broadcastHandler(resolve, reject));
20✔
1045

1046
      listenerEncrypted.on('error', err => {
20✔
1047
        reject(err);
×
1048
      });
1049
    }), timeout * 1000, () => {
1050
      // Have to do this so we exit cleanly
1051
      listener.close();
8✔
1052
      listener.removeAllListeners();
8✔
1053
      listenerEncrypted.close();
8✔
1054
      listenerEncrypted.removeAllListeners();
8✔
1055

1056
      // Return all devices
1057
      if (all) {
8✔
1058
        return this.foundDevices;
4✔
1059
      }
1060

1061
      // Otherwise throw error
1062
      throw new Error('find() timed out. Is the device powered on and the ID or IP correct?');
4✔
1063
    });
1064
  }
1065

1066
  /**
1067
   * Toggles a boolean property.
1068
   * @param {Number} [property=1] property to toggle
1069
   * @returns {Promise<Boolean>} the resulting state
1070
   */
1071
  async toggle(property = '1') {
4✔
1072
    property = property.toString();
4✔
1073

1074
    // Get status
1075
    const status = await this.get({dps: property});
4✔
1076

1077
    // Set to opposite
1078
    await this.set({set: !status, dps: property});
4✔
1079

1080
    // Return new status
1081
    return this.get({dps: property});
4✔
1082
  }
1083
}
1084

1085
module.exports = TuyaDevice;
12✔
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

© 2025 Coveralls, Inc