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>
100 lines
3.9 KiB
Python
100 lines
3.9 KiB
Python
"""rawcli line parsing.
|
|
|
|
Classifies an input line into a **control** command (help/identify/…), a **function-style call**
|
|
(``READ(4)``), or a **raw** byte payload, and parses loose hex/binary — intermixed ``0x``/``0b``
|
|
tokens, whitespace-insensitive, defaulting to the session's current entry mode when a token has no
|
|
prefix.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
HEX = "hex"
|
|
BIN = "bin"
|
|
|
|
#: single-word control verbs the REPL handles directly
|
|
CONTROL_VERBS = {"help", "identify", "transponder", "close", "tlv", "quit", "exit", "q"}
|
|
|
|
_CALL_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)\s*$", re.DOTALL)
|
|
|
|
|
|
@dataclass
|
|
class Command:
|
|
"""A parsed rawcli line. ``kind`` is ``"control"``, ``"call"`` or ``"raw"``."""
|
|
kind: str
|
|
name: str = ""
|
|
args: list[str] = field(default_factory=list)
|
|
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."""
|
|
text = token
|
|
mode = default_mode
|
|
low = text.lower()
|
|
if low.startswith("0x"):
|
|
mode, text = HEX, text[2:]
|
|
elif low.startswith("0b"):
|
|
mode, text = BIN, text[2:]
|
|
if text == "":
|
|
return b""
|
|
if mode == HEX:
|
|
if len(text) % 2:
|
|
raise ValueError(f"hex token {token!r} has an odd number of digits")
|
|
try:
|
|
return bytes.fromhex(text)
|
|
except ValueError:
|
|
raise ValueError(f"invalid hex token {token!r}") from None
|
|
# binary
|
|
if len(text) % 8:
|
|
raise ValueError(f"binary token {token!r} is not a whole number of bytes "
|
|
f"({len(text)} bits)")
|
|
try:
|
|
return bytes(int(text[i:i + 8], 2) for i in range(0, len(text), 8))
|
|
except ValueError:
|
|
raise ValueError(f"invalid binary token {token!r}") from None
|
|
|
|
|
|
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 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)
|
|
|
|
|
|
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, 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:
|
|
return Command("call", name=m.group(1), args=_split_args(m.group(2)))
|
|
first = stripped.split(None, 1)
|
|
verb = first[0].lower() if first else ""
|
|
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, bases))
|