feat(rawcli): annotate raw hex/binary commands with datasheet memory regions too

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 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-15 17:03:37 -07:00
parent c53c087e84
commit 33e713549d
4 changed files with 87 additions and 11 deletions

View File

@@ -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:

View File

@@ -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()

View File

@@ -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")):

View File

@@ -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