From 260cb8eb9e4f8b658b4fffbe34fba00d649164ba Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 15 Jul 2026 16:18:36 -0700 Subject: [PATCH] feat(rawcli): annotate read/write output with datasheet memory regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , FAST_READ = .., 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 --- pm3py/cli/rawcli/app.py | 11 +++++++++++ pm3py/cli/rawcli/catalog.py | 17 +++++++++++------ pm3py/cli/rawcli/memory.py | 22 ++++++++++++++++++++++ tests/test_rawcli.py | 24 ++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py index cbebfd0..6be9182 100644 --- a/pm3py/cli/rawcli/app.py +++ b/pm3py/cli/rawcli/app.py @@ -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 diff --git a/pm3py/cli/rawcli/catalog.py b/pm3py/cli/rawcli/catalog.py index f099a57..2f25964 100644 --- a/pm3py/cli/rawcli/catalog.py +++ b/pm3py/cli/rawcli/catalog.py @@ -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 (0x30)"), + "read 4 pages starting at (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 .. in one frame (0x3A)"), + "read pages .. 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 to (0xA2)"), + "write 4 bytes to (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 to , two-phase (0xA0)"), + "compatibility write: 16-byte to , two-phase (0xA0)", + pages=lambda page, data: [_int(page)]), _c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the tag (0x50 00)"), ) diff --git a/pm3py/cli/rawcli/memory.py b/pm3py/cli/rawcli/memory.py index 94597e8..57c0f12 100644 --- a/pm3py/cli/rawcli/memory.py +++ b/pm3py/cli/rawcli/memory.py @@ -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) diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 127215d..3c4b890 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -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)"