From 65be53da8c48c17ed7935abd8f2d2a93aa851453 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 15 Jul 2026 19:09:16 -0700 Subject: [PATCH] fix(rawcli): all hints live in the completion dropdown, not the bottom bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The memory-location info was being surfaced on the bottom toolbar (input_hint). It belongs in the autocomplete dropdown — the same popup where identify/READ/… appear. Moved it there and took it off the bottom bar entirely. - Bottom toolbar shows only the entry-mode status now; the input_hint machinery is removed. - Raw hex/binary entry: a matched opcode inserts the RAW BYTE (30, 00110000), labelled with the command name — not "READ(". You're building a raw byte string, so accepting a hint keeps you in raw bytes. - New: after a page-command opcode in raw entry, the next byte gets the tag's memory map as raw bytes (30 04 -> "04 user memory"), matching what READ( offers. Renders in the entry base. - Function-call page args still insert 0x-hex addresses (the chosen display). Verified through the real interactive TUI (pty): typing "30 " pops the memory map in the completion menu as raw bytes; the old bottom-bar hint string no longer appears anywhere. 1328 green. Co-Authored-By: Claude Opus 4.8 --- pm3py/cli/rawcli/app.py | 12 +----- pm3py/cli/rawcli/completer.py | 64 +++++++++++++++++++++++++----- pm3py/cli/rawcli/memory.py | 73 ++--------------------------------- tests/test_rawcli.py | 61 +++++++++++++---------------- 4 files changed, 87 insertions(+), 123 deletions(-) diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py index cbebfd0..720ca04 100644 --- a/pm3py/cli/rawcli/app.py +++ b/pm3py/cli/rawcli/app.py @@ -210,9 +210,6 @@ 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 @@ -221,13 +218,8 @@ def run(port=None) -> int: 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'" + # just the entry-mode status — all command/page hints live in the completion menu + return f"entry {state.entry_prefix} · Ctrl-/ hex/binary · 'help'" kb = KeyBindings() install_key_bindings(kb, state) diff --git a/pm3py/cli/rawcli/completer.py b/pm3py/cli/rawcli/completer.py index 14a50ed..5a130c5 100644 --- a/pm3py/cli/rawcli/completer.py +++ b/pm3py/cli/rawcli/completer.py @@ -16,7 +16,7 @@ import re from prompt_toolkit.completion import Completer, Completion -from .parser import CONTROL_VERBS +from .parser import CONTROL_VERBS, parse_token from .catalog import catalog_for from .memory import landmark_pages @@ -49,14 +49,60 @@ class RawCompleter(Completer): tokens = stripped.split() at_word = bool(tokens) and not stripped.endswith(" ") - word = tokens[-1] if at_word else "" + # raw byte entry, on the byte after a page-command opcode (30 …): offer the memory map as + # raw bytes, so `30 04` gets the same suggestions as READ( — inserting the raw value + on_second = (len(tokens) == 2 and at_word) or (len(tokens) == 1 and not at_word) + if on_second and self._is_raw_opcode(tokens[0]): + yield from self._raw_page_byte(tokens, at_word) + return + + word = tokens[-1] if at_word else "" completing_first = (" " not in stripped) or (len(tokens) == 1 and at_word) if completing_first: yield from self._first_token(word) elif tokens and tokens[0].lower() == "help": yield from self._catalog_names(word) + def _is_raw_opcode(self, tok: str) -> bool: + """True when ``tok`` parses to a single byte that is a page-command opcode (so the next + raw byte is a page argument we can hint).""" + catalog = catalog_for(self._session) + if catalog is None: + return False + try: + b = parse_token(tok, getattr(self._session, "entry_mode", "hex")) + except ValueError: + return False + if len(b) != 1: + return False + tc = catalog.by_opcode(b[0]) + return tc is not None and bool(tc.params) and tc.params[0] in ("page", "block", "start") + + def _raw_page_byte(self, tokens: list[str], at_word: bool): + """Offer the tag's memory map for a raw page byte (after a page-command opcode), rendered as + a raw byte in the entry base — ``04`` in hex, ``00000100`` in binary. Accepting inserts the + raw value, keeping you in raw entry (30 04), not the command name.""" + partial = tokens[1] if (len(tokens) >= 2 and at_word) else "" + low = partial.lower() + if low.startswith("0x"): + base, body = "hex", low[2:] + elif low.startswith("0b"): + base, body = "bin", low[2:] + elif getattr(self._session, "entry_mode", "hex") == "bin": + base, body = "bin", low + else: + base, body = "hex", low + for page, role in landmark_pages(self._session.transponder): + if base == "bin": + val, forms = f"{page:08b}", (f"{page:08b}", f"{page:b}") + else: + val, forms = f"{page:02X}", (f"{page:02x}", f"{page:x}") + if body and not any(f.startswith(body) for f in forms): + continue + yield Completion(val, start_position=-len(partial), + display=f"{val} {role}", display_meta=role) + def _page_arg(self, cmd_name: str, partial: str): """Complete a page/block argument with the identified tag's memory landmarks, rendered in the active entry mode — ``0x04`` in hex, ``0b00000100`` in binary (every bit visible, @@ -121,23 +167,23 @@ class RawCompleter(Completer): return low, getattr(self._session, "entry_mode", "hex") def _opcode_matches(self, word: str, catalog): + # matching raw hex/binary input to a command by opcode. Accepting inserts the RAW BYTE + # (rendered in the entry base), not the command name — you're building a raw byte string. digits, base = self._numeric(word) if not digits: return if base == "hex": - valid, render = _HEXDIGITS, (lambda op: f"{op:02x}") + valid, match, value = _HEXDIGITS, (lambda op: f"{op:02x}"), (lambda op: f"{op:02X}") else: - valid, render = _BINDIGITS, (lambda op: f"{op:08b}") + valid, match, value = _BINDIGITS, (lambda op: f"{op:08b}"), (lambda op: f"{op:08b}") if any(ch not in valid for ch in digits): return - low = word.lower() + prefix = word[:len(word) - len(digits)] # keep any 0x/0b the user typed for name in catalog.names(): - if name.lower().startswith(low): - continue # already offered by name tc = catalog.get(name) op = _opcode(tc) - if op is not None and render(op).startswith(digits): - yield Completion(f"{name}(", start_position=-len(word), + if op is not None and match(op).startswith(digits): + yield Completion(prefix + value(op), start_position=-len(word), display=f"{name} (0x{op:02X})", display_meta=f"opcode 0x{op:02X} — {tc.help}") diff --git a/pm3py/cli/rawcli/memory.py b/pm3py/cli/rawcli/memory.py index b8a1ae1..935495b 100644 --- a/pm3py/cli/rawcli/memory.py +++ b/pm3py/cli/rawcli/memory.py @@ -1,18 +1,16 @@ -"""Per-transponder memory maps + the live relevance hint. +"""Per-transponder memory maps. 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. +(user memory, CC, AUTH0, PWD, lock bytes, …), and ``landmark_pages`` lists the notable pages so the +completion menu can surface the memory map as you type. All of this shows up in the completion +dropdown — never in the bottom bar or command output. 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}, @@ -32,8 +30,6 @@ _ROLES = { "lock": "dynamic lock bytes", } -_CALL_HEAD = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([^,)]*)") - # The pages guaranteed on every Type 2 tag (down to the smallest, NTAG210): UID/CC and user # memory 0x04-0x0F. Used when only the NTAG/Ultralight *family* is known — GET_VERSION couldn't # complete on a flaky link, or an original Ultralight has no GET_VERSION. Config pages differ by @@ -105,64 +101,3 @@ def landmark_pages(transponder: str | None) -> list[tuple[int, str]]: if key in layout: out[layout[key]] = _ROLES.get(key, key) return sorted(out.items()) - - -def _to_int(text: str) -> int | None: - from .parser import parse_arg_int # function-call arg: base 10 by default (0x/0b) - try: - return parse_arg_int(text) - except (ValueError, TypeError): - return None - - -_ADDR_PARAMS = ("page", "block", "start") - - -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. Covers the function-call - form (``READ(4``) and raw byte entry (``30 04``); the memory-location info lives *only* here and - in completion, never in command output.""" - from .catalog import catalog_for - - stripped = (text or "").strip() - if not stripped: - return None - catalog = catalog_for(session) - if catalog is None: - return None - - # function-style call: READ(4 — base-10 arg; hint the command and where that page lives - 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 _ADDR_PARAMS and first: - role = page_role(session.transponder, _to_int(first)) - if role: - return f"{base} · {tc.params[0]} {first} → {role}" - return base - - # a bare catalog command name - tc = catalog.get(stripped) - if tc: - return f"{tc.usage()} — {tc.help}" - - # raw hex/binary bytes: 30 04 — if the opcode maps to a page command, hint it + the page's role - from .parser import parse_bytes - try: - payload = parse_bytes(stripped, getattr(session, "entry_mode", "hex")) - except ValueError: - payload = b"" - if payload: - tc = catalog.by_opcode(payload[0]) - if tc: - base = f"{tc.usage()} — {tc.help}" - if len(payload) >= 2 and tc.params and tc.params[0] in _ADDR_PARAMS: - role = page_role(session.transponder, payload[1]) - if role: - return f"{base} · {tc.params[0]} 0x{payload[1]:02X} → {role}" - return base - return None diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 4953f60..fa9c579 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -534,18 +534,39 @@ class TestCompleter: cs = self._complete(s, "help GET") assert any(c.text == "GET_VERSION" for c in cs) - def test_opcode_completion_hex(self): + def _disp(self, comp): + return comp.display[0][1] if comp.display else comp.text + + def test_opcode_completion_hex_inserts_raw_byte(self): + # entering raw hex, a matched opcode inserts the RAW BYTE (60), labelled with the command s = RawSession() s.protocol, s.transponder = "hf14a", "NTAG213" cs = self._complete(s, "60") # GET_VERSION opcode - assert any(c.text == "GET_VERSION(" for c in cs) + gv = next(c for c in cs if "GET_VERSION" in self._disp(c)) + assert gv.text == "60" # not "GET_VERSION(" + # a partial digit completes to the full opcode byte + assert "30" in [c.text for c in self._complete(s, "3")] # READ (0x30) - def test_opcode_completion_binary(self): + def test_opcode_completion_binary_inserts_raw_byte(self): s = RawSession() s.protocol, s.transponder = "hf14a", "NTAG213" s.entry_mode = "bin" cs = self._complete(s, "01100000") # 0x60 in binary - assert any(c.text == "GET_VERSION(" for c in cs) + gv = next(c for c in cs if "GET_VERSION" in self._disp(c)) + assert gv.text == "01100000" # raw binary byte, not the command name + + def test_raw_page_byte_completion(self): + # after a page-command opcode in raw entry, the page byte gets the memory map (raw bytes) + s = RawSession() + s.protocol, s.transponder = "hf14a", "NTAG213" + by_text = {c.text: c.display_meta_text for c in self._complete(s, "30 ")} + assert by_text["04"] == "user memory" # inserts raw byte 04, not 0x04 + assert by_text["28"] == "dynamic lock bytes" + assert "PWD" in by_text["2B"] + # narrows as you type the byte + assert [c.text for c in self._complete(s, "30 2")] == ["28", "29", "2A", "2B", "2C"] + # a non-page opcode (GET_VERSION) gets no page menu + assert self._complete(s, "60 ") == [] def test_page_arg_lists_memory_map(self): # typing the page argument suggests the tag's memory landmarks with their roles @@ -621,32 +642,6 @@ class TestMemoryHint: 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_hint_raw_bytes(self): - # raw hex (30 04) gets the location hint too — the opcode maps to READ, byte 1 is the page - from pm3py.cli.rawcli.memory import input_hint - s = self._sess() # NTAG213 - h = input_hint(s, "30 04") - assert h.startswith("READ(page)") and "page 0x04 → user memory" in h - assert "PWD" in input_hint(s, "30 2B") # page 0x2B = PWD on a 213 - assert input_hint(s, "60").startswith("GET_VERSION") # opcode-only command, no page part - def test_generic_type2_fallback(self): from pm3py.cli.rawcli.memory import landmark_pages, page_role # only the family is known (flaky/absent GET_VERSION) -> universal pages still resolve @@ -659,15 +654,11 @@ class TestMemoryHint: assert page_role("MIFARE Classic 1K", 4) is None # not Type 2 -> nothing def test_t5577_block_roles(self): - from pm3py.cli.rawcli.memory import page_role, input_hint + from pm3py.cli.rawcli.memory import page_role tp = "T5577 (EM4100 20260716FF)" assert "config" in page_role(tp, 0) assert "password" in page_role(tp, 7) assert "data" in page_role(tp, 3) - s = RawSession() - s.protocol, s.transponder = "lf", tp - assert "config" in input_hint(s, "READ(0") # block-role relevance for LF too - assert "password" in input_hint(s, "WRITE(7,") def test_layouts_match_models(self): from pm3py.cli.rawcli.memory import _LAYOUTS