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:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user