On the flaky NG link a command's response can arrive *after* its read times out, and the next command's read then picks up that stale frame — observed in rawcli as every raw 14a command returning "04 BB" (the truncated UID from _ensure_selected's timed-out scan) instead of its own response. read_response/_read_any_response now set a _resync_needed flag on timeout, and the next send_frame drains any late/stale input before writing. Cheap — only fires after a timeout (rare on a healthy link) and no-ops when the stream is clean. Hardware-verified: identify -> NTAG216, READ(4) 6/6 correct, GET_VERSION correct, with the hardening in place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
214 lines
8.5 KiB
Python
214 lines
8.5 KiB
Python
import struct
|
|
from pm3py.core.transport import encode_ng_frame, decode_response_frame, encode_mix_frame, _crc_to_wire
|
|
from pm3py.core.protocol import Cmd, CMD_PREAMBLE_MAGIC, CMD_POSTAMBLE_NOCRC, RESP_PREAMBLE_MAGIC, RESP_POSTAMBLE_NOCRC, crc16_a
|
|
|
|
def test_encode_ng_frame_ping_no_payload():
|
|
frame = encode_ng_frame(Cmd.PING, b"")
|
|
# 8 byte preamble + 0 payload + 2 postamble = 10 bytes
|
|
assert len(frame) == 10
|
|
magic, length_ng, cmd = struct.unpack_from("<IHH", frame, 0)
|
|
assert magic == CMD_PREAMBLE_MAGIC
|
|
assert length_ng & 0x8000 # ng bit set
|
|
assert (length_ng & 0x7FFF) == 0
|
|
assert cmd == Cmd.PING
|
|
# Default: no CRC (USB mode)
|
|
crc_actual = struct.unpack_from("<H", frame, 8)[0]
|
|
assert crc_actual == CMD_POSTAMBLE_NOCRC
|
|
|
|
def test_encode_ng_frame_with_payload_no_crc():
|
|
"""Default USB mode: no CRC, uses magic postamble."""
|
|
payload = bytes(range(32))
|
|
frame = encode_ng_frame(Cmd.PING, payload)
|
|
assert len(frame) == 10 + 32
|
|
_, length_ng, _ = struct.unpack_from("<IHH", frame, 0)
|
|
assert (length_ng & 0x7FFF) == 32
|
|
assert frame[8:40] == payload
|
|
crc_actual = struct.unpack_from("<H", frame, 40)[0]
|
|
assert crc_actual == CMD_POSTAMBLE_NOCRC
|
|
|
|
def test_encode_ng_frame_with_crc():
|
|
"""FPC UART mode: real CRC, byte-swapped."""
|
|
payload = bytes(range(32))
|
|
frame = encode_ng_frame(Cmd.PING, payload, use_crc=True)
|
|
crc_expected = _crc_to_wire(crc16_a(frame[:40]))
|
|
crc_actual = struct.unpack_from("<H", frame, 40)[0]
|
|
assert crc_actual == crc_expected
|
|
|
|
def test_encode_mix_frame():
|
|
frame = encode_mix_frame(Cmd.HF_ISO14443A_READER, 0x107, 0, 0, b"")
|
|
_, length_ng, _ = struct.unpack_from("<IHH", frame, 0)
|
|
assert not (length_ng & 0x8000) # ng bit NOT set for MIX
|
|
assert (length_ng & 0x7FFF) == 24 # 3x uint64 = 24 bytes
|
|
|
|
def test_decode_response_frame_no_crc():
|
|
"""USB mode: response uses no-CRC magic."""
|
|
payload = b"\x01\x02\x03\x04"
|
|
length_ng = len(payload) | 0x8000
|
|
preamble = struct.pack("<IHbbH", RESP_PREAMBLE_MAGIC, length_ng, 0, 0, Cmd.PING)
|
|
body = preamble + payload
|
|
postamble = struct.pack("<H", RESP_POSTAMBLE_NOCRC)
|
|
frame = body + postamble
|
|
|
|
resp = decode_response_frame(frame)
|
|
assert resp["cmd"] == Cmd.PING
|
|
assert resp["status"] == 0
|
|
assert resp["data"] == payload
|
|
assert resp["ng"] is True
|
|
|
|
def test_decode_response_frame_with_crc():
|
|
"""FPC UART mode: response has real CRC, byte-swapped."""
|
|
payload = b"\x01\x02\x03\x04"
|
|
length_ng = len(payload) | 0x8000
|
|
preamble = struct.pack("<IHbbH", RESP_PREAMBLE_MAGIC, length_ng, 0, 0, Cmd.PING)
|
|
body = preamble + payload
|
|
crc = _crc_to_wire(crc16_a(body))
|
|
postamble = struct.pack("<H", crc)
|
|
frame = body + postamble
|
|
|
|
resp = decode_response_frame(frame)
|
|
assert resp["cmd"] == Cmd.PING
|
|
assert resp["status"] == 0
|
|
assert resp["data"] == payload
|
|
|
|
def test_decode_response_frame_error_status():
|
|
length_ng = 0 | 0x8000
|
|
preamble = struct.pack("<IHbbH", RESP_PREAMBLE_MAGIC, length_ng, -4, -1, Cmd.STATUS)
|
|
postamble = struct.pack("<H", RESP_POSTAMBLE_NOCRC)
|
|
frame = preamble + postamble
|
|
|
|
resp = decode_response_frame(frame)
|
|
assert resp["status"] == -4 # ETIMEOUT
|
|
assert resp["data"] == b""
|
|
|
|
|
|
# --- bulk download (BigBuf): one request -> OLD data frames -> ACK -----------------------------
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
from pm3py.core.transport import PM3Transport, PM3Response
|
|
from pm3py.core.protocol import PM3_CMD_DATA_SIZE
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.get_event_loop().run_until_complete(coro)
|
|
|
|
|
|
def _bigbuf_frame(offset, data):
|
|
# OLD-format CMD_DOWNLOADED_BIGBUF: oldarg=[offset, len, tracelen], data padded to 512
|
|
return PM3Response(cmd=int(Cmd.DOWNLOADED_BIGBUF), status=0, reason=0, ng=False,
|
|
data=data + b"\x00" * (PM3_CMD_DATA_SIZE - len(data)),
|
|
oldarg=[offset, len(data), 4242])
|
|
|
|
|
|
def _ack():
|
|
return PM3Response(cmd=int(Cmd.ACK), status=0, reason=0, ng=False, data=b"", oldarg=[1, 0, 0])
|
|
|
|
|
|
def _transport_with(frames):
|
|
t = PM3Transport("/dev/null")
|
|
t.send_frame = AsyncMock()
|
|
t._read_any_response = AsyncMock(side_effect=frames)
|
|
return t
|
|
|
|
|
|
def test_download_bulk_count_terminated():
|
|
payload = bytes((i * 7) % 256 for i in range(1224))
|
|
t = _transport_with([_bigbuf_frame(0, payload[0:512]), _bigbuf_frame(512, payload[512:1024]),
|
|
_bigbuf_frame(1024, payload[1024:1224]), _ack()])
|
|
got = _run(t.download_bulk(Cmd.DOWNLOAD_BIGBUF, 0, 1224, Cmd.DOWNLOADED_BIGBUF, Cmd.ACK))
|
|
assert got == payload # exactly count bytes, reassembled in order
|
|
assert t.send_frame.call_count == 1 # ONE request, not one per chunk
|
|
|
|
|
|
def test_download_bulk_ack_terminated_early():
|
|
# device has fewer bytes than requested -> stops at the ACK, returns what came
|
|
payload = bytes(range(200))
|
|
t = _transport_with([_bigbuf_frame(0, payload), _ack()])
|
|
got = _run(t.download_bulk(Cmd.DOWNLOAD_BIGBUF, 0, 99999, Cmd.DOWNLOADED_BIGBUF, Cmd.ACK))
|
|
assert got == payload
|
|
|
|
|
|
def test_download_bulk_uses_oldarg_length_not_padding():
|
|
# the OLD frame is always 512 bytes; only oldarg[1] bytes are valid (rest is padding)
|
|
t = _transport_with([_bigbuf_frame(0, b"\xAA\xBB\xCC"), _ack()])
|
|
got = _run(t.download_bulk(Cmd.DOWNLOAD_BIGBUF, 0, 3, Cmd.DOWNLOADED_BIGBUF, Cmd.ACK))
|
|
assert got == b"\xAA\xBB\xCC" # trimmed to oldarg length, not 512 of padding
|
|
|
|
|
|
def test_download_bulk_drains_stale_input_first():
|
|
# a bulk download must flush stale bytes before sending, so OLD frames start byte-aligned
|
|
payload = bytes(range(100))
|
|
t = _transport_with([_bigbuf_frame(0, payload), _ack()])
|
|
calls = []
|
|
async def fake_drain(quiet=0.08):
|
|
calls.append("drain")
|
|
return 7
|
|
orig_send = t.send_frame
|
|
async def tracking_send(frame):
|
|
calls.append("send")
|
|
return await orig_send(frame)
|
|
t._drain_input = fake_drain
|
|
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("<IHbbH", RESP_PREAMBLE_MAGIC, length_ng, status, 0, cmd)
|
|
return pre + data + struct.pack("<H", RESP_POSTAMBLE_NOCRC)
|
|
|
|
|
|
def test_read_response_skips_async_debug_frames():
|
|
# a debug-print frame interleaved before the real reply must be skipped, not misread as data
|
|
from unittest.mock import AsyncMock as _AM
|
|
t = PM3Transport("/dev/null")
|
|
t._reader = object() # truthy so read_response proceeds
|
|
debug = _resp_frame(int(Cmd.DEBUG_PRINT_STRING), b"LF Sampling config q=95")
|
|
reply = _resp_frame(int(Cmd.LF_ACQ_RAW_ADC), struct.pack("<I", 12288))
|
|
t._read_response_frame = _AM(side_effect=[debug, reply])
|
|
resp = _run(t.read_response())
|
|
assert resp.cmd == int(Cmd.LF_ACQ_RAW_ADC)
|
|
assert struct.unpack_from("<I", resp.data, 0)[0] == 12288 # the real sample count, not debug bytes
|
|
|
|
|
|
def test_read_response_skips_multiple_debug_then_reply():
|
|
from unittest.mock import AsyncMock as _AM
|
|
t = PM3Transport("/dev/null")
|
|
t._reader = object()
|
|
frames = [_resp_frame(int(Cmd.DEBUG_PRINT_STRING), b"a"),
|
|
_resp_frame(int(Cmd.DEBUG_PRINT_INTEGERS), struct.pack("<I", 999)),
|
|
_resp_frame(int(Cmd.DEBUG_PRINT_BYTES), b"\x01\x02"),
|
|
_resp_frame(int(Cmd.PING), b"pong")]
|
|
t._read_response_frame = _AM(side_effect=frames)
|
|
resp = _run(t.read_response())
|
|
assert resp.cmd == int(Cmd.PING) and resp.data == b"pong"
|
|
|
|
|
|
def test_read_timeout_sets_resync_next_send_drains():
|
|
# a read timeout flags a resync; the next send drains the late response before writing
|
|
import asyncio as _a
|
|
from unittest.mock import AsyncMock as _AM
|
|
t = PM3Transport("/dev/null")
|
|
t._reader = object()
|
|
async def _timeout():
|
|
raise _a.TimeoutError
|
|
t._read_response_frame = _timeout
|
|
try:
|
|
_run(t.read_response(timeout=0.01))
|
|
except Exception:
|
|
pass
|
|
assert t._resync_needed is True # timeout armed a resync
|
|
|
|
t._writer = _AM()
|
|
t._writer.write = lambda f: None
|
|
t._writer.drain = _AM()
|
|
drained = []
|
|
async def _fake_drain(quiet=0.08):
|
|
drained.append(True)
|
|
return 0
|
|
t._drain_input = _fake_drain
|
|
_run(t.send_frame(b"\x00"))
|
|
assert drained == [True] and t._resync_needed is False # drained once, flag cleared
|