From 33e713549db1a9ac9a4de68ddd8c2c204011ebc2 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 15 Jul 2026 17:03:37 -0700 Subject: [PATCH] feat(rawcli): annotate raw hex/binary commands with datasheet memory regions too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw '30 04' is a READ(4), so it now earns the same region line as the function-call form — the annotation follows the bytes, not the syntax: 30 04 -> 04-07 → user memory 3A 00 06 -> 00-02 → UID/serial · 03 → CC · 04-06 → user memory 30 E3 -> E3 → CFG0 · E4 → CFG1 · E5 → PWD · E6 → PACK catalog.pages_for_payload() reverse-maps a raw payload to the pages it touches by matching the opcode (payload[0]) to a page/block command and reading its page argument from payload[1:]. dispatch's raw path prints region_annotation() after the exchange, mirroring _handle_call. Works from either entry mode (it operates on the decoded bytes). Along the way: TagCommand.opcode() centralises opcode extraction, tolerating data args (valid hex placeholder) and two-phase (multi-frame) builds — fixing a latent bug where COMPAT_WRITE's opcode came back as bytes/None. The completer now keys landmark suggestions on the first parameter being a page/block/start address, so FAST_READ( gets the memory map too. Hardware-verified on a real NTAG216 (raw 30 04 / 3A 00 06 / 30 E3, and the binary form 00110000 00000100). Tests for the reverse-map, the two-phase opcode, and the raw dispatch annotation. 1274 green. Co-Authored-By: Claude Opus 4.8 --- pm3py/cli/rawcli/app.py | 12 ++++++++++++ pm3py/cli/rawcli/catalog.py | 37 +++++++++++++++++++++++++++++++++++ pm3py/cli/rawcli/completer.py | 15 ++++---------- tests/test_rawcli.py | 34 ++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py index 6be9182..2615ee0 100644 --- a/pm3py/cli/rawcli/app.py +++ b/pm3py/cli/rawcli/app.py @@ -131,9 +131,21 @@ def dispatch(state: RawSession, line: str, out=print) -> bool: response = _raw_exchange(state.device, cmd.payload, protocol=state.protocol, crc=state.protocol == "hf14a") # 14a frames need CRC; bare `60` works out(render_exchange(cmd.payload, response, protocol=state.protocol, is_tty=True)) + _annotate_pages(state, cmd.payload, out) # raw '30 04' -> "04-07 → user memory" return True +def _annotate_pages(state: RawSession, payload: bytes, out=print) -> None: + """Name the datasheet regions a raw command touched, when its bytes map to a known page/block + op — so raw hex/binary gets the same annotation as the function-call form (``READ(4)``).""" + from .catalog import pages_for_payload + from .memory import region_annotation + covered = pages_for_payload(catalog_for(state), payload) + ann = region_annotation(state.transponder, covered) if covered else None + if ann: + out(f" {ann}") + + def _handle_call(state: RawSession, cmd, out=print) -> None: catalog = catalog_for(state) if catalog is None: diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index 2f25964..31e6e4b 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -23,6 +23,19 @@ class TagCommand: def usage(self) -> str: return f"{self.name}({', '.join(self.params)})" + def opcode(self) -> int | None: + """The command's first sent byte, by building it with placeholder args — used to match raw + input back to a command. None when there's no build (LF run-only commands).""" + if self.build is None: + return None + try: + built = self.build(*(["00"] * len(self.params))) # "00" is valid for both _int and _hex + except Exception: + return None + if isinstance(built, list): # two-phase write: the first frame's opcode + built = built[0] if built else b"" + return built[0] if built else None + @dataclass class Catalog: @@ -168,6 +181,30 @@ LF_READONLY = _catalog( ) +def pages_for_payload(catalog: Catalog | None, payload: bytes) -> list[int] | None: + """Reverse-map raw command bytes to the memory pages the command touches, so raw hex like + ``30 04`` earns the same region annotation as ``READ(4)``. Matches a page/block command by + opcode (``payload[0]``) and reads its page argument from ``payload[1:]``. Returns the page + list, or None (no catalog / unknown opcode / not a page command / no page byte).""" + if catalog is None or not payload: + return None + op = payload[0] + for name in catalog.names(): + tc = catalog.get(name) + if tc.pages is None: # only commands that touch memory pages + continue + if tc.opcode() != op or len(payload) < 2: # need the opcode + at least the page byte + continue + n = len(tc.params) + args = [str(b) for b in payload[1:1 + n]] + args += ["0"] * (n - len(args)) # pad trailing data args; the page is present + try: + return tc.pages(*args) + except (TypeError, ValueError): + return None + return None + + def catalog_for(session) -> Catalog | None: """Resolve the command catalog for the session's identified transponder, or None.""" name = (session.transponder or "").upper() diff --git a/pm3py/cli/rawcli/completer.py b/pm3py/cli/rawcli/completer.py index 71bdf15..14a50ed 100644 --- a/pm3py/cli/rawcli/completer.py +++ b/pm3py/cli/rawcli/completer.py @@ -29,15 +29,8 @@ _CALL_ARG = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\(\s*([^,)]*)$") def _opcode(tc) -> int | None: - """The command's first byte (opcode), obtained by building it with placeholder args. Returns - None for commands with no ``build`` (read-only credential ops that carry no opcode).""" - if tc.build is None: - return None - try: - built = tc.build(*(["0"] * len(tc.params))) - return built[0] if built else None - except Exception: - return None + """The command's first byte (opcode) — None for run-only commands with no ``build``.""" + return tc.opcode() class RawCompleter(Completer): @@ -73,8 +66,8 @@ class RawCompleter(Completer): if catalog is None: return tc = catalog.get(cmd_name) - if tc is None or not tc.params or tc.params[0] not in ("page", "block"): - return + if tc is None or not tc.params or tc.params[0] not in ("page", "block", "start"): + return # only a page/block/start address gets a map mode = self._arg_mode(partial) body = partial.lower() if body.startswith(("0x", "0b")): diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 7cfa623..fcbc1c7 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -365,6 +365,23 @@ class TestCatalog: assert {c.name for c in T5577.commands.values()} == {"READ", "WRITE", "WAKE", "DETECT"} assert T5577.get("READ").run is not None and LF_READONLY.get("INFO").run is not None + def test_opcode_covers_two_phase_write(self): + # opcode() tolerates data args (which need valid hex) and multi-frame builds + assert TYPE2.get("WRITE").opcode() == 0xA2 + assert TYPE2.get("COMPAT_WRITE").opcode() == 0xA0 # first frame's opcode, not None + assert TYPE2.get("GET_VERSION").opcode() == 0x60 + assert LF_READONLY.get("INFO").opcode() is None # run-only, no build + + def test_pages_for_payload(self): + # raw bytes reverse-map to the pages a page/block command touches + from pm3py.cli.rawcli.catalog import pages_for_payload + assert pages_for_payload(TYPE2, b"\x30\x04") == [4, 5, 6, 7] # READ(4) + assert pages_for_payload(TYPE2, b"\x3A\x00\x06") == [0, 1, 2, 3, 4, 5, 6] # FAST_READ(0,6) + assert pages_for_payload(TYPE2, b"\xA2\x04\x01\x02\x03\x04") == [4] # WRITE(4, ..) + assert pages_for_payload(TYPE2, b"\x60") is None # GET_VERSION — not a page command + assert pages_for_payload(TYPE2, b"\x30") is None # opcode only, no page byte + assert pages_for_payload(None, b"\x30\x04") is None # no catalog + class TestFunctionCalls: def _id(self, sak=0x00): @@ -407,6 +424,23 @@ class TestFunctionCalls: o = _plain(capsys.readouterr().out) assert "UID / serial number" in o and "Capability Container" in o + def test_raw_hex_annotates_memory_region(self, capsys): + # raw '30 04' is a READ(4) — it earns the same region annotation as the function form + s = RawSession(device=_mock_device( + scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}, + version=NTAG216_VERSION)) + dispatch(s, "identify") + assert s.transponder == "NTAG216" + s.device.hf.iso14a.raw.return_value = {"raw": b"\x03\x00\xFE\x00" * 4} + capsys.readouterr() + dispatch(s, "30 04") # raw READ(4) + assert "04-07 → user memory" in _plain(capsys.readouterr().out) + dispatch(s, "3A 00 06") # raw FAST_READ(0,6) + o = _plain(capsys.readouterr().out) + assert "UID / serial number" in o and "04-06 → user memory" in o + dispatch(s, "60") # GET_VERSION — no page op, no region + assert "user memory" not in _plain(capsys.readouterr().out) + def test_call_without_identify(self, capsys): dispatch(RawSession(), "READ(4)") assert "identify" in capsys.readouterr().out