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,
|
response = _raw_exchange(state.device, cmd.payload, protocol=state.protocol,
|
||||||
crc=state.protocol == "hf14a") # 14a frames need CRC; bare `60` works
|
crc=state.protocol == "hf14a") # 14a frames need CRC; bare `60` works
|
||||||
out(render_exchange(cmd.payload, response, protocol=state.protocol, is_tty=True))
|
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
|
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:
|
def _handle_call(state: RawSession, cmd, out=print) -> None:
|
||||||
catalog = catalog_for(state)
|
catalog = catalog_for(state)
|
||||||
if catalog is None:
|
if catalog is None:
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ class TagCommand:
|
|||||||
def usage(self) -> str:
|
def usage(self) -> str:
|
||||||
return f"{self.name}({', '.join(self.params)})"
|
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
|
@dataclass
|
||||||
class Catalog:
|
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:
|
def catalog_for(session) -> Catalog | None:
|
||||||
"""Resolve the command catalog for the session's identified transponder, or None."""
|
"""Resolve the command catalog for the session's identified transponder, or None."""
|
||||||
name = (session.transponder or "").upper()
|
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:
|
def _opcode(tc) -> int | None:
|
||||||
"""The command's first byte (opcode), obtained by building it with placeholder args. Returns
|
"""The command's first byte (opcode) — None for run-only commands with no ``build``."""
|
||||||
None for commands with no ``build`` (read-only credential ops that carry no opcode)."""
|
return tc.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
|
|
||||||
|
|
||||||
|
|
||||||
class RawCompleter(Completer):
|
class RawCompleter(Completer):
|
||||||
@@ -73,8 +66,8 @@ class RawCompleter(Completer):
|
|||||||
if catalog is None:
|
if catalog is None:
|
||||||
return
|
return
|
||||||
tc = catalog.get(cmd_name)
|
tc = catalog.get(cmd_name)
|
||||||
if tc is None or not tc.params or tc.params[0] not in ("page", "block"):
|
if tc is None or not tc.params or tc.params[0] not in ("page", "block", "start"):
|
||||||
return
|
return # only a page/block/start address gets a map
|
||||||
mode = self._arg_mode(partial)
|
mode = self._arg_mode(partial)
|
||||||
body = partial.lower()
|
body = partial.lower()
|
||||||
if body.startswith(("0x", "0b")):
|
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 {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
|
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:
|
class TestFunctionCalls:
|
||||||
def _id(self, sak=0x00):
|
def _id(self, sak=0x00):
|
||||||
@@ -407,6 +424,23 @@ class TestFunctionCalls:
|
|||||||
o = _plain(capsys.readouterr().out)
|
o = _plain(capsys.readouterr().out)
|
||||||
assert "UID / serial number" in o and "Capability Container" in o
|
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):
|
def test_call_without_identify(self, capsys):
|
||||||
dispatch(RawSession(), "READ(4)")
|
dispatch(RawSession(), "READ(4)")
|
||||||
assert "identify" in capsys.readouterr().out
|
assert "identify" in capsys.readouterr().out
|
||||||
|
|||||||
Reference in New Issue
Block a user