feat(rawcli): suggest the tag's memory map when completing a page argument
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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( <first-arg-being-typed> — 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(<page> / WRITE(<block> — 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):
|
||||
|
||||
@@ -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(<page>)`` / ``WRITE(<block>)`` 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)``.
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user