From d263ee389aefc9110825948aa361a91c53803805 Mon Sep 17 00:00:00 2001 From: Andy Wright Date: Sat, 17 Oct 2026 08:45:38 +0100 Subject: [PATCH] fix: keep sequence counter in sync with device-initiated packets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device advances its own sequence number for every packet it sends, including unsolicited ones — e.g. the STATUS/DP_REFRESH push a v3.4 device emits immediately after a SET. `_currentSequenceN` was only advanced when *we* sent a packet, so after such a push the client's next request reused a sequence number the device had already consumed. The device then answered with a different number and the resolver registered for the request was never called, leaving the pending get()/set() unresolved until the socket was recycled. Reproduced against a Tuya v3.4 panel heater: connect -> _currentSequenceN = 53896 SET -> request seq 53897 ; device acks seq 53897 (push) -> device sends STATUS seq 53898 ; client counter stays 53897 GET -> request seq 53898 ; device replies seq 53899 -> no resolver for 53899 -> get() hangs Re-sync `_currentSequenceN` from every received packet in `_packetHandler()`. Only moving it forward keeps it monotonic. This is safe for the session-key negotiation: the SESS_KEY_NEG_RES branch that follows still rebases the counter with `sequenceN - 1`. Adds test/sequence.js covering the re-sync, a no-rewind guard, and the end-to-end case where a request must still resolve after an unsolicited push. --- index.js | 12 +++++++++ test/sequence.js | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 test/sequence.js diff --git a/index.js b/index.js index 5618d73..bffa265 100644 --- a/index.js +++ b/index.js @@ -739,6 +739,18 @@ class TuyaDevice extends EventEmitter { } _packetHandler(packet) { + // Keep our outgoing sequence counter in sync with the device's. + // The device advances its own sequence number for every packet it sends, + // including unsolicited ones (e.g. the STATUS/DP_REFRESH push it emits right + // after a SET). If we don't account for those, our next request reuses a + // sequence number the device has already consumed, so its reply no longer + // matches the resolver we registered and the pending get()/set() hangs until + // the socket is recycled. Re-syncing here makes request/resolver matching + // survive device-initiated packets. + if (packet.sequenceN > this._currentSequenceN) { + this._currentSequenceN = packet.sequenceN; + } + // Protocol 3.4, 3.5 - Response to Msg 0x03 if (packet.commandByte === CommandType.SESS_KEY_NEG_RES) { if (!this.connectPromise) { diff --git a/test/sequence.js b/test/sequence.js new file mode 100644 index 0000000..ae12aa2 --- /dev/null +++ b/test/sequence.js @@ -0,0 +1,64 @@ +import test from 'ava'; + +const TuyAPI = require('..'); + +// A Tuya device advances its own sequence number for every packet it sends, +// including unsolicited ones (e.g. the STATUS/DP_REFRESH push emitted right +// after a SET). TuyAPI must re-sync its outgoing counter from received packets: +// otherwise the next request reuses a sequence number the device has already +// consumed, the device answers with a different number, and the resolver we +// registered is never called — so the pending get()/set() hangs until the socket +// is recycled. + +function makeDevice() { + const device = new TuyAPI({id: '22325186db4a2217dc8e', + key: '4226aa407d5c1e2b', + ip: '127.0.0.1', + version: '3.4'}); + // Pretend a session is already established and swallow socket writes. + device._connected = true; + device.client = {write() {}}; + return device; +} + +test('re-syncs the sequence counter from an unsolicited device packet', t => { + const device = makeDevice(); + device._currentSequenceN = 98; + + // The device pushes a STATUS packet using sequence 100; our counter is behind. + device._packetHandler({commandByte: 8, sequenceN: 100, payload: {dps: {3: 15}}}); + + t.is(device._currentSequenceN, 100); +}); + +test('does not rewind the sequence counter on an older packet', t => { + const device = makeDevice(); + device._currentSequenceN = 100; + + device._packetHandler({commandByte: 8, sequenceN: 90, payload: {dps: {3: 15}}}); + + t.is(device._currentSequenceN, 100); +}); + +test('a pending request still resolves after an unsolicited push', t => { + const device = makeDevice(); + let deviceSequenceN = 200; // the last sequence number the device has used + + // The device pushes an unsolicited packet, advancing its own counter. + deviceSequenceN++; + device._packetHandler({commandByte: 8, sequenceN: deviceSequenceN, payload: {dps: {3: 15}}}); + + // Issue a read the way get()/_send() do: claim the next sequence number and + // register a resolver for it. + const requestSequenceN = ++device._currentSequenceN; + let received; + device._resolvers[requestSequenceN] = payload => { + received = payload; + }; + + // The device answers on the sequence number that follows the one it last used. + deviceSequenceN++; + device._packetHandler({commandByte: 16, sequenceN: deviceSequenceN, payload: {dps: {1: true}}}); + + t.is(received.dps['1'], true); +});