From c675f6b278aac5307905f9eca1829478c6627236 Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 16 Jul 2026 12:15:06 -0700 Subject: [PATCH] fix(transport): handle CMD_WTX wait-extension frames, don't misread them as replies The firmware sends an async CMD_WTX ("wait N ms") control frame on FPGA bitstream reload (fpgaloader.c: send_wtx(FPGA_LOAD_WAIT_TIME=1500)). The first HF command after connect triggers the HF-bitstream load, so a WTX frame lands before the real reply. Our read loops skipped only DEBUG_PRINT_* frames, so they returned the WTX frame as the command reply -- leaving the real reply pending and permanently desyncing every later read (each command then got the *previous* command's response). This was the root cause of the intermittent "15693 garbage/shift" reads. ping (no bitstream load) and well-coupled 14a were unaffected, which is why it looked RF/coupling-specific -- it never was, and it is not a firmware bug: the firmware behaves correctly and the C client already handles WTX (comms.c). pm3py just was not a spec-compliant client. Mirror the C client: on CMD_WTX, add the wtx value to the wait and keep reading. Applied to both read_response and _read_any_response. Verified on hardware: an ICODE SLIX GET_SYSTEM_INFO/READ stress went from 100% anomaly (permanent shift) to 0% (187/187 correct). Adds a regression test. Co-Authored-By: Claude Opus 4.8 --- pm3py/core/transport.py | 28 ++++++++++++++++++++++++++-- tests/test_transport.py | 15 +++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/pm3py/core/transport.py b/pm3py/core/transport.py index 694488b..b8f7072 100644 --- a/pm3py/core/transport.py +++ b/pm3py/core/transport.py @@ -23,6 +23,20 @@ _ASYNC_FRAME_CMDS = frozenset({ _MAX_ASYNC_SKIP = 64 # bound the skip loop so a chatty/looping device can't hang a read +def _wtx_ms(resp) -> int | None: + """Waiting-Time-eXtension value (ms) if ``resp`` is a CMD_WTX control frame, else ``None``. + + The firmware emits CMD_WTX to ask the host to *keep waiting* — notably on FPGA bitstream + reload (``send_wtx(FPGA_LOAD_WAIT_TIME)``, 1500ms), which the first HF command after connect + triggers. It is NOT a command reply. The C client extends its response timeout by this value + and reads on (comms.c). pm3py must do the same: otherwise the WTX frame is returned as the + reply, and — because every later read then grabs the *previous* command's response — one WTX + permanently desyncs the whole session (the bug behind the '15693 garbage/shift' reports).""" + if resp.cmd == Cmd.WTX and len(resp.data) >= 2: + return struct.unpack_from(" int: """Byte-swap CRC to match PM3 wire format: (low << 8) | high.""" return ((crc & 0xFF) << 8) | ((crc >> 8) & 0xFF) @@ -211,9 +225,10 @@ class PM3Transport: as the reply — the cause of garbage sample counts and desynced downloads.""" if not self._reader: raise PM3Error("Not connected") + extra = 0.0 # accumulated WTX wait-extension (seconds) for _ in range(_MAX_ASYNC_SKIP): try: - raw = await asyncio.wait_for(self._read_response_frame(), timeout=timeout) + raw = await asyncio.wait_for(self._read_response_frame(), timeout=timeout + extra) except asyncio.TimeoutError: self._resync_needed = True # late response would desync the next read — drain it raise PM3Error("Response timeout", status=-4) @@ -221,6 +236,10 @@ class PM3Transport: resp = PM3Response.from_dict(decode_response_frame(raw)) except ValueError: continue # malformed frame — resync on the next magic + wtx = _wtx_ms(resp) + if wtx is not None: + extra += wtx / 1000.0 # device asked to wait longer — keep reading + continue if resp.cmd in _ASYNC_FRAME_CMDS: continue # device debug output — not our reply return resp @@ -364,9 +383,10 @@ class PM3Transport: """Read one NG-or-OLD frame, skipping async debug-print frames (as read_response does).""" if not self._reader: raise PM3Error("Not connected") + extra = 0.0 # accumulated WTX wait-extension (seconds) for _ in range(_MAX_ASYNC_SKIP): try: - raw = await asyncio.wait_for(self._read_any_frame(), timeout=timeout) + raw = await asyncio.wait_for(self._read_any_frame(), timeout=timeout + extra) except asyncio.TimeoutError: self._resync_needed = True # late response would desync the next read — drain it raise PM3Error("Response timeout", status=-4) @@ -380,6 +400,10 @@ class PM3Transport: data=d["data"], oldarg=d["oldarg"]) except ValueError: continue + wtx = _wtx_ms(resp) + if wtx is not None: + extra += wtx / 1000.0 # device asked to wait longer — keep reading + continue if resp.cmd in _ASYNC_FRAME_CMDS: continue # device debug output — not our reply return resp diff --git a/tests/test_transport.py b/tests/test_transport.py index b470c4d..e1c8dfc 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -186,6 +186,21 @@ def test_read_response_skips_multiple_debug_then_reply(): assert resp.cmd == int(Cmd.PING) and resp.data == b"pong" +def test_read_response_skips_wtx_frame_not_returns_it(): + # a CMD_WTX "wait N ms" control frame (firmware sends it on FPGA bitstream reload) must be + # skipped like a debug frame — returning it as the reply is what permanently desynced every + # subsequent 15693 read (each command then grabbed the previous command's response) + from unittest.mock import AsyncMock as _AM + t = PM3Transport("/dev/null") + t._reader = object() + wtx = _resp_frame(int(Cmd.WTX), struct.pack("