diff --git a/pm3py/core/transport.py b/pm3py/core/transport.py index da7c5e9..8f7eeb4 100644 --- a/pm3py/core/transport.py +++ b/pm3py/core/transport.py @@ -14,6 +14,14 @@ from .protocol import ( OLD_FRAME_FMT, OLD_FRAME_HDR_SIZE, OLD_FRAME_SIZE, ) +# Frames the device emits asynchronously (debug output), independent of any command. They can +# arrive in the middle of a command's response stream on debug-heavy firmware, so the response +# readers skip them to return the real reply (mirrors the C client routing debug to its logger). +_ASYNC_FRAME_CMDS = frozenset({ + Cmd.DEBUG_PRINT_STRING, Cmd.DEBUG_PRINT_INTEGERS, Cmd.DEBUG_PRINT_BYTES, +}) +_MAX_ASYNC_SKIP = 64 # bound the skip loop so a chatty/looping device can't hang a read + def _crc_to_wire(crc: int) -> int: """Byte-swap CRC to match PM3 wire format: (low << 8) | high.""" @@ -188,19 +196,27 @@ class PM3Transport: await self._writer.drain() async def read_response(self, timeout: float = 2.0) -> PM3Response: - """Read and decode one response frame from the device.""" + """Read one command reply, skipping async debug-print frames the device interleaves. + + Debug-heavy firmware emits DEBUG_PRINT_* frames asynchronously, which can land in the + middle of a command's response stream. The C client routes those to its logger and keeps + reading for the real reply; pm3py must do the same, or it returns a debug line misparsed + as the reply — the cause of garbage sample counts and desynced downloads.""" if not self._reader: raise PM3Error("Not connected") - - try: - # Read until we find response magic - raw = await asyncio.wait_for( - self._read_response_frame(), timeout=timeout, - ) - d = decode_response_frame(raw) - return PM3Response.from_dict(d) - except asyncio.TimeoutError: - raise PM3Error("Response timeout", status=-4) + for _ in range(_MAX_ASYNC_SKIP): + try: + raw = await asyncio.wait_for(self._read_response_frame(), timeout=timeout) + except asyncio.TimeoutError: + raise PM3Error("Response timeout", status=-4) + try: + resp = PM3Response.from_dict(decode_response_frame(raw)) + except ValueError: + continue # malformed frame — resync on the next magic + if resp.cmd in _ASYNC_FRAME_CMDS: + continue # device debug output — not our reply + return resp + raise PM3Error("Too many async frames without a reply", status=-4) async def _read_response_frame(self) -> bytes: """Read a complete response frame, scanning for magic bytes.""" @@ -337,19 +353,28 @@ class PM3Transport: return bytes(buf[:count]) if count else bytes(buf) async def _read_any_response(self, timeout: float = 5.0) -> PM3Response: - """Read one frame that may be NG or OLD and return a PM3Response.""" + """Read one NG-or-OLD frame, skipping async debug-print frames (as read_response does).""" if not self._reader: raise PM3Error("Not connected") - try: - raw = await asyncio.wait_for(self._read_any_frame(), timeout=timeout) - except asyncio.TimeoutError: - raise PM3Error("Response timeout", status=-4) - magic = struct.unpack_from(" bytes: """Read a complete frame, branching NG vs OLD on the leading 4-byte magic. diff --git a/tests/test_transport.py b/tests/test_transport.py index c1343e9..160b20c 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -151,3 +151,36 @@ def test_download_bulk_drains_stale_input_first(): t.send_frame = tracking_send _run(t.download_bulk(Cmd.DOWNLOAD_BIGBUF, 0, 100, Cmd.DOWNLOADED_BIGBUF, Cmd.ACK)) assert calls[0] == "drain" and calls[1] == "send" # drained BEFORE the request went out + + +def _resp_frame(cmd, data=b"", status=0): + # build a no-CRC NG response frame the way the device sends one + length_ng = len(data) | 0x8000 + pre = struct.pack("