fix(rawcli): memory locations live only in suggestions; base-10 call args

Two corrections:

1. Memory-location names are a SUGGESTION, never command output. Removed the region annotation
   printed after each exchange (both the function-call and raw-hex paths) along with its dead
   machinery (region_annotation, pages_for_payload, TagCommand.pages). The location now shows in
   exactly two places: the completion menu (page landmarks) and the live bottom-bar hint —
   which now also covers raw byte entry (30 04 -> "READ(page) … · page 0x04 → user memory")
   via Catalog.by_opcode.

2. Function-call arguments are base 10 by default. READ(04) crashed on int("04", 0) (Python
   rejects leading-zero decimals in base 0). parser.parse_arg_int parses a call argument as
   decimal unless it carries a 0x/0b/0o prefix, so READ(04)/READ(40) work and READ(0x28) still
   overrides to hex. Raw byte entry keeps its opposite default (hex, 0b to intermix) — unchanged.

Hardware-verified on NTAG213: READ(04) sends 30 04 with no region line in the output; the hint
shows the page role for READ(4), READ(04), READ(0x28), raw 30 04 and 30 28; completion still
lists the memory map. 1331 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-15 18:38:53 -07:00
parent 899142f23a
commit a3cef180d2
5 changed files with 87 additions and 131 deletions

View File

@@ -131,21 +131,9 @@ def dispatch(state: RawSession, line: str, out=print) -> bool:
response = _raw_exchange(state.device, cmd.payload, protocol=state.protocol,
crc=state.protocol == "hf14a") # 14a frames need CRC; bare `60` works
out(render_exchange(cmd.payload, response, protocol=state.protocol, is_tty=True))
_annotate_pages(state, cmd.payload, out) # raw '30 04' -> "04-07 → user memory"
return True
def _annotate_pages(state: RawSession, payload: bytes, out=print) -> None:
"""Name the datasheet regions a raw command touched, when its bytes map to a known page/block
op — so raw hex/binary gets the same annotation as the function-call form (``READ(4)``)."""
from .catalog import pages_for_payload
from .memory import region_annotation
covered = pages_for_payload(catalog_for(state), payload)
ann = region_annotation(state.transponder, covered) if covered else None
if ann:
out(f" {ann}")
def _handle_call(state: RawSession, cmd, out=print) -> None:
catalog = catalog_for(state)
if catalog is None:
@@ -182,17 +170,6 @@ def _handle_call(state: RawSession, cmd, out=print) -> None:
response = _raw_exchange(state.device, frame, protocol=catalog.protocol, crc=crc)
out(render_exchange(frame, response, protocol=catalog.protocol, is_tty=True))
# name the datasheet memory regions the command covered (READ(4) -> "04-07 → user memory")
if tc.pages is not None:
from .memory import region_annotation
try:
covered = tc.pages(*cmd.args)
except (TypeError, ValueError):
covered = None
ann = region_annotation(state.transponder, covered) if covered else None
if ann:
out(f" {ann}")
def _handle_tlv(state: RawSession, args, out=print) -> None:
from .tlv import format_tlv, prompt_tlv

View File

@@ -10,6 +10,8 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from .parser import parse_arg_int
@dataclass
class TagCommand:
@@ -18,7 +20,6 @@ class TagCommand:
build: Callable[..., bytes] | None = None # HF: string args -> raw payload bytes to exchange
help: str = ""
run: Callable[..., object] | None = None # LF: (device, *args) -> a human result line
pages: Callable[..., list[int]] | None = None # the page/block numbers the command reads/writes
def usage(self) -> str:
return f"{self.name}({', '.join(self.params)})"
@@ -46,12 +47,20 @@ class Catalog:
def get(self, name: str) -> TagCommand | None:
return self.commands.get(name.upper())
def by_opcode(self, op: int) -> TagCommand | None:
"""The command whose first sent byte is ``op`` — for hinting a raw payload (``30 …`` →
READ). None if no command matches."""
for tc in self.commands.values():
if tc.opcode() == op:
return tc
return None
def names(self) -> list[str]:
return list(self.commands)
def _int(s: str) -> int:
return int(s, 0) # accepts decimal or 0x-prefixed
return parse_arg_int(s) # base 10 by default; 0x/0b/0o to override
def _hex(s: str) -> bytes:
@@ -62,19 +71,17 @@ def _catalog(name: str, protocol: str, *cmds: TagCommand) -> Catalog:
return Catalog(name, protocol, {c.name.upper(): c for c in cmds})
def _c(name, params, build=None, help="", run=None, pages=None) -> TagCommand:
return TagCommand(name, tuple(params), build, help, run, pages)
def _c(name, params, build=None, help="", run=None) -> TagCommand:
return TagCommand(name, tuple(params), build, help, run)
# ---- ISO 14443-A Type 2 (NTAG / Ultralight) ----
TYPE2 = _catalog(
"NTAG / Ultralight (Type 2)", "hf14a",
_c("READ", ["page"], lambda page: bytes([0x30, _int(page)]),
"read 4 pages starting at <page> (0x30)",
pages=lambda page: list(range(_int(page), _int(page) + 4))),
"read 4 pages starting at <page> (0x30)"),
_c("FAST_READ", ["start", "end"], lambda start, end: bytes([0x3A, _int(start), _int(end)]),
"read pages <start>..<end> in one frame (0x3A)",
pages=lambda start, end: list(range(_int(start), _int(end) + 1))),
"read pages <start>..<end> in one frame (0x3A)"),
_c("GET_VERSION", [], lambda: bytes([0x60]), "product/version info (0x60)"),
_c("READ_SIG", [], lambda: bytes([0x3C, 0x00]), "read the ECC originality signature (0x3C)"),
_c("READ_CNT", ["counter"], lambda counter="0": bytes([0x39, _int(counter)]),
@@ -84,12 +91,10 @@ TYPE2 = _catalog(
"authenticate with the 4-byte password → PACK (0x1B)"),
_c("WRITE", ["page", "data"],
lambda page, data: bytes([0xA2, _int(page)]) + _hex(data).ljust(4, b"\x00")[:4],
"write 4 bytes <data> to <page> (0xA2)",
pages=lambda page, data: [_int(page)]),
"write 4 bytes <data> to <page> (0xA2)"),
_c("COMPAT_WRITE", ["page", "data"],
lambda page, data: [bytes([0xA0, _int(page)]), _hex(data).ljust(16, b"\x00")[:16]],
"compatibility write: 16-byte <data> to <page>, two-phase (0xA0)",
pages=lambda page, data: [_int(page)]),
"compatibility write: 16-byte <data> to <page>, two-phase (0xA0)"),
_c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the tag (0x50 00)"),
)
@@ -181,30 +186,6 @@ LF_READONLY = _catalog(
)
def pages_for_payload(catalog: Catalog | None, payload: bytes) -> list[int] | None:
"""Reverse-map raw command bytes to the memory pages the command touches, so raw hex like
``30 04`` earns the same region annotation as ``READ(4)``. Matches a page/block command by
opcode (``payload[0]``) and reads its page argument from ``payload[1:]``. Returns the page
list, or None (no catalog / unknown opcode / not a page command / no page byte)."""
if catalog is None or not payload:
return None
op = payload[0]
for name in catalog.names():
tc = catalog.get(name)
if tc.pages is None: # only commands that touch memory pages
continue
if tc.opcode() != op or len(payload) < 2: # need the opcode + at least the page byte
continue
n = len(tc.params)
args = [str(b) for b in payload[1:1 + n]]
args += ["0"] * (n - len(args)) # pad trailing data args; the page is present
try:
return tc.pages(*args)
except (TypeError, ValueError):
return None
return None
def catalog_for(session) -> Catalog | None:
"""Resolve the command catalog for the session's identified transponder, or None."""
name = (session.transponder or "").upper()

View File

@@ -107,38 +107,22 @@ def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
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)``.
Returns None when the transponder has no known layout (nothing useful to say)."""
if not pages:
return None
segs: list[list] = [] # [lo, hi, role]
for p in pages:
role = page_role(transponder, p)
if segs and segs[-1][2] == role and segs[-1][1] == p - 1:
segs[-1][1] = p
else:
segs.append([p, p, role])
if all(role is None for _, _, role in segs): # no layout for this tag -> nothing to add
return None
parts = []
for lo, hi, role in segs:
loc = f"{lo:02X}" if lo == hi else f"{lo:02X}-{hi:02X}"
parts.append(f"{loc}{role or 'reserved / unknown'}")
return " · ".join(parts)
def _to_int(text: str) -> int | None:
from .parser import parse_arg_int # function-call arg: base 10 by default (0x/0b)
try:
return int(text, 0)
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."""
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()
@@ -148,19 +132,37 @@ def input_hint(session, text: str) -> str | None:
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 ("page", "block") and first:
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

View File

@@ -28,6 +28,17 @@ class Command:
payload: bytes = b""
def parse_arg_int(s: str) -> int:
"""A function-style command's numeric argument (``READ(4)``, ``WRITE(40, …)``): **base 10 by
default**, with a ``0x`` / ``0b`` / ``0o`` prefix selecting the base. This is the opposite
default from raw byte entry (which is hex) — a page number reads naturally as decimal, so
``READ(40)`` is page 40 and ``READ(0x28)`` is the same page in hex."""
s = s.strip()
if s[:2].lower() in ("0x", "0b", "0o"):
return int(s, 0)
return int(s, 10)
def parse_token(token: str, default_mode: str = HEX) -> bytes:
"""Parse one hex/binary token to bytes. A ``0x``/``0b`` prefix picks the base; otherwise
``default_mode`` is used."""