diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7133def..a81d578 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,13 +1,17 @@ name: Lint -on: push +on: + push: + branches: + - '*' + pull_request: {} jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Install and cache dependencies uses: bahmutov/npm-install@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 88d2610..dda6d7e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,16 +1,20 @@ name: Test -on: push +on: + push: + branches: + - '*' + pull_request: {} jobs: test: runs-on: ubuntu-latest strategy: matrix: - node-version: [12.x, 14.x, 16.x, 18.x] + node-version: [12.x, 14.x, 16.x, 18.x, 20.x, 22.x] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Install and cache dependencies uses: bahmutov/npm-install@v1 diff --git a/index.js b/index.js index 13879d5..b1b8a64 100644 --- a/index.js +++ b/index.js @@ -411,17 +411,35 @@ class TuyaDevice extends EventEmitter { // Queue this request and limit concurrent set requests to one return this._setQueue.add(() => pTimeout(new Promise((resolve, reject) => { + // Make sure we only resolve or reject once + let resolvedOrRejected = false; + // Send request and wait for response try { + if (this.device.version === '3.5') { + this._currentSequenceN++; + } + // Send request - this._send(buffer); + this._send(buffer).catch(error => { + if (options.shouldWaitForResponse && !resolvedOrRejected) { + reject(error); + } + }); if (options.shouldWaitForResponse) { - this._setResolver = resolve; + this._setResolver = () => { + if (!resolvedOrRejected) { + resolve(); + } + }; + this._setResolveAllowGet = options.isSetCallToGetData; } else { + resolvedOrRejected = true; resolve(); } } catch (error) { + resolvedOrRejected = true; reject(error); } }), this._responseTimeout * 2500, () => { @@ -487,11 +505,16 @@ class TuyaDevice extends EventEmitter { // Check for response const now = new Date(); - this._pingPongTimeout = setTimeout(() => { - if (this._lastPingAt < now) { - this.disconnect(); - } - }, this._responseTimeout * 1000); + if (this._pingPongTimeout === null) { + // If we do not expect a pong from a former ping, we need to set a timeout + this._pingPongTimeout = setTimeout(() => { + if (this._lastPingAt < now) { + this.disconnect(); + } + }, this._responseTimeout * 1000); + } else { + debug('There was no response to the last ping.'); + } // Send ping this.client.write(buffer); @@ -702,9 +725,6 @@ class TuyaDevice extends EventEmitter { } _packetHandler(packet) { - // Response was received, so stop waiting - clearTimeout(this._sendTimeout); - // Protocol 3.4, 3.5 - Response to Msg 0x03 if (packet.commandByte === CommandType.SESS_KEY_NEG_RES) { if (!this.connectPromise) { @@ -717,8 +737,9 @@ class TuyaDevice extends EventEmitter { debug('Protocol 3.4, 3.5: Local Random Key: ' + this._tmpLocalKey.toString('hex')); debug('Protocol 3.4, 3.5: Remote Random Key: ' + this._tmpRemoteKey.toString('hex')); - if(this.device.version === '3.4' || this.device.version === '3.5') + if (this.device.version === '3.4' || this.device.version === '3.5') { this._currentSequenceN = packet.sequenceN - 1; + } const calcLocalHmac = this.device.parser.cipher.hmac(this._tmpLocalKey).toString('hex'); const expLocalHmac = packet.payload.slice(16, 16 + 32).toString('hex'); @@ -749,10 +770,12 @@ class TuyaDevice extends EventEmitter { this.sessionKey[i] = this._tmpLocalKey[i] ^ this._tmpRemoteKey[i]; } - if(this.device.version === '3.4') + if (this.device.version === '3.4') { this.sessionKey = this.device.parser.cipher._encrypt34({data: this.sessionKey}); - else if(this.device.version === '3.5') + } else if (this.device.version === '3.5') { this.sessionKey = this.device.parser.cipher._encrypt35({data: this.sessionKey, iv: this._tmpLocalKey}); + } + debug('Protocol 3.4, 3.5: Session Key: ' + this.sessionKey.toString('hex')); debug('Protocol 3.4, 3.5: Initialization done'); @@ -770,6 +793,8 @@ class TuyaDevice extends EventEmitter { */ this.emit('heartbeat'); + clearTimeout(this._pingPongTimeout); + this._pingPongTimeout = null; this._lastPingAt = new Date(); return; @@ -780,14 +805,6 @@ class TuyaDevice extends EventEmitter { packet.commandByte === CommandType.CONTROL || packet.commandByte === CommandType.CONTROL_NEW ) && packet.payload === false) { - - if(this.device.version === '3.5') - { - // Move resolver to next sequence for incoming response after ack - this._resolvers[(parseInt(packet.sequenceN) + 1).toString()] = this._resolvers[packet.sequenceN.toString()]; - delete this._resolvers[packet.sequenceN.toString()]; - } - debug('Got SET ack.'); return; } @@ -804,26 +821,26 @@ class TuyaDevice extends EventEmitter { this._setResolveAllowGet = undefined; delete this._resolvers[packet.sequenceN]; this._expectRefreshResponseForSequenceN = undefined; - } else { + } else if (packet.sequenceN in this._resolvers) { // Call data resolver for sequence number - if (packet.sequenceN in this._resolvers) { - debug('Received DP_REFRESH response packet - resolve'); - this._resolvers[packet.sequenceN](packet.payload); - - // Remove resolver - delete this._resolvers[packet.sequenceN]; - this._expectRefreshResponseForSequenceN = undefined; - } else if (this._expectRefreshResponseForSequenceN && this._expectRefreshResponseForSequenceN in this._resolvers) { - debug('Received DP_REFRESH response packet without data - resolve'); - this._resolvers[this._expectRefreshResponseForSequenceN](packet.payload); - - // Remove resolver - delete this._resolvers[this._expectRefreshResponseForSequenceN]; - this._expectRefreshResponseForSequenceN = undefined; - } else { - debug('Received DP_REFRESH response packet - no resolver found for sequence number' + packet.sequenceN); - } + + debug('Received DP_REFRESH response packet - resolve'); + this._resolvers[packet.sequenceN](packet.payload); + + // Remove resolver + delete this._resolvers[packet.sequenceN]; + this._expectRefreshResponseForSequenceN = undefined; + } else if (this._expectRefreshResponseForSequenceN && this._expectRefreshResponseForSequenceN in this._resolvers) { + debug('Received DP_REFRESH response packet without data - resolve'); + this._resolvers[this._expectRefreshResponseForSequenceN](packet.payload); + + // Remove resolver + delete this._resolvers[this._expectRefreshResponseForSequenceN]; + this._expectRefreshResponseForSequenceN = undefined; + } else { + debug('Received DP_REFRESH response packet - no resolver found for sequence number' + packet.sequenceN); } + return; } @@ -910,9 +927,6 @@ class TuyaDevice extends EventEmitter { this.device.parser.cipher.setSessionKey(null); // Clear timeouts - clearTimeout(this._sendTimeout); - clearTimeout(this._connectTimeout); - clearTimeout(this._responseTimeout); clearInterval(this._pingPongInterval); clearTimeout(this._pingPongTimeout); diff --git a/lib/cipher.js b/lib/cipher.js index c27c43e..ddac1d0 100644 --- a/lib/cipher.js +++ b/lib/cipher.js @@ -37,7 +37,7 @@ class TuyaCipher { return this._encrypt34(options); } - else if (this.version === '3.5') { + if (this.version === '3.5') { return this._encrypt35(options); } @@ -95,19 +95,18 @@ class TuyaCipher { */ _encrypt35(options) { let encrypted; - let localIV = Buffer.from((Date.now() * 10).toString().substring(0, 12)); - if(options.iv !== undefined) localIV = options.iv.slice(0, 12); + let localIV = Buffer.from((Date.now() * 10).toString().slice(0, 12)); + if (options.iv !== undefined) { + localIV = options.iv.slice(0, 12); + } const cipher = crypto.createCipheriv('aes-128-gcm', this.getKey(), localIV); - if(options.aad !== undefined) - { + if (options.aad === undefined) { + encrypted = Buffer.concat([cipher.update(options.data), cipher.final()]); + } else { cipher.setAAD(options.aad); encrypted = Buffer.concat([localIV, cipher.update(options.data), cipher.final(), cipher.getAuthTag(), Buffer.from([0x00, 0x00, 0x99, 0x66])]); } - else - { - encrypted = Buffer.concat([cipher.update(options.data), cipher.final()]); - } return encrypted; } @@ -123,7 +122,7 @@ class TuyaCipher { return this._decrypt34(data); } - else if (this.version === '3.5') { + if (this.version === '3.5') { return this._decrypt35(data); } @@ -222,24 +221,23 @@ class TuyaCipher { */ _decrypt35(data) { let result; - let header = data.slice(0, 14); - let iv = data.slice(14, 26); - let tag = data.slice(data.length - 16); + const header = data.slice(0, 14); + const iv = data.slice(14, 26); + const tag = data.slice(data.length - 16); data = data.slice(26, data.length - 16); - + try { const decipher = crypto.createDecipheriv('aes-128-gcm', this.getKey(), iv); decipher.setAuthTag(tag); decipher.setAAD(header); result = Buffer.concat([decipher.update(data), decipher.final()]); - result = result.slice(4); // remove 32bit return code + result = result.slice(4); // Remove 32bit return code } catch (_) { throw new Error('Decrypt failed'); } - - // Try to parse data as JSON, - // otherwise return as string. + + // Try to parse data as JSON, otherwise return as string. // 3.5 protocol // {"protocol":4,"t":1632405905,"data":{"dps":{"101":true},"cid":"00123456789abcde"}} try { diff --git a/lib/message-parser.js b/lib/message-parser.js index a36f5cd..371df00 100644 --- a/lib/message-parser.js +++ b/lib/message-parser.js @@ -119,8 +119,9 @@ class MessageParser { let leftover = false; let suffixLocation = buffer.indexOf('0000AA55', 0, 'hex'); - if (suffixLocation === -1) // Couldn't find 0000AA55 during parse + if (suffixLocation === -1) {// Couldn't find 0000AA55 during parse suffixLocation = buffer.indexOf('00009966', 0, 'hex'); + } if (suffixLocation !== buffer.length - 4) { leftover = buffer.slice(suffixLocation + 4); @@ -138,8 +139,7 @@ class MessageParser { let commandByte; let payloadSize; - if (suffix === 0x0000AA55) - { + if (suffix === 0x0000AA55) { // Get sequence number sequenceN = buffer.readUInt32BE(4); @@ -153,9 +153,7 @@ class MessageParser { if (buffer.length - 8 < payloadSize) { throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`); } - } - else if (suffix === 0x00009966) - { + } else if (suffix === 0x00009966) { // Get sequence number sequenceN = buffer.readUInt32BE(6); @@ -185,13 +183,15 @@ class MessageParser { // Get the payload // Adjust for messages lacking a return code let payload; - if(this.version !== '3.5') - { + if (this.version === '3.5') { + payload = buffer.slice(HEADER_SIZE_3_5, HEADER_SIZE_3_5 + payloadSize); + sequenceN = buffer.slice(6, 10).readUInt32BE(); + commandByte = buffer.slice(10, 14).readUInt32BE(); + } else { if (returnCode & 0xFFFFFF00) { if (this.version === '3.4' && !packageFromDiscovery) { payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 0x24); - } - else if (this.version === '3.5' && !packageFromDiscovery) { + } else if (this.version === '3.5' && !packageFromDiscovery) { payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 0x24); } else { payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 8); @@ -221,12 +221,6 @@ class MessageParser { } } } - else - { - payload = buffer.slice(HEADER_SIZE_3_5, HEADER_SIZE_3_5 + payloadSize); - sequenceN = buffer.slice(6, 10).readUInt32BE(); - commandByte = buffer.slice(10, 14).readUInt32BE(); - } return {payload, leftover, commandByte, sequenceN}; } @@ -255,10 +249,10 @@ class MessageParser { } // Incoming 3.5 data isn't 0 because of iv and tag so check size after - if(this.version === '3.5') - { - if(data.length === 0) + if (this.version === '3.5') { + if (data.length === 0) { return false; + } } // Try to parse data as JSON. @@ -336,7 +330,7 @@ class MessageParser { return this._encode34(options); } - else if (this.version === '3.5') { + if (this.version === '3.5') { return this._encode35(options); } @@ -498,18 +492,18 @@ class MessageParser { Buffer.from('3.5').copy(buffer, 0); payload.copy(buffer, 15); payload = buffer; - options.data = '3.5\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + options.data; + // OO options.data = '3.5\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + options.data; } // Allocate buffer for prefix, unknown, sequence, command, length let buffer = Buffer.alloc(18); - + // Add prefix, command, and length buffer.writeUInt32BE(0x00006699, 0); // Prefix buffer.writeUInt16BE(0x0, 4); // Unknown buffer.writeUInt32BE(options.sequenceN, 6); // Sequence buffer.writeUInt32BE(options.commandByte, 10); // Command - buffer.writeUInt32BE(payload.length + 0x1c, 14); // Length + buffer.writeUInt32BE(payload.length + 28 /* 0x1c */, 14); // Length const encrypted = this.cipher.encrypt({ data: payload, diff --git a/package-lock.json b/package-lock.json index bcaa506..243dd10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "7.5.2", "license": "MIT", "dependencies": { - "debug": "^4.3.4", + "debug": "^4.3.6", "p-queue": "6.6.2", "p-retry": "4.6.2", "p-timeout": "3.2.0" @@ -4267,9 +4267,9 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "dependencies": { "ms": "2.1.2" }, @@ -16242,9 +16242,9 @@ "dev": true }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "requires": { "ms": "2.1.2" } diff --git a/package.json b/package.json index 26da206..af868fd 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ }, "homepage": "https://github.com/codetheweb/tuyapi#readme", "dependencies": { - "debug": "^4.3.4", + "debug": "^4.3.6", "p-queue": "6.6.2", "p-retry": "4.6.2", "p-timeout": "3.2.0"