feat(rawcli): live relevance hint — per-transponder memory map

Surfaces what the input *means* on the identified tag, in a bottom-bar
hint that updates as you type (the point, over aggressive autocomplete):

- memory.py: page_role(transponder, page) maps a page/block to its role
  on the specific IC — UID, Capability Container, user memory, dynamic
  lock, CFG0/AUTH0, CFG1/ACCESS, PWD, PACK. Layouts mirror the models'
  page attributes (NTAG210/212/213/215/216, Ultralight EV1); a test
  guards against drift.
- input_hint(session, text): the command's purpose, and — once a page
  argument is typed (even partial, hex or decimal) — where that page
  lives. e.g. "READ(4)" -> user memory, "READ(0x2B" -> PWD,
  "WRITE(0x29" -> CFG0/AUTH0.
- app: wired as the prompt's bottom_toolbar.

11 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-14 13:02:59 -07:00
parent 1370526883
commit cecfc03ba7
3 changed files with 148 additions and 0 deletions

View File

@@ -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():

View File

@@ -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

View File

@@ -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