fix(rawcli): render ANSI, stop mangling commands, fuller Type 2 catalog

Three fixes from live hardware use:

- Colors/ANSI now render. Output routes through prompt_toolkit's
  print_formatted_text(ANSI(...)) instead of print(), which showed the
  escape codes literally under patch_stdout. Formatters force is_tty so
  the printer decides color-vs-plain. dispatch()/handlers take an `out`.
- Auto-byte-spacing no longer mangles non-hex input. byte_space() only
  regroups a *pure* hex/binary run; command words ("help transponder",
  "identify", "close") and 0x/0b-prefixed tokens are left intact (a-f in
  words used to trigger regrouping -> failed hex parse).
- Type 2 catalog gains PWD_AUTH (0x1B), COMPAT_WRITE (0xA0), HALT — the
  NTAG213 set was missing password auth.
- identify trims the firmware buffer padding off the GET_VERSION response
  (was dumping ~40 trailing 00 bytes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-14 12:50:01 -07:00
parent 6b4a4551d4
commit 41db552e05
5 changed files with 67 additions and 41 deletions

View File

@@ -58,103 +58,105 @@ def _raw_exchange(device, payload: bytes, protocol: str = "hf14a") -> bytes | No
return None
def dispatch(state: RawSession, line: str) -> bool:
"""Handle one input line. Returns False to exit the REPL, True to keep going. Testable
without a live prompt."""
def dispatch(state: RawSession, line: str, out=print) -> bool:
"""Handle one input line. ``out`` prints a (possibly ANSI-colored) string. Returns False to
exit the REPL, True to keep going. Testable without a live prompt (default ``out=print``)."""
try:
cmd = parse_line(line, state.entry_mode)
except ValueError as exc:
print(f"rawcli: {exc}")
out(f"rawcli: {exc}")
return True
if cmd.kind == "control":
if cmd.name in ("quit", "exit", "q"):
return False
if cmd.name == "help":
_handle_help(state, cmd.args)
_handle_help(state, cmd.args, out)
elif cmd.name == "identify":
result = identify(state, emit=print) # stream the probe exchanges to the trace
print(format_summary(result))
result = identify(state, emit=out) # stream the probe exchanges to the trace
out(format_summary(result))
elif cmd.name == "transponder":
print(state.transponder or "no transponder identified — run 'identify'")
out(state.transponder or "no transponder identified — run 'identify'")
elif cmd.name == "close":
clear(state)
print("connection closed")
out("connection closed")
elif cmd.name == "tlv":
_handle_tlv(state, cmd.args)
_handle_tlv(state, cmd.args, out)
return True
if cmd.kind == "call":
_handle_call(state, cmd)
_handle_call(state, cmd, out)
return True
# raw payload
if not cmd.payload:
return True
response = _raw_exchange(state.device, cmd.payload, protocol=state.protocol)
print(render_exchange(cmd.payload, response, protocol=state.protocol,
is_tty=sys.stdout.isatty()))
out(render_exchange(cmd.payload, response, protocol=state.protocol, is_tty=True))
return True
def _handle_call(state: RawSession, cmd) -> None:
def _handle_call(state: RawSession, cmd, out=print) -> None:
catalog = catalog_for(state)
if catalog is None:
print("no transponder identified — run 'identify' first")
out("no transponder identified — run 'identify' first")
return
tc = catalog.get(cmd.name)
if tc is None:
print(f"unknown command {cmd.name!r} for {catalog.name}; try 'help'")
out(f"unknown command {cmd.name!r} for {catalog.name}; try 'help'")
return
try:
payload = tc.build(*cmd.args)
except (TypeError, ValueError) as exc:
print(f"usage: {tc.usage()} ({exc})")
out(f"usage: {tc.usage()} ({exc})")
return
response = _raw_exchange(state.device, payload, protocol=catalog.protocol)
print(render_exchange(payload, response, protocol=catalog.protocol,
is_tty=sys.stdout.isatty()))
out(render_exchange(payload, response, protocol=catalog.protocol, is_tty=True))
def _handle_tlv(state: RawSession, args) -> None:
def _handle_tlv(state: RawSession, args, out=print) -> None:
from .tlv import format_tlv, prompt_tlv
from .parser import parse_bytes
if args:
try:
data = parse_bytes(" ".join(args), state.entry_mode)
except ValueError as exc:
print(f"rawcli: {exc}")
out(f"rawcli: {exc}")
return
else:
data = prompt_tlv(state) # interactive multi-line editor
if not data:
return
print(format_tlv(data))
out(format_tlv(data))
def _handle_help(state: RawSession, args) -> None:
def _handle_help(state: RawSession, args, out=print) -> None:
catalog = catalog_for(state)
if args and catalog is not None:
tc = catalog.get(args[0])
if tc is not None:
print(f"{tc.usage()}{tc.help}")
out(f"{tc.usage()}{tc.help}")
return
print(_HELP)
lines = [_HELP]
if catalog is not None:
print(f"\n{catalog.name} commands:")
for name in catalog.names():
tc = catalog.get(name)
print(f" {tc.usage():<28}{tc.help}")
lines.append(f"\n{catalog.name} commands:")
lines += [f" {catalog.get(n).usage():<28}{catalog.get(n).help}" for n in catalog.names()]
out("\n".join(lines))
def run(port=None) -> int:
from prompt_toolkit import PromptSession
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.patch_stdout import patch_stdout
from .completer import RawCompleter
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))
kb = KeyBindings()
install_key_bindings(kb, state)
@@ -176,7 +178,7 @@ def run(port=None) -> int:
break
if not line.strip():
continue
if not dispatch(state, line):
if not dispatch(state, line, out):
break
print("bye")
out("bye")
return 0

View File

@@ -62,9 +62,15 @@ TYPE2 = _catalog(
_c("READ_SIG", [], lambda: bytes([0x3C, 0x00]), "read the ECC originality signature (0x3C)"),
_c("READ_CNT", ["counter"], lambda counter="0": bytes([0x39, _int(counter)]),
"read the 24-bit NFC counter (0x39)"),
_c("PWD_AUTH", ["password"],
lambda password: bytes([0x1B]) + _hex(password).ljust(4, b"\x00")[:4],
"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)"),
_c("COMPAT_WRITE", ["page"], lambda page: bytes([0xA0, _int(page)]),
"compatibility write, two-phase (0xA0)"),
_c("HALT", [], lambda: bytes([0x50, 0x00]), "halt the tag (0x50 00)"),
)
# ---- MIFARE Classic (block ops; auth is the reader's job before these) ----

View File

@@ -17,14 +17,18 @@ def group_size(mode: str) -> int:
def byte_space(text: str, mode: str = HEX) -> str:
"""Re-group a run of hex/binary digits into space-separated byte groups.
"""Re-group a **pure** run of hex/binary digits into space-separated byte groups.
Leaves a token that carries an explicit ``0x``/``0b`` prefix untouched (so intermixed input
isn't mangled); only a bare digit run is regrouped."""
if "x" in text.lower() or "b" in text.lower():
return text
n = group_size(mode)
Anything that isn't purely digits of the current mode is returned untouched — command words
("help", "transponder"), function calls, and explicit ``0x``/``0b`` prefixed tokens are never
regrouped. This is what keeps auto-spacing from mangling non-hex input."""
low = text.strip().lower()
if low.startswith("0x") or low.startswith("0b"):
return text # explicit prefix — leave intact
compact = text.replace(" ", "")
if not compact or any(ch not in _DIGITS[mode] for ch in compact):
return text # not a pure numeric run (e.g. a command)
n = group_size(mode)
return " ".join(compact[i:i + n] for i in range(0, len(compact), n))

View File

@@ -147,7 +147,8 @@ def identify(session, emit=None) -> dict:
scan = _try(lambda: dev.hf.iso14a.scan(), {"found": False})
if scan.get("found"):
session.field, session.protocol = "hf", "hf14a"
fmt = TraceFormatter(mode="reader", decoder=_ident_decode, crc_len=0)
# always emit ANSI; the app's printer renders it (and strips it off a non-terminal)
fmt = TraceFormatter(mode="reader", decoder=_ident_decode, crc_len=0, is_tty=True)
for direction, data in _activation_frames(scan):
emit(fmt.format(direction, data).lstrip("\n"))
model, version = None, None
@@ -184,8 +185,9 @@ def _probe_version(dev, fmt, emit) -> tuple[str | None, bytes | None]:
raw = resp.get("raw") if isinstance(resp, dict) else None
if not raw:
return None, None
emit(fmt.format(1, bytes(raw)).lstrip("\n"))
version = bytes(raw[:8])
display = bytes(raw).rstrip(b"\x00") or version # drop the firmware buffer padding
emit(fmt.format(1, display).lstrip("\n"))
model = decode_version(version)
if model:
emit(f"{model}")