tuyapi icon indicating copy to clipboard operation
tuyapi copied to clipboard

Implement 3.5 protocol

Open Apollon77 opened this issue 2 years ago • 84 comments

There seems to be new devices with a new format at least on discovery.

  • see https://github.com/jasonacox/tinytuya/issues/247
  • see https://github.com/jasonacox/tinytuya/pull/261
  • see https://github.com/jasonacox/tinytuya/discussions/260

Apollon77 avatar Feb 28 '23 15:02 Apollon77

Are you going to be able to update to 3.5? I hope so!

nospam2k avatar Jul 30 '24 20:07 nospam2k

@nospam2k I have no such device, sooo right now not really on my list ... (and even if time is another topic). But happy to get a PR :-)

Apollon77 avatar Jul 31 '24 08:07 Apollon77

From looking at Protocol notes from tinytuya as Apollon77 has posted, I have been able to convert the test python code to node.js and return a packet from the 3.5 device, I am going to work on figuring out how to implement it into tuyapi but I'm not sure exactly what I'm doing as I'm not familiar with the tuyapi code. Any help would be appreciated.

const net = require('net');
const crypto = require('crypto');

const ip = '192.168.x.xxx'; // Add the correct IP address
const key = Buffer.from('xxxxxxxxxxxxxx', 'utf-8'); // Add local key
var stime;

for (let i = 0; i < 2; i++) {
    const client = new net.Socket();

    client.setTimeout(5000);

    client.connect(6668, ip, () => {
        console.log('connected!');

        stime = Date.now();
        const localNonce = Buffer.from('0123456789abcdef', 'utf-8'); // not-so-random random key
        const localIV = localNonce.slice(0, 12); // not-so-random random iv

        const pkt = Buffer.alloc(18);
        pkt.writeUInt32BE(0x6699, 0);
        pkt.writeUInt16BE(0, 4);
        pkt.writeUInt32BE(1, 6);
        pkt.writeUInt32BE(3, 10);
        pkt.writeUInt32BE(localNonce.length + localIV.length + 16, 14);

        const cipher = crypto.createCipheriv('aes-128-gcm', key, localIV);
        cipher.setAAD(pkt.slice(4));
        const encrypted = Buffer.concat([cipher.update(localNonce), cipher.final()]);
        const tag = cipher.getAuthTag();

        const message = Buffer.concat([pkt, localIV, encrypted, tag, Buffer.from([0x00, 0x00, 0x99, 0x66])]);

        client.write(message);
    });

    client.on('data', (data) => {
        console.log('data:', data, 'in', (Date.now() - stime) / 1000);
        client.destroy();
    });

    client.on('timeout', () => {
        console.log('socket timeout');
        client.destroy();
    });

    client.on('error', (err) => {
        console.log('socket error:', err.message);
        client.destroy();
    });

    client.on('close', () => {
        console.log('connection closed');
    });
}

nospam2k avatar Jul 31 '24 14:07 nospam2k

Ok, so this in your connect callback is kind if the handshake? And then data arrive? are the data then plain text or also encrypted?

So the code you have here should go into https://github.com/codetheweb/tuyapi/blob/master/index.js#L668 ... so like another "if" with 3.5 after the 3.4 if

Apollon77 avatar Jul 31 '24 16:07 Apollon77

@Apollon77 I really don't know what I'm doing but I THINK I've worked out an encrypt and decrypt from digging through tinytuya. I'll include my return at the end. If you have time to put in some more info I'll be glad to test things. I'm not sure about implementation in your above reply because I really don't know anything about the protocol. Right now, I've just been trying to isolate how the message is sent and received. Thanks for any help you can give. Here is the updated code:

const net = require('net');
const crypto = require('crypto');

const ip = '<ip address>'; // Add the correct IP address
const key = Buffer.from('<local key>', 'utf-8');
var stime;

for (let i = 0; i < 2; i++) {
    const client = new net.Socket();

    client.setTimeout(5000);

    client.connect(6668, ip, () => {
        console.log('connected!');

        stime = Date.now();
        const localNonce = Buffer.from('0123456789abcdef', 'utf-8'); // not-so-random random key
        const localIV = localNonce.slice(0, 12); // not-so-random random iv

        const pkt = Buffer.alloc(18);
        pkt.writeUInt32BE(0x6699, 0);
        pkt.writeUInt16BE(0, 4);
        pkt.writeUInt32BE(1, 6);
        pkt.writeUInt32BE(3, 10);
        pkt.writeUInt32BE(localNonce.length + localIV.length + 16, 14);

        const cipher = crypto.createCipheriv('aes-128-gcm', key, localIV);
        cipher.setAAD(pkt.slice(4));
        const encrypted = Buffer.concat([cipher.update(localNonce), cipher.final()]);
        const tag = cipher.getAuthTag();

        const message = Buffer.concat([pkt, localIV, encrypted, tag, Buffer.from([0x00, 0x00, 0x99, 0x66])]);

        client.write(message);
    });

    client.on('data', (data) => {
        //console.log('data:', data.toString('hex'));
        //console.log('prefix:', data.slice(0, 4).toString('hex'));
        //console.log('unknown:', data.slice(4, 6).toString('hex'));
        //console.log('sequence:', data.slice(6, 10).toString('hex'));
        //console.log('command:', data.slice(10, 14).toString('hex'));
        //console.log('length:', data.slice(14, 18).toString('hex'));
        //console.log('iv:', data.slice(18, 30).toString('hex'));
        //console.log('payload:', data.slice(18 + 12, plen + 18 - 16).toString('hex'));
        //console.log('tag:', data.slice(plen + 18 - 16, plen + 18 - 16 + 16).toString('hex'));
        //console.log('footer:', data.slice(plen + 18).toString('hex'));
        const header = data.slice(4, 18)
        const prefix = data.slice(0, 4);
        const unknown = data.slice(4, 6);
        const sequence = data.slice(6, 10);
        const command = data.slice(10, 14);
        const length = data.slice(14, 18);
        const iv = data.slice(18, 30);
        const plen = parseInt(data.slice(14, 18).toString('hex'), 16);
        const payload = data.slice(18 + 12, plen + 2); // 18 - 16
        const tag = data.slice(plen + 2, plen + 18); // 18 - 16
        const footer = data.slice(plen + 18);

        client.destroy();

        const decipher = crypto.createDecipheriv('aes-256-gcm', key.toString('hex'), iv.toString('hex'));
        decipher.setAAD(header);
        decipher.setAuthTag(tag);
        let raw = decipher.update(payload, 'binary', 'utf-8');
        console.log(raw);
    });

    client.on('timeout', () => {
        console.log('socket timeout');
        client.destroy();
    });

    client.on('error', (err) => {
        console.log('socket error:', err.message);
        client.destroy();
    });

    client.on('close', () => {
        console.log('connection closed');
    });
}
connected!
connected!
�����(\<}�5W(�M�'��W�y�� ��}���z�_�
                                   �d��v{ey�f
connection closed

�?�3&3݄~>�O8����놡�Ý*����7�:�����:��J��{4
connection closed

nospam2k avatar Aug 02 '24 00:08 nospam2k

Looking more, it looks like I need to update these, but I'm not sure exactly how to get the missing parameters. Options seems to be the payload???:

cipher.js
  /**
   * Encrypt data for protocol 3.4
   * @param {Object} options Options for encryption
   * @param {String} options.data data to encrypt
   * @param {Boolean} [options.base64=true] `true` to return result in Base64
   * @returns {Buffer|String} returns Buffer unless options.base64 is true
   */
  _encrypt34(options) {
    const cipher = crypto.createCipheriv('aes-128-ecb', this.getKey(), null);
    cipher.setAutoPadding(false);
    const encrypted = cipher.update(options.data);
    cipher.final();

    // Default base64 enable TODO: check if this is needed?
    // if (options.base64 === false) {
    //   return Buffer.from(encrypted, 'base64');
    // }

    return encrypted;
  }

  /**
   * Decrypts data for protocol 3.4
   * @param {String|Buffer} data to decrypt
   * @returns {Object|String}
   * returns object if data is JSON, else returns string
   */
  _decrypt34(data) {
    let result;
    try {
      const decipher = crypto.createDecipheriv('aes-128-ecb', this.getKey(), null);
      decipher.setAutoPadding(false);
      result = decipher.update(data);
      decipher.final();
      // Remove padding
      result = result.slice(0, (result.length - result[result.length - 1]));
    } catch (_) {
      throw new Error('Decrypt failed');
    }

    // Try to parse data as JSON,
    // otherwise return as string.
    // 3.4 protocol
    // {"protocol":4,"t":1632405905,"data":{"dps":{"101":true},"cid":"00123456789abcde"}}
    try {
      if (result.indexOf(this.version) === 0) {
        result = result.slice(15);
      }

      const res = JSON.parse(result);
      if ('data' in res) {
        const resData = res.data;
        resData.t = res.t;
        return resData; // Or res.data // for compatibility with tuya-mqtt
      }

      return res;
    } catch (_) {
      return result;
    }
  }

message-parser.js
_encode34(options) {
    let payload = options.data;

    if (options.commandByte !== CommandType.DP_QUERY &&
        options.commandByte !== CommandType.HEART_BEAT &&
        options.commandByte !== CommandType.DP_QUERY_NEW &&
        options.commandByte !== CommandType.SESS_KEY_NEG_START &&
        options.commandByte !== CommandType.SESS_KEY_NEG_FINISH &&
        options.commandByte !== CommandType.DP_REFRESH) {
      // Add 3.4 header
      // check this: mqc_very_pcmcd_mcd(int a1, unsigned int a2)
      const buffer = Buffer.alloc(payload.length + 15);
      Buffer.from('3.4').copy(buffer, 0);
      payload.copy(buffer, 15);
      payload = buffer;
    }

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

    payload = this.cipher.encrypt({
      data: payload
    });

    payload = Buffer.from(payload);

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

    // Add prefix, command, and length
    buffer.writeUInt32BE(0x000055AA, 0);
    buffer.writeUInt32BE(options.commandByte, 8);
    buffer.writeUInt32BE(payload.length + 0x24, 12);

    if (options.sequenceN) {
      buffer.writeUInt32BE(options.sequenceN, 4);
    }

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

    buffer.writeUInt32BE(0x0000AA55, payload.length + 48);
    return buffer;
  }

nospam2k avatar Aug 02 '24 01:08 nospam2k

Yes I also think you need to add a new method "_encrypt35" and "_decrypt35" options.data and pot options.base64 is the values on encrypt and on decrypt also comparable.

In fact try to also log your result after decryption form what you get as data in as hex too then we might see details

Apollon77 avatar Aug 02 '24 10:08 Apollon77

Sorry for the delay

This is console.log(raw.toString('hex'): <Buffer 4b 51 45 29 69 60 67 47 4f 56 75 39 47 5d 6f 41>

nospam2k avatar Aug 04 '24 22:08 nospam2k

@Apollon77 Ok, I've gotten a long way on this but I'm stuck. It seems like I've got the negotiation of the key working but the packet for getting query doesn't seem to be coming back. I set up a test.js to make sure the packet is encrypting and decrypting with the new session key and it is.

Decrypted text: Id":"xxxxxxxxxxxxxxxxxxxxxxxx","devId":"xxxxxxxxxxxxxxxxxxxxxxxx","t":"1723094767","dps":{},"uid":"xxxxxxxxxxxxxxxxxxxxxxxx"}

Here is my getdev.js file:

const TuyAPI = require('tuyapi');

const device = new TuyAPI({
  id: 'xxxxxxxxxxxxxxxxxx',
  key: 'xxxxxxxxxxxxxxxxxx',
  ip: '192.168.2.187',
  version: '3.5',
  issueGetOnConnect: false});

(async () => {
  await device.find();

  await device.connect();

  let status = await device.get();

  console.log(`Current status: ${status}.`);

  await device.set({set: !status});

  status = await device.get();

  console.log(`New status: ${status}.`);

  device.disconnect();
})();

Here is a debug:

 TuyAPI IP and ID are already both resolved. +0ms
  TuyAPI Connecting to 192.168.2.187... +2ms
  TuyAPI Socket connected. +94ms
  TuyAPI Protocol 3.4, 3.5: Negotiate Session Key - Send Msg 0x03 +2ms
  TuyAPI Received data: 000066990000000047010000000400000050a66ff15d05b84428b5ccf97be39affa4acc15d15929f11120fea57f332702a2b5fb0c8368d2eef6dfab8da055e2084ce8da5774fa91d3de7dc8189679801b2daa04fcf8f05e5694256cb08f97da2197b00009966 +110ms
  TuyAPI Parsed: +2ms
  TuyAPI {
  TuyAPI   payload: <Buffer 35 38 39 39 32 34 35 63 37 38 64 30 63 37 34 32 3a bf d5 25 b5 63 5d e6 2e c9 57 35 b0 89 d2 f1 e4 22 f9 6e e8 8e 04 9b 13 21 05 3b bf 3a 31 a2>,
  TuyAPI   leftover: false,
  TuyAPI   commandByte: 4,
  TuyAPI   sequenceN: <Buffer 00 00 47 01>
  TuyAPI } +0ms
  TuyAPI Protocol 3.5: Local Random Key: a46f0c381283c50d35f5d5bc971f19b5 +2ms
  TuyAPI Protocol 3.5: Remote Random Key: 34323abfd525b5635de62ec9 +0ms
  TuyAPI Protocol 3.4, 3.5: Session Key: 793a236b01ced4deb36d4ba9011a34c1 +1ms
  TuyAPI Protocol 3.4, 3.5: Initialization done +0ms
  TuyAPI GET Payload: +1ms
  TuyAPI {
  TuyAPI   gwId: 'xxxxxxxxxxxxxxxxxxxxxxxx',
  TuyAPI   devId: 'xxxxxxxxxxxxxxxxxxxxxxxxx',
  TuyAPI   t: '1723095589',
  TuyAPI   dps: {},
  TuyAPI   uid: 'xxxxxxxxxxxxxxxxxxxx'
  TuyAPI } +0ms
  TuyAPI Socket closed: 192.168.2.187 +98ms
  TuyAPI Disconnect +0ms

nospam2k avatar Aug 08 '24 05:08 nospam2k

Where exactly happens this part? Because I can not see your other log messages? or does it mean it hangs on connect? or on the first device get? Did you tried to get with a list of datapoint ids instead of "all"?

But hm ... the log also seems incomplete ... because should after the "get payload" not also it log the binary data from the encoded packet that is sent? Instead connection gets closed

Apollon77 avatar Aug 08 '24 07:08 Apollon77

index.js

 async get(options = {}) {
    const payload = {
      gwId: this.device.gwID,
      devId: this.device.id,
      t: Math.round(new Date().getTime() / 1000).toString(),
      dps: {},
      uid: this.device.id
    };

    if (options.cid) {
      payload.cid = options.cid;
    }

    const commandByte = this.device.version === '3.4' || this.device.version === '3.5' ? CommandType.DP_QUERY_NEW : CommandType.DP_QUERY;

    // Create byte buffer
    const buffer = this.device.parser.encode({
      data: payload,
      commandByte,
      sequenceN: ++this._currentSequenceN
    });

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

      data = await this._send(buffer); // <- this never returns.

nospam2k avatar Aug 08 '24 14:08 nospam2k

Okmthen this means that the devcie closes the connection ... try to add 3.5 to that "exception list so that you go into https://github.com/codetheweb/tuyapi/blob/master/index.js#L168 case ... does that work?

Apollon77 avatar Aug 08 '24 15:08 Apollon77

@Apollon77 Ok, I messed with how the session key is chopped out of the packet and now it looks like it's better. It isn't returning the query but I'm getting some communication and it never exits. I'm getting this in debug:

TuyAPI IP and ID are already both resolved. +0ms
  TuyAPI Connecting to 192.168.2.187... +2ms
  TuyAPI Socket connected. +43ms
  TuyAPI Protocol 3.4, 3.5: Negotiate Session Key - Send Msg 0x03 +1ms
  TuyAPI Received data: 00006699000000004a290000000400000050edea2e4207e6c194190cbf50b2fd1a443b25812fcced1ce5dfd4933d873228b896a4846fef0d5399dc0eb0d9c461a86c673a8715fd2f72b7004fc5d304ac01582b2e3ee5e61ee3ae0a6d180ce120eba100009966 +122ms
  TuyAPI Parsed: +1ms
  TuyAPI {
  TuyAPI   payload: <Buffer 63 62 31 61 34 31 33 65 66 65 31 61 61 37 35 39 e8 d7 8d a4 92 87 e2 fa f8 21 b6 d2 5f 25 60 60 c7 57 9a 6b bc 5d 45 70 1d 3d 87 b7 02 07 f6 f8>,
  TuyAPI   leftover: false,
  TuyAPI   commandByte: 4,
  TuyAPI   sequenceN: <Buffer 00 00 4a 29>
  TuyAPI } +0ms
  TuyAPI Protocol 3.4, 3.5: Local Random Key: d1c72259f0997d66628f824263ce74cf +3ms
  TuyAPI Protocol 3.4, 3.5: Remote Random Key: 63623161343133656665316161373539 +0ms
  TuyAPI Protocol 3.4, 3.5: Session Key: 5bc206d402c0eab3df9403ff94fc6c82 +1ms
  TuyAPI Protocol 3.4, 3.5: Initialization done +0ms
  TuyAPI GET Payload: +1ms
  TuyAPI {
  TuyAPI   gwId: 'eb840f68d95e7cbc92ivmj',
  TuyAPI   devId: 'eb840f68d95e7cbc92ivmj',
  TuyAPI   t: '1723131283',
  TuyAPI   dps: {},
  TuyAPI   uid: 'eb840f68d95e7cbc92ivmj'
  TuyAPI } +0ms
  TuyAPI Pinging 192.168.2.187 +10s
  TuyAPI Received data: 00006699000000004a2a000000090000002056b841f17444ab11ee2a1ca28b184b079c16aef0f66b65604714b02b62a3c81e00009966 +118ms
  TuyAPI Parsed: +1ms
  TuyAPI {
  TuyAPI   payload: '\x00\x00\x00\x00J*\x00\x00\x00\t\x00\x00\x00 V�A�tD�\x11�*\x1C��\x18K\x07�\x16���ke`G\x14�+b��\x1E',
  TuyAPI   leftover: false,
  TuyAPI   commandByte: 9,
  TuyAPI   sequenceN: <Buffer 00 00 4a 2a>
  TuyAPI } +0ms
  TuyAPI Pong from 192.168.2.187 +1ms
  TuyAPI Pinging 192.168.2.187 +10s
  TuyAPI Received data: 00006699000000004a2b0000000900000020c424fe4285fe82a4a22d1cf527b260ff84971ac2ea145564589b63a998a242e600009966 +51ms
  TuyAPI Parsed: +0ms
  TuyAPI {
  TuyAPI   payload: "\x00\x00\x00\x00J+\x00\x00\x00\t\x00\x00\x00 �$�B�����-\x1C�'�`���\x1A��\x14UdX�c���B�",
  TuyAPI   leftover: false,
  TuyAPI   commandByte: 9,
  TuyAPI   sequenceN: <Buffer 00 00 4a 2b>
  TuyAPI } +0ms
  TuyAPI Pong from 192.168.2.187 +0ms
  TuyAPI Pinging 192.168.2.187 +10s
  TuyAPI Received data: 00006699000000004a2c0000000900000020ba6e2036c6b03a4c2819b91392e892406e36a3631477d1b64ec10720e2ecedac00009966 +85ms
  TuyAPI Parsed: +1ms
  TuyAPI {
  TuyAPI   payload: '\x00\x00\x00\x00J,\x00\x00\x00\t\x00\x00\x00 �n 6ư:L(\x19�\x13��@n6�c\x14wѶN�\x07 ����',
  TuyAPI   leftover: false,
  TuyAPI   commandByte: 9,
  TuyAPI   sequenceN: <Buffer 00 00 4a 2c>
  TuyAPI } +0ms
  TuyAPI Pong from 192.168.2.187 +0ms

nospam2k avatar Aug 08 '24 15:08 nospam2k

But ok that binary response payloads look like that something is now still wrong with decryption ... but great progress, awesome.

PS: One info: I'm still here whole next week, but then on vacation 19.8.-28.8. without access to things ... so depending on when it would be ready it could then have a break because of me absent to get a published version (and maybe also makes sense to give it some days before I can not release fixes or such) :) But keep the great progress!

Apollon77 avatar Aug 08 '24 16:08 Apollon77

Thx

nospam2k avatar Aug 08 '24 18:08 nospam2k

Ok, I'm nearly there (thanks to the help of @uzlonewolf !!) I'm not getting a return of the query but here is the debug which shows I received the packet. The program hangs at this point.

  TuyAPI Connecting to 192.168.2.187... +0ms
  TuyAPI Socket connected. +95ms
  TuyAPI Protocol 3.5: Negotiate Session Key - Send Msg 0x03 +2ms
  TuyAPI Received data: 0000669900000000ed650000000400000050ba56d857c587135e7495e13c72bca670be3f077cf6f52b4e303785c9ba798b7d20b19f5f3002081936dc0d28355d4f9cb2b5dcd08caa086e724477953e695ee01f2de9a4a0a56483f45fd578193e6d1f00009966 +216ms
  TuyAPI Parsed: +2ms
  TuyAPI {
  TuyAPI   payload: <Buffer 65 38 65 34 30 39 61 30 31 31 66 39 33 61 37 66 f8 3f 7e 76 97 f1 98 38 85 44 e8 ce 4c 40 a8 9e ab 43 06 eb 88 a1 b5 cf de 41 97 16 7e 11 61 d0>,
  TuyAPI   leftover: false,
  TuyAPI   commandByte: 4,
  TuyAPI   sequenceN: <Buffer 00 00 ed 65>
  TuyAPI } +0ms
  TuyAPI Protocol 3.4: Local Random Key: 3a2d998bb7abfc7e52d38e7908e3c97d +2ms
  TuyAPI Protocol 3.4: Remote Random Key: 65386534303961303131663933613766 +0ms
  TuyAPI Protocol 3.4: Session Key: a64d6c0852818d65fe93a3c60b45acb2 +1ms
  TuyAPI Protocol 3.4: Initialization done +0ms
  TuyAPI GET Payload: +1ms
  TuyAPI {
  TuyAPI   gwId: 'eb840f68d95e7cbc92ivmj',
  TuyAPI   devId: 'eb840f68d95e7cbc92ivmj',
  TuyAPI   t: '1723360392',
  TuyAPI   dps: {},
  TuyAPI   uid: 'eb840f68d95e7cbc92ivmj'
  TuyAPI } +0ms
  TuyAPI Received data: 0000669900000000ed66000000100000009c9a6db21d1c47d725b9f00d456f26e751aeb5842a561e57db8bc3181bafd9388a932ff964dcdd63d53e6abae0d2f82c097f181e74997874843721f5ef08ab25d78b6326ae7f355b56f98b069d8530a35c477cca006b73566d941f05073594bc688853dbdfcb8fa8597069bb47bff8e332119dc774205f3f9e0e3d70a930981ddea708971dee015bd01eff49dac6c1ea7fb0ff26c43b79b819525792b200009966 +403ms
  TuyAPI Parsed: +1ms
  TuyAPI {
  TuyAPI   payload: {
  TuyAPI     dps: {
  TuyAPI       '20': false,
  TuyAPI       '21': 'white',
  TuyAPI       '22': 1000,
  TuyAPI       '23': 0,
  TuyAPI       '24': '000003e803e8',
  TuyAPI       '25': '000e0d0000000000000000c80000',
  TuyAPI       '26': 0,
  TuyAPI       '34': false
  TuyAPI     }
  TuyAPI   },
  TuyAPI   leftover: false,
  TuyAPI   commandByte: 16,
  TuyAPI   sequenceN: <Buffer 00 00 ed 66>
  TuyAPI } +0ms
  TuyAPI Received DATA packet +0ms
  TuyAPI data: 16 : {"dps":{"20":false,"21":"white","22":1000,"23":0,"24":"000003e803e8","25":"000e0d0000000000000000c80000","26":0,"34":false}} +0ms

nospam2k avatar Aug 11 '24 07:08 nospam2k

Cooool. From where the log comes in the last line? "data:..."?

Apollon77 avatar Aug 11 '24 07:08 Apollon77

Ps: could it be that now #634 joins the game? Can you try the change that was proposed there. If yes could you add it for 3.4 and 3.5? Then we have that in too

Apollon77 avatar Aug 11 '24 07:08 Apollon77

I don't thinks this applies. in _packetHandler, this._currentSequenceN = 1 already. The difficulty is I'm chasing promises and on.data emit etc. So where does emit('data') end up?

      this.emit('data', packet.payload, packet.commandByte, packet.sequenceN);

this is in index.js _packetHandler.

nospam2k avatar Aug 11 '24 22:08 nospam2k

Ok, a little more playing and it seems that neither set nor get are returning. Here is some test code:

const TuyAPI = require('tuyapi');

const device = new TuyAPI({
  id: '<id>',
  key: '<key>',
  ip: '<ip>',
  version: '3.5'});

let stateHasChanged = false;

// Find device on network
//device.find().then(() => { <<<<< I haven't messed with find yet
// Connect to device
  device.connect();
//});

// Add event listeners
device.on('connected', () => {
  console.log('Connected to device!');
  //device.get();
  device.set({
      dps: 20,
      set: true
  });
});

device.on('disconnected', () => {
  console.log('Disconnected from device.');
});

device.on('error', error => {
  console.log('Error!', error);
});

device.on('data', data => {
  console.log('Data from device:', data);
});

// Disconnect after 10 seconds
setTimeout(() => { device.disconnect(); }, 10000);

The light turns on, but it never returns and then dies after setTimeout expires. device.on('data') is returning data correctly.

nospam2k avatar Aug 11 '24 23:08 nospam2k

More info from the end of _packetHandler with some console.logging:

    console.log('packet.sequenceN', packet.sequenceN);
    console.log('this._resolvers', this._resolvers);

    // Call data resolver for sequence number
    if (packet.sequenceN in this._resolvers) {
      this._resolvers[packet.sequenceN](packet.payload);

      // Remove resolver
      delete this._resolvers[packet.sequenceN];
      this._expectRefreshResponseForSequenceN = undefined;
    }
packet.sequenceN <Buffer 00 00 d8 ad>
this._resolvers { '5': [Function (anonymous)], '6': [Function (anonymous)] }
Data from device: {
  dps: {
    '20': false,
    '21': 'white',
    '22': 1000,
    '23': 0,
    '24': '000003e803e8',
    '25': '000e0d0000000000000000c80000',
    '26': 0,
    '34': false
  }
}
packet.sequenceN <Buffer 00 00 d8 ae>
this._resolvers { '5': [Function (anonymous)], '6': [Function (anonymous)] }

Looks like packet.sequenceN may be an issue coming from the device?

nospam2k avatar Aug 11 '24 23:08 nospam2k

Thats why I asked for the sequence thing. The responses are mapped via the expectedsequence Number to return when I remember correctly. When you see here sequence number coming back is 00 00 d8 ad which do not match to 5 or 6

Apollon77 avatar Aug 12 '24 08:08 Apollon77

Ok, so what I think is happening is here:

    // Call data resolver for sequence number
    if (packet.sequenceN in this._resolvers) {
      this._resolvers[packet.sequenceN](packet.payload);

      // Remove resolver
      delete this._resolvers[packet.sequenceN];
      this._expectRefreshResponseForSequenceN = undefined;
    }

I tried

this._currentSequenceN = packet.sequenceN - 1;

but it didn't work.

I only have 3.5 devices so I will need confirmation, but I believe <3.4 devices return a sequence the same as the query packet. 3.4 devices are sending +1 (why the sequence - 1 is necessary) but 3.5 devices are a totally different sequence. Either that or I don't understand the sequence handling at all. So if 3.5 device and client sequence numbers are not related, this._resolvers cannot work. Somehow the sequence number has to be from the device or a completely different way of resolving is necessary for 3.5. I willingly admit I now have enough information to be insanely wrong as my knowledge of Tuya protocol was 0 and my knowledge of node is not much above web based javascript.

UPDATE: I thoroughly read through #634 and it seem 3.4 and 3.5 are doing the same thing but I cannot understand how the recommended fix (above) worked as the resolve is already set so changing this._currentSequenceN only changes the pointer but the resolve array still contains the client sequence number.

nospam2k avatar Aug 12 '24 15:08 nospam2k

That could be. When I check your logs then the sequence seems to be increasing ... What about just remembering the last received sequence and then use this +1 for the next expected package? (for 3.5 packages)

Apollon77 avatar Aug 12 '24 15:08 Apollon77

Where would I do that so this._resolvers is correct? I'm including my latest debug with my console logs.

david@bkup my_tuya % node tuyapi.js
  TuyAPI Connecting to 192.168.2.187... +0ms
  TuyAPI Socket connected. +125ms
  TuyAPI Protocol 3.5: Negotiate Session Key - Send Msg 0x03 +2ms
  TuyAPI Received data: 0000669900000000708100000004000000507e156992640142165ed6feb037a2d5da9b5fc1670f31fd8f81be2e8de530646fc1471edc2d56e404f854648016d0ff19450003bf46ad7b8f9b745cdacde58f35b08d926f753e839857f8c1f39e8f003500009966 +96ms
  TuyAPI Parsed: +1ms
  TuyAPI {
  TuyAPI   payload: <Buffer 63 31 66 61 36 33 66 30 39 62 63 31 31 65 36 34 b0 82 d6 98 09 c4 63 15 95 dd 65 b6 eb 12 74 da f2 92 c6 3a 57 d1 da e5 9f be 1b 31 05 74 65 59>,
  TuyAPI   leftover: false,
  TuyAPI   commandByte: 4,
  TuyAPI   sequenceN: <Buffer 00 00 70 81>
  TuyAPI } +0ms
  TuyAPI Protocol 3.4: Local Random Key: 60061fd06df3a396e1c245d50476b8ca +3ms
  TuyAPI Protocol 3.4: Remote Random Key: 63316661363366303962633131653634 +0ms
  TuyAPI Protocol 3.4, 3.5: Session Key: fe1b797875755c607b04bb5aab44bfd8 +1ms
  TuyAPI Protocol 3.4, 3.5: Initialization done +0ms
  TuyAPI GET Payload: +0ms
  TuyAPI {
  TuyAPI   gwId: 'eb840f68d95e7cbc92ivmj',
  TuyAPI   devId: 'eb840f68d95e7cbc92ivmj',
  TuyAPI   t: '1723477133',
  TuyAPI   dps: {},
  TuyAPI   uid: 'eb840f68d95e7cbc92ivmj'
  TuyAPI } +0ms
index 468 Resolving sequence number: 3
  TuyAPI Received data: 00006699000000007082000000100000009c9d3c3309d6323dd51e8c3ebfd51daafdc6ff780e31da0601eb971480c0c6c84687c74b278136a9cde85177e20b776bb660edd5102bda9297a8f2553674145aedb02701946b5aa8340d26fb93008b0aa6b43e7d783bae3004070150e0b6b37855d6278956d4fdcd1bbc7bc337c8f8cfcaf7fa62a1c07c814a219fa2ea39fe240f0e1bafc297af7e82b4b4e6688453772ac5d47107b15f71ba7718c6cb00009966 +195ms
  TuyAPI Parsed: +1ms
  TuyAPI {
  TuyAPI   payload: {
  TuyAPI     dps: {
  TuyAPI       '20': false,
  TuyAPI       '21': 'white',
  TuyAPI       '22': 1000,
  TuyAPI       '23': 0,
  TuyAPI       '24': '000003e803e8',
  TuyAPI       '25': '000e0d0000000000000000c80000',
  TuyAPI       '26': 0,
  TuyAPI       '34': false
  TuyAPI     }
  TuyAPI   },
  TuyAPI   leftover: false,
  TuyAPI   commandByte: 16,
  TuyAPI   sequenceN: <Buffer 00 00 70 82>
  TuyAPI } +0ms
  TuyAPI Received DATA packet +1ms
  TuyAPI data: 16 : {"dps":{"20":false,"21":"white","22":1000,"23":0,"24":"000003e803e8","25":"000e0d0000000000000000c80000","26":0,"34":false}} +0ms
index 910 packet.sequenceN <Buffer 00 00 70 82>
index 911 this._currentSequenceN 3
index 912 this._resolvers { '3': [Function (anonymous)] }

My main confusion is I don't really understand the data resolver. If I force:

      this._resolvers[packet.sequenceN](packet.payload);

      // Remove resolver
      delete this._resolvers[packet.sequenceN];
      this._expectRefreshResponseForSequenceN = undefined;

I get back: Current status: undefined.

What exactly should happen during _packetHandler for a get?

nospam2k avatar Aug 12 '24 15:08 nospam2k

I would do a new class instance variable "previouslyReceivedCommandNo" and whenever you receive a package set this number there. then when you send simply use it when setting the number for the expected resolver instead of this "packet.sequenceN".

It seems that 3.5 uses separated counters where the older versions synced them

Apollon77 avatar Aug 12 '24 16:08 Apollon77

What about just remembering the last received sequence and then use this +1 for the next expected package?

My 3.5 devices send 2 responses: an async STATUS (command 8) followed by the result for the sent command (CONTROL_NEW command 13). So my suggestion would be any sequence greater than the last plus match the command.

uzlonewolf avatar Aug 12 '24 16:08 uzlonewolf

maybe try to add some logging in the method that handles that received data ... because hard to see whats the reason here

Apollon77 avatar Aug 13 '24 12:08 Apollon77

Thank you very much for all your efforts so far! Interesting question is how to continue ... Seems I need to try to get a 3.5 device myself to debug based on your awesome work - but this will not happen before beginning of september due to my vacation.

On how to do a PR: Simply way to go into GitHub and select e.g,. the index.ts file and hit the pencil icon upper right. Then edit the relevant content (or copy your version in) then scroll down and add details and the button. This creates a fork and branch with this one change. On next page klick create Pull Request. Then in the top line you see tuyapi_master and the branch name of your branch ... second one as "link" ... klick it and you are on this branch ... edit the other files via pencil and these changes are all added to this one PR

Apollon77 avatar Aug 14 '24 13:08 Apollon77

Ok, I think I resolved it, but my fix is ugly. What do you think? This issue is 3.5 payload is not length 0 because of iv and tag. Here is the code with my changes:

    if (
      (
        packet.commandByte === CommandType.CONTROL ||
        packet.commandByte === CommandType.CONTROL_NEW
      ) && packet.payload.toString() === '') {

      if(this.device.version === '3.5')
      {
        // This is the ugly part. Adding an incremented resolver and deleting the current one.
        this._resolvers[(parseInt(packet.sequenceN) + 1).toString()] = this._resolvers[packet.sequenceN.toString()];
        delete this._resolvers[packet.sequenceN.toString()];
      }
    
      debug('Got SET ack.');
      return;
    }

Also needed to update getPayload because 3.5 payload is never 0 length with iv and tag:

  /**
   * Attempts to decode a given payload into
   * an object or string.
   * @param {Buffer} data to decode
   * @returns {Object|String}
   * object if payload is JSON, otherwise string
   */
  getPayload(data) {
    if (data.length === 0) {
      return false;
    }

    // Try to decrypt data first.
    try {
      if (!this.cipher) {
        throw new Error('Missing key or version in constructor.');
      }

      data = this.cipher.decrypt(data);
    } catch (_) {
      data = data.toString('utf8');
    }

    // Incoming data isn't 0 because of iv and tag so check size after
    if(this.version === '3.5')
    {
      if(data.length === 0)
        return false;
    }

    // Try to parse data as JSON.
    // If error, return as string.
    if (typeof data === 'string') {
      try {
        data = JSON.parse(data);
      } catch (_) { }
    }

    return data;
  }

nospam2k avatar Aug 14 '24 15:08 nospam2k