feat(rawcli): per-byte base intermixed entry; fix MFC hf.mf; help + strict mixing
Entry now tracks each raw byte's base, so hex and binary can share one line with a clean display: `30` (hex) then Ctrl-/ then `00000100` (binary) shows `30 00000100` and sends 0x30 0x04. The toggle only changes the base of the NEXT byte — it never rewrites what you've typed. Each byte auto-seals at its base width (2 hex / 8 binary); the next digit starts a fresh byte in the current mode; a digit invalid for its byte's base is dropped. Applies to raw byte entry only (function args stay base-10). - entry.type_digit / is_byte_line / reconcile_bases carry the logic (pure, unit-tested); the digit and backspace key bindings maintain session.byte_bases; the toggle just flips the mode. - parser.parse_bytes/parse_line take per-byte bases (explicit 0x/0b still wins). A line mixing a command with raw bytes (`READ 30`) raises instead of transmitting. - completer parses the opcode token in its own base, so `30`+binary still autocompletes the page. - help documents all of it (per-byte base, toggle, auto-seal, base-10 args, no command/byte mix). Also fixes a real bug found on hardware: the MFC catalog called hf.mfc (nonexistent) — it's hf.mf. Hardware-verified on a MIFARE Classic 1K: identify, CHK(0)=FFFFFFFFFFFF, READ(0) returns the manufacturer block, READ(4)/READ(1) the data blocks. Intermixed entry verified end-to-end in the live prompt (30 00000100 -> 0x30 0x04). 1351 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,15 +19,26 @@ from .trace_view import render_exchange
|
||||
_HELP = """\
|
||||
rawcli — raw command interface
|
||||
|
||||
help show this help
|
||||
help [cmd] show this help (or one command's usage)
|
||||
identify probe the field; sets HF/LF + transponder, keeps the connection
|
||||
transponder show the identified transponder
|
||||
tlv [bytes] decode / edit a TLV (NDEF) blob
|
||||
close drop the tag connection
|
||||
quit | exit | q leave rawcli
|
||||
<hex/bin ...> send raw bytes to the tag (CRC auto-appended on 14a); trace prints above
|
||||
NAME(args) run a command from the identified tag (READ(4), GET_VERSION()) — listed below
|
||||
|
||||
Type hex as "30 04" or "3004"; Ctrl-/ toggles hex/binary entry (0x/0b). Function-style
|
||||
commands (READ(4), GET_VERSION()) come from the identified tag — listed below."""
|
||||
Entry:
|
||||
* Raw bytes are hex by default ("30 04" or "3004" auto-spaces into bytes).
|
||||
* Ctrl-/ (or Ctrl-t) toggles hex (0x) / binary (0b) entry — shown in the breadcrumb.
|
||||
* Base is tracked PER BYTE: the toggle only changes the base of the NEXT byte, it never
|
||||
rewrites what you've typed. So `30` then toggle then `00000100` gives 30 00000100 and
|
||||
sends 0x30 0x04. A byte auto-seals at its width (2 hex / 8 binary) and the next digit
|
||||
starts a fresh byte. A 0x/0b prefix on one token overrides the mode for that token.
|
||||
* Function-call arguments are base-10 unless prefixed: READ(4) = page 4, READ(0x2B) = hex.
|
||||
* Suggestions (commands, opcodes by value, and the tag's memory map) appear in the
|
||||
completion menu as you type — in whichever base you're entering.
|
||||
* You can't mix a command and raw bytes on one line — that's an error, not a transmit."""
|
||||
|
||||
|
||||
def _connect(port):
|
||||
@@ -99,10 +110,12 @@ 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)
|
||||
cmd = parse_line(line, state.entry_mode, state.byte_bases)
|
||||
except ValueError as exc:
|
||||
out(f"rawcli: {exc}")
|
||||
return True
|
||||
finally:
|
||||
state.byte_bases = [] # the line is consumed — start the next one fresh
|
||||
|
||||
if cmd.kind == "control":
|
||||
if cmd.name in ("quit", "exit", "q"):
|
||||
|
||||
@@ -100,7 +100,7 @@ TYPE2 = _catalog(
|
||||
|
||||
# ---- MIFARE Classic ---------------------------------------------------------------------------
|
||||
# Classic reads/writes are gated by a crypto1 auth per sector, so — unlike NTAG — a bare 0x30 does
|
||||
# nothing. These run via hf.mfc, which does the auth + block op in firmware. build() still exposes
|
||||
# nothing. These run via hf.mf, which does the auth + block op in firmware. build() still exposes
|
||||
# the wire opcode (0x30/0xA0) for hex/binary completion. Key defaults to the transport key
|
||||
# FFFFFFFFFFFF / key A; override as READ(<block>, <12-hex key>, A|B).
|
||||
_MFC_KEYTYPE = {"A": 0, "B": 1, "0": 0, "1": 1}
|
||||
@@ -114,7 +114,7 @@ def _kt(keytype) -> int:
|
||||
|
||||
|
||||
def _mfc_read(dev, block, key="FFFFFFFFFFFF", keytype="A"):
|
||||
r = dev.hf.mfc.rdbl(_int(block), key=key, key_type=_kt(keytype))
|
||||
r = dev.hf.mf.rdbl(_int(block), key=key, key_type=_kt(keytype))
|
||||
if isinstance(r, dict) and r.get("success"):
|
||||
return f"block {_int(block)} = {r['data']} (key {str(keytype).upper()})"
|
||||
err = r.get("error") if isinstance(r, dict) else "?"
|
||||
@@ -122,13 +122,13 @@ def _mfc_read(dev, block, key="FFFFFFFFFFFF", keytype="A"):
|
||||
|
||||
|
||||
def _mfc_write(dev, block, data, key="FFFFFFFFFFFF", keytype="A"):
|
||||
r = dev.hf.mfc.wrbl(_int(block), _hex(data).ljust(16, b"\x00")[:16], key=key, key_type=_kt(keytype))
|
||||
r = dev.hf.mf.wrbl(_int(block), _hex(data).ljust(16, b"\x00")[:16], key=key, key_type=_kt(keytype))
|
||||
ok = isinstance(r, dict) and r.get("success")
|
||||
return f"WRITE block {_int(block)} <- {_hex(data).hex()}: {'ok' if ok else 'failed'} (key {str(keytype).upper()})"
|
||||
|
||||
|
||||
def _mfc_chk(dev, block, keytype="A"):
|
||||
r = dev.hf.mfc.chk(_int(block), _MFC_KEYS, key_type=_kt(keytype))
|
||||
r = dev.hf.mf.chk(_int(block), _MFC_KEYS, key_type=_kt(keytype))
|
||||
if isinstance(r, dict) and r.get("found"):
|
||||
return f"block {_int(block)} key {str(keytype).upper()} = {r['key']}"
|
||||
return f"CHK block {_int(block)} key {str(keytype).upper()}: no key from the default list works"
|
||||
|
||||
@@ -70,8 +70,12 @@ class RawCompleter(Completer):
|
||||
catalog = catalog_for(self._session)
|
||||
if catalog is None:
|
||||
return False
|
||||
# parse the opcode byte in ITS base — the base it was typed in, not the current entry mode
|
||||
# (after `30` in hex you toggle to binary to type the page byte; `30` is still hex)
|
||||
bases = getattr(self._session, "byte_bases", []) or []
|
||||
base = bases[0] if bases else getattr(self._session, "entry_mode", "hex")
|
||||
try:
|
||||
b = parse_token(tok, getattr(self._session, "entry_mode", "hex"))
|
||||
b = parse_token(tok, base)
|
||||
except ValueError:
|
||||
return False
|
||||
if len(b) != 1:
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""Numeric entry helpers: byte-group spacing for hex/binary input and the prompt_toolkit key
|
||||
bindings that toggle entry mode (Ctrl-/) and auto-space digits as you type.
|
||||
"""Numeric entry: per-byte base-aware input for raw hex/binary, plus the prompt_toolkit key
|
||||
bindings that drive it.
|
||||
|
||||
The pure ``byte_space`` / ``group_size`` functions carry the logic and are unit-tested; the key
|
||||
bindings are thin wiring exercised in the live REPL.
|
||||
Raw byte entry mixes bases **per byte** — each byte remembers the base it was typed in, so
|
||||
``30`` (hex) and ``00000100`` (binary) can sit on one line as ``30 00000100`` and send ``0x30
|
||||
0x04``. The display stays clean (no ``0x``/``0b`` clutter); the base of each byte is tracked
|
||||
alongside the text in ``session.byte_bases``. A byte auto-seals at its base width (2 hex / 8
|
||||
binary) and the next digit starts a fresh byte in the current entry mode; toggling the mode
|
||||
(Ctrl-/) only changes the base of the *next* byte — it never rewrites what you've typed.
|
||||
|
||||
The pure ``type_digit`` / ``group_size`` / ``is_byte_line`` functions carry the logic and are
|
||||
unit-tested; the key bindings are thin wiring exercised in the live REPL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,20 +23,39 @@ def group_size(mode: str) -> int:
|
||||
return 2 if mode == HEX else 8
|
||||
|
||||
|
||||
def byte_space(text: str, mode: str = HEX) -> str:
|
||||
"""Re-group a **pure** run of hex/binary digits into space-separated byte groups.
|
||||
def is_byte_line(text: str) -> bool:
|
||||
"""Whether ``text`` (spaces ignored) is a pure hex/binary byte run — i.e. raw entry, not a
|
||||
command word or function call. Empty is a byte line (you're about to type bytes)."""
|
||||
return all(ch in _DIGITS[HEX] for ch in text.replace(" ", ""))
|
||||
|
||||
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))
|
||||
|
||||
def type_digit(text: str, bases: list[str], mode: str, digit: str) -> tuple[str, list[str]]:
|
||||
"""Apply one digit keystroke to a raw-byte line, base-aware. Returns ``(new_text, new_bases)``.
|
||||
|
||||
A byte seals at its base's width (2 hex / 8 binary); once full, the next digit starts a new
|
||||
byte in ``mode``. A digit that isn't valid for the byte it would land in is dropped (no
|
||||
change), so binary bytes can't gain hex digits and vice-versa."""
|
||||
d = digit.lower()
|
||||
groups = text.split(" ") if text else []
|
||||
aligned = len(bases) == len(groups)
|
||||
# extend the trailing byte if it's still open (present, aligned, below its base width)
|
||||
if groups and aligned and groups[-1] and len(groups[-1]) < group_size(bases[-1]):
|
||||
b = bases[-1]
|
||||
if d in _DIGITS[b]:
|
||||
groups[-1] += digit
|
||||
return " ".join(groups), list(bases)
|
||||
return text, list(bases) # wrong base for this byte — drop
|
||||
# otherwise begin a new byte in the current entry mode
|
||||
if d in _DIGITS[mode]:
|
||||
return (" ".join(groups + [digit]) if groups else digit), list(bases) + [mode]
|
||||
return text, list(bases)
|
||||
|
||||
|
||||
def reconcile_bases(text: str, bases: list[str]) -> list[str]:
|
||||
"""Trim ``bases`` to the number of byte groups left in ``text`` (after a backspace/edit). Only
|
||||
a best-effort end-alignment — enough for append + backspace, which is what raw entry does."""
|
||||
groups = [g for g in text.split(" ") if g]
|
||||
return list(bases[:len(groups)])
|
||||
|
||||
|
||||
#: keys that toggle entry mode. Ctrl-/ is emitted as Ctrl-_ (0x1F) by most terminals; c-t is an
|
||||
@@ -46,25 +72,36 @@ def _bind(kb, key, handler) -> None:
|
||||
|
||||
|
||||
def install_key_bindings(kb, session) -> None:
|
||||
"""Wire the entry-mode toggle (Ctrl-/ , via Ctrl-_ , plus Ctrl-t) and live auto-byte-spacing
|
||||
of digit input onto ``kb``. ``session`` is the :class:`RawSession` whose ``entry_mode`` is
|
||||
toggled and read. Never raises on an unknown key name."""
|
||||
"""Wire the entry-mode toggle (Ctrl-/ via Ctrl-_, plus Ctrl-t) and per-byte base-aware digit
|
||||
entry onto ``kb``. ``session`` is the :class:`RawSession` whose ``entry_mode`` is toggled/read
|
||||
and whose ``byte_bases`` records each raw byte's base. Never raises on an unknown key name."""
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
def _toggle(event):
|
||||
session.toggle_entry_mode()
|
||||
session.toggle_entry_mode() # only flips the mode — the next byte you type uses it
|
||||
event.app.invalidate() # redraw the breadcrumb
|
||||
|
||||
for key in TOGGLE_KEYS:
|
||||
_bind(kb, key, _toggle)
|
||||
|
||||
def _regroup_after(event):
|
||||
def _digit(event):
|
||||
buf = event.current_buffer
|
||||
buf.insert_text(event.data)
|
||||
# only auto-space a bare digit run (no prefix), matching byte_space's guard
|
||||
grouped = byte_space(buf.text, session.entry_mode)
|
||||
if grouped != buf.text:
|
||||
buf.document = Document(grouped, len(grouped))
|
||||
d = event.data
|
||||
text = buf.text
|
||||
# only base-group a raw byte line; a command/function-call being typed inserts verbatim
|
||||
if not is_byte_line(text.replace(" ", "") + d):
|
||||
buf.insert_text(d)
|
||||
return
|
||||
new_text, session.byte_bases = type_digit(text, session.byte_bases, session.entry_mode, d)
|
||||
buf.document = Document(new_text, len(new_text))
|
||||
|
||||
for ch in sorted(_DIGITS[HEX] | _DIGITS[BIN]):
|
||||
_bind(kb, ch, _regroup_after)
|
||||
_bind(kb, ch, _digit)
|
||||
|
||||
def _backspace(event):
|
||||
buf = event.current_buffer
|
||||
buf.delete_before_cursor()
|
||||
session.byte_bases = reconcile_bases(buf.text, session.byte_bases)
|
||||
|
||||
for key in ("backspace", "c-h"):
|
||||
_bind(kb, key, _backspace)
|
||||
|
||||
@@ -68,11 +68,15 @@ def parse_token(token: str, default_mode: str = HEX) -> bytes:
|
||||
raise ValueError(f"invalid binary token {token!r}") from None
|
||||
|
||||
|
||||
def parse_bytes(text: str, default_mode: str = HEX) -> bytes:
|
||||
"""Parse a whitespace-separated raw payload (intermixed 0x/0b tokens allowed)."""
|
||||
def parse_bytes(text: str, default_mode: str = HEX, bases: list[str] | None = None) -> bytes:
|
||||
"""Parse a whitespace-separated raw payload. Each token is parsed in its own base: an explicit
|
||||
``0x``/``0b`` prefix wins, else ``bases[i]`` (the base that token was typed in, when known),
|
||||
else ``default_mode``. A token that isn't a valid byte raises ``ValueError`` — so a line that
|
||||
mixes a command with raw bytes (``READ 30``) errors here instead of transmitting."""
|
||||
out = bytearray()
|
||||
for tok in text.split():
|
||||
out += parse_token(tok, default_mode)
|
||||
for i, tok in enumerate(text.split()):
|
||||
mode = bases[i] if bases and i < len(bases) else default_mode
|
||||
out += parse_token(tok, mode)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
@@ -80,8 +84,9 @@ def _split_args(inside: str) -> list[str]:
|
||||
return [a.strip() for a in inside.split(",")] if inside.strip() else []
|
||||
|
||||
|
||||
def parse_line(line: str, default_mode: str = HEX) -> Command:
|
||||
"""Classify and parse a full input line."""
|
||||
def parse_line(line: str, default_mode: str = HEX, bases: list[str] | None = None) -> Command:
|
||||
"""Classify and parse a full input line. ``bases`` gives the per-byte base for a raw line
|
||||
(from the live entry tracker); a raw line that isn't pure bytes raises ``ValueError``."""
|
||||
stripped = line.strip()
|
||||
m = _CALL_RE.match(stripped)
|
||||
if m:
|
||||
@@ -91,4 +96,4 @@ def parse_line(line: str, default_mode: str = HEX) -> Command:
|
||||
if verb in CONTROL_VERBS:
|
||||
rest = first[1].split() if len(first) > 1 else []
|
||||
return Command("control", name=verb, args=rest)
|
||||
return Command("raw", payload=parse_bytes(stripped, default_mode))
|
||||
return Command("raw", payload=parse_bytes(stripped, default_mode, bases))
|
||||
|
||||
@@ -32,6 +32,7 @@ class RawSession:
|
||||
self.transponder: str | None = None
|
||||
self.protocol: str = "hf14a" # wire protocol for raw exchanges (set by identify)
|
||||
self.entry_mode: str = HEX
|
||||
self.byte_bases: list[str] = [] # base of each raw byte typed on the current line
|
||||
self.connected: bool = device is not None
|
||||
|
||||
@property
|
||||
|
||||
Reference in New Issue
Block a user