From b6fdb7bca44671067d8ca5aaefccfbc55b6be319 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 15 Jul 2026 16:24:45 -0700 Subject: [PATCH] feat(rawcli): suggest the tag's memory map when completing a page argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing the page/block argument of a READ/WRITE now pops the identified tag's memory landmarks as completions, each labelled with its datasheet role, so you pick a location by meaning: READ( -> 0x00 UID / serial number 0x03 Capability Container (CC) 0x04 user memory 0xE3 CFG0 — MIRROR / MIRROR_PAGE / AUTH0 0xE5 PWD (32-bit password) ... READ(0xE -> filters to the E-page landmarks memory.landmark_pages() derives the notable pages from the same _LAYOUTS the hint/annotation use (NTAG21x / Ultralight EV1 landmarks; T5577 blocks 0-7). The completer matches the partial in 0x-, bare-hex, or decimal form and inserts canonical 0xNN. A comma ends the page argument, so data args get no page suggestions; tags with no known layout (MIFARE Classic) offer nothing. Hardware-verified: completer run against a real identified NTAG216 lists 0x00..0xE6 with roles. Tests for the map, partial filtering, second-arg guard, T5577 blocks, and the no-layout case. 1269 green. Co-Authored-By: Claude Opus 4.8 --- pm3py/cli/rawcli/completer.py | 32 ++++++++++++++++++++++++++++++ pm3py/cli/rawcli/memory.py | 18 +++++++++++++++++ tests/test_rawcli.py | 37 +++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/pm3py/cli/rawcli/completer.py b/pm3py/cli/rawcli/completer.py index d82eaf5..31a7f00 100644 --- a/pm3py/cli/rawcli/completer.py +++ b/pm3py/cli/rawcli/completer.py @@ -12,14 +12,21 @@ typing and history auto-suggestion for the ipython feel. """ from __future__ import annotations +import re + from prompt_toolkit.completion import Completer, Completion from .parser import CONTROL_VERBS from .catalog import catalog_for +from .memory import landmark_pages _HEXDIGITS = set("0123456789abcdefABCDEF") _BINDIGITS = set("01") +# NAME( — matches only while the first argument is under the cursor +# (a comma ends the match, so second args aren't touched). +_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 @@ -40,6 +47,13 @@ class RawCompleter(Completer): def get_completions(self, document, complete_event): text = document.text_before_cursor stripped = text.lstrip() + + # inside READ( / WRITE( — offer the tag's memory map for the page argument + m = _CALL_ARG.match(stripped) + if m: + yield from self._page_arg(m.group(1), m.group(2)) + return + tokens = stripped.split() at_word = bool(tokens) and not stripped.endswith(" ") word = tokens[-1] if at_word else "" @@ -50,6 +64,24 @@ class RawCompleter(Completer): elif tokens and tokens[0].lower() == "help": yield from self._catalog_names(word) + def _page_arg(self, cmd_name: str, partial: str): + """Complete a page/block argument with the identified tag's memory landmarks, so + ``READ(`` lists ``0x00 → UID``, ``0x03 → CC``, ``0xE3 → CFG0`` … as suggestions.""" + catalog = catalog_for(self._session) + 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 + low = partial.lower() + for page, role in landmark_pages(self._session.transponder): + val = f"0x{page:02X}" + forms = (val.lower(), f"{page:x}", f"{page:02x}", str(page)) # 0x-, bare-hex, decimal + if low and not any(f.startswith(low) for f in forms): + continue + yield Completion(val, start_position=-len(partial), + display=f"{val} {role}", display_meta=role) + def _first_token(self, word: str): low = word.lower() for verb in sorted(CONTROL_VERBS): diff --git a/pm3py/cli/rawcli/memory.py b/pm3py/cli/rawcli/memory.py index 57c0f12..239c87f 100644 --- a/pm3py/cli/rawcli/memory.py +++ b/pm3py/cli/rawcli/memory.py @@ -69,6 +69,24 @@ def page_role(transponder: str | None, page: int | None) -> str | None: return None +def landmark_pages(transponder: str | None) -> list[tuple[int, str]]: + """Notable pages/blocks with their datasheet role, for argument autocomplete inside a + ``READ()`` / ``WRITE()`` call — so the completion menu lists the memory map + (``0x03 → Capability Container``, ``0xE5 → PWD``, …). Ordered by page. Empty when the tag + has no known layout.""" + if transponder and "T5577" in transponder.upper(): + return [(b, _t5577_block_role(b)) for b in range(8)] + layout = _LAYOUTS.get(transponder or "") + if layout is None: + return [] + out = {0: "UID / serial number", 3: "Capability Container (CC)", + layout["user"][0]: "user memory"} + for key in ("lock", "cfg0", "cfg1", "pwd", "pack"): + if key in layout: + out[layout[key]] = _ROLES.get(key, key) + return sorted(out.items()) + + 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)``. diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 3c4b890..26f7b9e 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -504,6 +504,43 @@ class TestCompleter: cs = self._complete(s, "01100000") # 0x60 in binary assert any(c.text == "GET_VERSION(" for c in cs) + def test_page_arg_lists_memory_map(self): + # typing the page argument suggests the tag's memory landmarks with their roles + s = RawSession() + s.protocol, s.transponder = "hf14a", "NTAG216" + cs = self._complete(s, "READ(") + by_text = {c.text: c.display_meta_text for c in cs} + assert by_text["0x00"] == "UID / serial number" + assert by_text["0x03"] == "Capability Container (CC)" + assert by_text["0x04"] == "user memory" + assert "PWD" in by_text["0xE5"] + + def test_page_arg_filters_by_partial(self): + s = RawSession() + s.protocol, s.transponder = "hf14a", "NTAG216" + texts = [c.text for c in self._complete(s, "READ(0xE")] + assert texts == ["0xE2", "0xE3", "0xE4", "0xE5", "0xE6"] # only the E-page landmarks + # a decimal/bare-hex partial matches too + assert [c.text for c in self._complete(s, "READ(4")] == ["0x04"] + + def test_page_arg_second_arg_untouched(self): + # a comma ends the page argument — the data argument gets no memory suggestions + s = RawSession() + s.protocol, s.transponder = "hf14a", "NTAG216" + assert self._complete(s, "WRITE(0x04, ") == [] + + def test_page_arg_t5577_blocks(self): + s = RawSession() + s.protocol, s.transponder = "lf", "T5577 / ATA5577" + by_text = {c.text: c.display_meta_text for c in self._complete(s, "READ(")} + assert "config" in by_text["0x00"] + assert "password" in by_text["0x07"] + + def test_page_arg_none_without_layout(self): + s = RawSession() + s.protocol, s.transponder = "hf14a", "MIFARE Classic 1K" # no page layout + assert self._complete(s, "READ(") == [] + class TestMemoryHint: def _sess(self, tp="NTAG213"):