fix(rawcli): all hints live in the completion dropdown, not the bottom bar
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user