Files
pm3py/pm3py/cli/rawcli/parser.py
michael 2b087a302a 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>
2026-07-15 20:43:04 -07:00

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))