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