feat(rawcli): annotate read/write output with datasheet memory regions

A READ now names the memory locations the pages cover, per the tag's datasheet layout,
grouping consecutive same-role pages:

  READ(0)  -> 00-02 → UID / serial number  ·  03 → Capability Container (CC)
  READ(4)  -> 04-07 → user memory
  READ(0xE3) -> E3 → CFG0 · E4 → CFG1 · E5 → PWD · E6 → PACK

TagCommand gains a pages() callable (which page/block numbers a command touches: READ = 4
pages from <page>, FAST_READ = <start>..<end>, WRITE = one page). memory.region_annotation()
groups those into "lo-hi → role" via the existing page_role layouts; _handle_call prints it
after the exchange. No layout for the tag (e.g. MIFARE Classic) -> no annotation.

Hardware-verified on NTAG216 (READ 0/4/0xE3, FAST_READ). Tests for the grouping + the
dispatch path. 1264 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-15 16:18:36 -07:00
parent a501759c43
commit 260cb8eb9e
4 changed files with 68 additions and 6 deletions

View File

@@ -170,6 +170,17 @@ def _handle_call(state: RawSession, cmd, out=print) -> None:
response = _raw_exchange(state.device, frame, protocol=catalog.protocol, crc=crc)
out(render_exchange(frame, response, protocol=catalog.protocol, is_tty=True))
# name the datasheet memory regions the command covered (READ(4) -> "04-07 → user memory")
if tc.pages is not None:
from .memory import region_annotation
try:
covered = tc.pages(*cmd.args)
except (TypeError, ValueError):
covered = None
ann = region_annotation(state.transponder, covered) if covered else None
if ann:
out(f" {ann}")
def _handle_tlv(state: RawSession, args, out=print) -> None:
from .tlv import format_tlv, prompt_tlv

View File

@@ -18,6 +18,7 @@ class TagCommand:
build: Callable[..., bytes] | None = None # HF: string args -> raw payload bytes to exchange
help: str = ""
run: Callable[..., object] | None = None # LF: (device, *args) -> a human result line
pages: Callable[..., list[int]] | None = None # the page/block numbers the command reads/writes
def usage(self) -> str:
return f"{self.name}({', '.join(self.params)})"
@@ -48,17 +49,19 @@ def _catalog(name: str, protocol: str, *cmds: TagCommand) -> Catalog:
return Catalog(name, protocol, {c.name.upper(): c for c in cmds})
def _c(name, params, build=None, help="", run=None) -> TagCommand:
return TagCommand(name, tuple(params), build, help, run)
def _c(name, params, build=None, help="", run=None, pages=None) -> TagCommand:
return TagCommand(name, tuple(params), build, help, run, pages)
# ---- ISO 14443-A Type 2 (NTAG / Ultralight) ----
TYPE2 = _catalog(
"NTAG / Ultralight (Type 2)", "hf14a",
_c("READ", ["page"], lambda page: bytes([0x30, _int(page)]),
"read 4 pages starting at <page> (0x30)"),
"read 4 pages starting at <page> (0x30)",
pages=lambda page: list(range(_int(page), _int(page) + 4))),
_c("FAST_READ", ["start", "end"], lambda start, end: bytes([0x3A, _int(start), _int(end)]),
"read pages <start>..<end> in one frame (0x3A)"),
"read pages <start>..<end> in one frame (0x3A)",
pages=lambda start, end: list(range(_int(start), _int(end) + 1))),
_c("GET_VERSION", [], lambda: bytes([0x60]), "product/version info (0x60)"),
_c("READ_SIG", [], lambda: bytes([0x3C, 0x00]), "read the ECC originality signature (0x3C)"),
_c("READ_CNT", ["counter"], lambda counter="0": bytes([0x39, _int(counter)]),
@@ -68,10 +71,12 @@ TYPE2 = _catalog(
"authenticate with the 4-byte password → PACK (0x1B)"),
_c("WRITE", ["page", "data"],
lambda page, data: bytes([0xA2, _int(page)]) + _hex(data).ljust(4, b"\x00")[:4],
"write 4 bytes <data> to <page> (0xA2)"),
"write 4 bytes <data> to <page> (0xA2)",
pages=lambda page, data: [_int(page)]),
_c("COMPAT_WRITE", ["page", "data"],
lambda page, data: [bytes([0xA0, _int(page)]), _hex(data).ljust(16, b"\x00")[:16]],
"compatibility write: 16-byte <data> to <page>, two-phase (0xA0)"),
"compatibility write: 16-byte <data> to <page>, two-phase (0xA0)",
pages=lambda page, data: [_int(page)]),
_c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the tag (0x50 00)"),
)

View File

@@ -69,6 +69,28 @@ def page_role(transponder: str | None, page: int | None) -> str | None:
return None
def region_annotation(transponder: str | None, pages: list[int]) -> str | None:
"""Name the memory regions a read/write touched, grouping consecutive same-role pages:
``04-07 → user memory`` or ``00-02 → UID / serial number · 03 → Capability Container (CC)``.
Returns None when the transponder has no known layout (nothing useful to say)."""
if not pages:
return None
segs: list[list] = [] # [lo, hi, role]
for p in pages:
role = page_role(transponder, p)
if segs and segs[-1][2] == role and segs[-1][1] == p - 1:
segs[-1][1] = p
else:
segs.append([p, p, role])
if all(role is None for _, _, role in segs): # no layout for this tag -> nothing to add
return None
parts = []
for lo, hi, role in segs:
loc = f"{lo:02X}" if lo == hi else f"{lo:02X}-{hi:02X}"
parts.append(f"{loc}{role or 'reserved / unknown'}")
return " · ".join(parts)
def _to_int(text: str) -> int | None:
try:
return int(text, 0)

View File

@@ -392,6 +392,21 @@ class TestFunctionCalls:
assert calls[0].args[0] == b"\xA0\x04"
assert calls[1].args[0] == b"\x01\x02\x03\x04" + b"\x00" * 12
def test_read_annotates_memory_region(self, capsys):
# READ output names the datasheet regions the pages cover (the user's ask)
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, "READ(4)")
assert "04-07 → user memory" in _plain(capsys.readouterr().out)
dispatch(s, "READ(0)")
o = _plain(capsys.readouterr().out)
assert "UID / serial number" in o and "Capability Container" in o
def test_call_without_identify(self, capsys):
dispatch(RawSession(), "READ(4)")
assert "identify" in capsys.readouterr().out
@@ -524,6 +539,15 @@ class TestMemoryHint:
from pm3py.cli.rawcli.memory import input_hint
assert input_hint(RawSession(), "READ(4)") is None
def test_region_annotation(self):
from pm3py.cli.rawcli.memory import region_annotation
assert region_annotation("NTAG216", [0, 1, 2, 3]) == \
"00-02 → UID / serial number · 03 → Capability Container (CC)"
assert region_annotation("NTAG216", [4, 5, 6, 7]) == "04-07 → user memory"
assert "PWD" in region_annotation("NTAG213", [0x2B])
assert region_annotation("MIFARE Classic 1K", [4]) is None # no layout -> no annotation
assert region_annotation("NTAG216", []) is None
def test_t5577_block_roles(self):
from pm3py.cli.rawcli.memory import page_role, input_hint
tp = "T5577 (EM4100 20260716FF)"