diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py index c6db7d4..2fa0d8a 100644 --- a/pm3py/cli/rawcli/app.py +++ b/pm3py/cli/rawcli/app.py @@ -158,12 +158,25 @@ def run(port=None) -> int: from .completer import RawCompleter + from prompt_toolkit.application import get_app + from .memory import input_hint + def out(text): # parse the ANSI our formatters emit and render it through prompt_toolkit (renders # color on a terminal, strips it when piped) — plain print() shows the codes literally print_formatted_text(ANSI(str(text))) state = RawSession(device=_connect(port)) + + def bottom_toolbar(): + # live relevance for the current input: what the command does, and where a page lives + try: + text = get_app().current_buffer.text + except Exception: + text = "" + hint = input_hint(state, text) + return hint if hint else f"entry {state.entry_prefix} · Ctrl-/ hex/binary · 'help'" + kb = KeyBindings() install_key_bindings(kb, state) session = PromptSession( @@ -171,6 +184,7 @@ def run(port=None) -> int: completer=RawCompleter(state), complete_while_typing=True, # ipython-style live menu auto_suggest=AutoSuggestFromHistory(), # inline suggestion hints + bottom_toolbar=bottom_toolbar, # live relevance hint ) def message(): diff --git a/pm3py/cli/rawcli/memory.py b/pm3py/cli/rawcli/memory.py new file mode 100644 index 0000000..a7ad8c1 --- /dev/null +++ b/pm3py/cli/rawcli/memory.py @@ -0,0 +1,90 @@ +"""Per-transponder memory maps + the live relevance hint. + +The point of rawcli isn't just to send bytes — it's to tell you what they *mean* on the tag in +front of you. ``page_role`` maps a page/block number to its role on the identified transponder +(user memory, CC, AUTH0, PWD, lock bytes, …), and ``input_hint`` turns the current input line into +a one-line relevance hint for the prompt's bottom bar: the command's purpose, and — when you've +typed a page argument — where that page lives. + +Layouts mirror the transponder models' page attributes (``_cfg0_page`` etc.); kept static to +avoid an import-order circular, with a test guarding against drift. +""" +from __future__ import annotations + +import re + +# Page layout per NTAG21x / Ultralight EV1 IC (user range inclusive; keys are identify's names). +_LAYOUTS = { + "NTAG210": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13}, + "NTAG212": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28}, + "NTAG213": {"user": (0x04, 0x27), "lock": 0x28, "cfg0": 0x29, "cfg1": 0x2A, "pwd": 0x2B, "pack": 0x2C}, + "NTAG215": {"user": (0x04, 0x81), "lock": 0x82, "cfg0": 0x83, "cfg1": 0x84, "pwd": 0x85, "pack": 0x86}, + "NTAG216": {"user": (0x04, 0xE1), "lock": 0xE2, "cfg0": 0xE3, "cfg1": 0xE4, "pwd": 0xE5, "pack": 0xE6}, + "MIFARE Ultralight EV1 (MF0UL11)": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13}, + "MIFARE Ultralight EV1 (MF0UL21)": {"user": (0x04, 0x23), "lock": 0x24, "cfg0": 0x25, "cfg1": 0x26, "pwd": 0x27, "pack": 0x28}, +} + +_ROLES = { + "cfg0": "CFG0 — MIRROR / MIRROR_PAGE / AUTH0", + "cfg1": "CFG1 — ACCESS (PROT / CFGLCK / AUTHLIM)", + "pwd": "PWD (32-bit password)", + "pack": "PACK (password acknowledge)", + "lock": "dynamic lock bytes", +} + +_CALL_HEAD = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([^,)]*)") + + +def page_role(transponder: str | None, page: int | None) -> str | None: + """The role of ``page`` on ``transponder`` (user memory / CC / AUTH0 / PWD / …), or None.""" + layout = _LAYOUTS.get(transponder or "") + if layout is None or page is None: + return None + if 0 <= page <= 2: + return "UID / serial number" + if page == 3: + return "Capability Container (CC)" + lo, hi = layout["user"] + if lo <= page <= hi: + return "user memory" + for key, role in _ROLES.items(): + if page == layout.get(key): + return role + return None + + +def _to_int(text: str) -> int | None: + try: + return int(text, 0) + except (ValueError, TypeError): + return None + + +def input_hint(session, text: str) -> str | None: + """A one-line relevance hint for the current input: the command's purpose, and — for a page/ + block argument — where that page lives on the identified transponder.""" + from .catalog import catalog_for + + stripped = (text or "").strip() + if not stripped: + return None + catalog = catalog_for(session) + if catalog is None: + return None + + m = _CALL_HEAD.match(stripped) + if m: + tc = catalog.get(m.group(1)) + first = m.group(2).strip() + if tc: + base = f"{tc.usage()} — {tc.help}" + if tc.params and tc.params[0] in ("page", "block") and first: + role = page_role(session.transponder, _to_int(first)) + if role: + return f"{base} · {tc.params[0]} {first} → {role}" + return base + + tc = catalog.get(stripped) + if tc: + return f"{tc.usage()} — {tc.help}" + return None diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index e1dc7f2..a33bbfb 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -399,6 +399,50 @@ class TestCompleter: assert any(c.text == "GET_VERSION(" for c in cs) +class TestMemoryHint: + def _sess(self, tp="NTAG213"): + s = RawSession() + s.protocol, s.transponder = "hf14a", tp + return s + + def test_page_role(self): + from pm3py.cli.rawcli.memory import page_role + assert page_role("NTAG213", 0) == "UID / serial number" + assert page_role("NTAG213", 3) == "Capability Container (CC)" + assert page_role("NTAG213", 4) == "user memory" + assert "AUTH0" in page_role("NTAG213", 0x29) # CFG0 + assert page_role("NTAG213", 0x2A).startswith("CFG1") + assert "PWD" in page_role("NTAG213", 0x2B) + assert page_role("NTAG216", 0x04) == "user memory" # different IC, different pages + assert "AUTH0" in page_role("NTAG216", 0xE3) + + def test_hint_command_and_page_location(self): + from pm3py.cli.rawcli.memory import input_hint + s = self._sess() + assert "user memory" in input_hint(s, "READ(4)") + assert "PWD" in input_hint(s, "READ(0x2B") # partial input + hex arg + assert "Capability Container" in input_hint(s, "WRITE(3,") + + def test_hint_command_only(self): + from pm3py.cli.rawcli.memory import input_hint + s = self._sess() + assert input_hint(s, "GET_VERSION").startswith("GET_VERSION()") + assert input_hint(s, "") is None + + def test_hint_needs_transponder(self): + from pm3py.cli.rawcli.memory import input_hint + assert input_hint(RawSession(), "READ(4)") is None + + def test_layouts_match_models(self): + from pm3py.cli.rawcli.memory import _LAYOUTS + from pm3py.sim import NTAG210, NTAG212, NTAG213, NTAG215, NTAG216 + for cls, name in [(NTAG210, "NTAG210"), (NTAG212, "NTAG212"), (NTAG213, "NTAG213"), + (NTAG215, "NTAG215"), (NTAG216, "NTAG216")]: + L = _LAYOUTS[name] + assert (L["cfg0"], L["cfg1"], L["pwd"], L["pack"]) == \ + (cls._cfg0_page, cls._cfg1_page, cls._pwd_page, cls._pack_page) + + class TestKeyBindings: def test_install_does_not_raise(self): # regression: "c-/" is not a valid prompt_toolkit key and used to crash launch