feat(rawcli): interactive raw-command TUI skeleton (Phase 1)

First slice of `pm3py rawcli` — the interactive, annotated raw-command
tool (separate from pyws and from the future `pm3py client` CLI).

- pm3py/cli: a top-level `pm3py` console-script dispatcher (argparse)
  with a `rawcli` subcommand; prompt_toolkit gated behind a [rawcli] extra
- rawcli/session.py: RawSession — device/field/transponder/entry-mode
  state + the segmented breadcrumb `[raw / hf|lf / $tag / 0x|0b]`
- rawcli/trace_view.py: render a reader<->tag exchange as annotated,
  Proxmark-style trace lines, reusing TraceFormatter + the 14a/15693
  decoders
- rawcli/app.py: minimal PromptSession loop (connect best-effort,
  breadcrumb prompt, help/quit, raw-hex -> annotated exchange)

Entry-mode toggle, identify/connection, the command catalog, and TLV
editing follow in later phases. 9 hardware-free tests (session, trace
render, CLI dispatch); device I/O is the user's local hardware test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-14 11:59:29 -07:00
parent 5d4af2c8e4
commit 028ce9c21e
8 changed files with 254 additions and 0 deletions

5
pm3py/cli/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""pm3py command-line tools. The ``pm3py`` console-script dispatches to subcommands (rawcli,
and future flash/client). See :mod:`pm3py.cli.main`."""
from .main import main
__all__ = ["main"]

26
pm3py/cli/main.py Normal file
View File

@@ -0,0 +1,26 @@
"""`pm3py` top-level CLI dispatcher.
Subcommands live in submodules; ``rawcli`` is the first (the interactive raw-command TUI). Room
is left for ``flash`` (wrapping pm3flash) and a future ``client`` command CLI.
"""
from __future__ import annotations
import argparse
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="pm3py", description="pm3py command-line tools")
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("rawcli", help="interactive raw-command TUI for a connected Proxmark3")
p.add_argument("--port", default=None, help="serial port (default: auto-detect)")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "rawcli":
from pm3py.cli.rawcli.app import run
return run(port=args.port)
parser.print_help()
return 1

View File

@@ -0,0 +1,6 @@
"""rawcli — an interactive, annotated raw-command TUI for a connected Proxmark3.
Launched via ``pm3py rawcli``. Distinct from the pyws Python REPL and the future ``pm3py client``
command CLI: it has its own flexible grammar (loose hex/binary entry with 0x/0b toggle,
function-style transponder commands) and a live Proxmark-style annotated trace pane.
"""

80
pm3py/cli/rawcli/app.py Normal file
View File

@@ -0,0 +1,80 @@
"""rawcli interactive loop.
Phase 1 skeleton: connect (best-effort), a live breadcrumb prompt, ``help``/``quit``, and a raw
hex payload → annotated exchange. Entry-mode toggling, identify/connection, the command catalog,
and TLV editing arrive in later phases (see the plan). The interactive loop is intentionally thin;
the testable logic lives in :mod:`session` / :mod:`trace_view` / (later) ``parser``.
"""
from __future__ import annotations
import sys
from .session import RawSession
from .trace_view import render_exchange
_HELP = """\
rawcli — raw command interface
help show this help
quit | exit | q leave rawcli
<hex ...> send raw bytes to the tag; the annotated exchange prints above
Type hex as "30 04" or "3004". Entry-mode toggle (0x/0b), identify, and function-style
transponder commands (READ(4), GET_DATA) land in later phases."""
def _connect(port):
"""Best-effort device connection; returns the client or None (offline)."""
try:
from pm3py import Proxmark3
return Proxmark3(port)
except Exception as exc: # no device / connect failure — run offline
print(f"rawcli: no device ({type(exc).__name__}: {exc}); running offline", file=sys.stderr)
return None
def _raw_exchange(device, payload: bytes) -> bytes | None:
"""Issue one raw 14a exchange, returning the response bytes (or None)."""
if device is None:
return None
try:
resp = device.hf.iso14a.raw(payload)
return resp.get("data") if isinstance(resp, dict) else None
except Exception as exc:
print(f"rawcli: exchange failed ({type(exc).__name__}: {exc})", file=sys.stderr)
return None
def run(port=None) -> int:
from prompt_toolkit import PromptSession
from prompt_toolkit.patch_stdout import patch_stdout
state = RawSession(device=_connect(port))
session = PromptSession()
def message():
return state.breadcrumb() + " "
with patch_stdout():
while True:
try:
line = session.prompt(message).strip()
except (EOFError, KeyboardInterrupt):
break
if not line:
continue
if line in ("quit", "exit", "q"):
break
if line == "help":
print(_HELP)
continue
try:
payload = bytes.fromhex(line.lower().replace("0x", "").replace(" ", ""))
except ValueError:
print(f"rawcli: could not parse {line!r} as hex")
continue
response = _raw_exchange(state.device, payload)
print(render_exchange(payload, response, protocol="hf14a",
is_tty=sys.stdout.isatty()))
print("bye")
return 0

View File

@@ -0,0 +1,44 @@
"""rawcli session state — the connected device, the identified RF field and transponder, and
the current numeric entry mode. Kept deliberately small and free of any prompt_toolkit / device
detail so it stays unit-testable."""
from __future__ import annotations
from typing import Any
HEX = "hex"
BIN = "bin"
class RawSession:
"""Mutable rawcli session state.
``device`` is the connected client (or ``None`` when offline). ``field`` is the identified RF
field (``"hf"``/``"lf"``) and ``transponder`` the identified tag's display name — both set by
``identify`` in a later phase. ``entry_mode`` is the numeric entry mode toggled by the user.
"""
def __init__(self, device: Any = None):
self.device = device
self.field: str | None = None
self.transponder: str | None = None
self.entry_mode: str = HEX
self.connected: bool = device is not None
@property
def entry_prefix(self) -> str:
return "0x" if self.entry_mode == HEX else "0b"
def toggle_entry_mode(self) -> str:
self.entry_mode = BIN if self.entry_mode == HEX else HEX
return self.entry_mode
def breadcrumb(self) -> str:
"""The segmented prompt ``[raw / <field?> / <transponder?> / 0x|0b]`` — field and
transponder segments appear only once identified."""
segments = ["raw"]
if self.field:
segments.append(self.field)
if self.transponder:
segments.append(self.transponder)
segments.append(self.entry_prefix)
return "[" + " / ".join(segments) + "]"

View File

@@ -0,0 +1,24 @@
"""Render a raw reader↔tag exchange as annotated, Proxmark-style trace lines, reusing the
existing :class:`pm3py.trace.TraceFormatter` and the per-protocol decoders."""
from __future__ import annotations
from pm3py.trace import TraceFormatter, decode_14443a, decode_15693
#: protocol key -> (annotation decoder, CRC byte length)
_PROTOCOLS = {
"hf14a": (decode_14443a, 2),
"hf15": (decode_15693, 2),
"lf": (None, 0),
}
def render_exchange(command: bytes, response: bytes | None, *,
protocol: str = "hf14a", is_tty: bool | None = None) -> str:
"""Return the annotated trace for one exchange: a ``Reader → Tag`` line for ``command`` and,
if present, a ``Tag → Reader`` line for ``response``. ``is_tty`` forces/suppresses ANSI."""
decoder, crc_len = _PROTOCOLS.get(protocol, (None, 0))
fmt = TraceFormatter(mode="reader", decoder=decoder, crc_len=crc_len, is_tty=is_tty)
lines = [fmt.format(0, bytes(command)).lstrip("\n")]
if response:
lines.append(fmt.format(1, bytes(response)).lstrip("\n"))
return "\n".join(lines)

View File

@@ -42,9 +42,11 @@ dependencies = [
[project.optional-dependencies]
dev = ["pytest"]
rawcli = ["prompt_toolkit>=3.0"]
[project.scripts]
pm3flash = "pm3py.flash_cli:main"
pm3py = "pm3py.cli:main"
# pyws workspace plugin — discovered by the pyws engine (installed alongside) via this group.
[project.entry-points."pyws.plugins"]

67
tests/test_rawcli.py Normal file
View File

@@ -0,0 +1,67 @@
"""rawcli Phase 1 — session state, breadcrumb, trace rendering, CLI dispatch. Hardware-free."""
import re
from pm3py.cli.main import build_parser, main
from pm3py.cli.rawcli.session import RawSession
from pm3py.cli.rawcli.trace_view import render_exchange
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def _plain(s: str) -> str:
return _ANSI.sub("", s)
class TestBreadcrumb:
def test_default(self):
assert RawSession().breadcrumb() == "[raw / 0x]"
def test_toggle_to_binary(self):
s = RawSession()
assert s.toggle_entry_mode() == "bin"
assert s.entry_prefix == "0b"
assert s.breadcrumb() == "[raw / 0b]"
assert s.toggle_entry_mode() == "hex"
assert s.breadcrumb() == "[raw / 0x]"
def test_identified_fills_segments(self):
s = RawSession()
s.field = "hf"
s.transponder = "NTAG215"
assert s.breadcrumb() == "[raw / hf / NTAG215 / 0x]"
def test_field_only(self):
s = RawSession()
s.field = "lf"
assert s.breadcrumb() == "[raw / lf / 0x]"
class TestTraceView:
def test_command_and_response_lines(self):
out = _plain(render_exchange(bytes([0x30, 0x04]),
bytes([0x00, 0x01, 0x02, 0x03]),
protocol="hf14a", is_tty=False))
assert "Reader → Tag" in out
assert "Tag → Reader" in out
assert "30 04" in out # command bytes shown
def test_command_only_when_no_response(self):
out = _plain(render_exchange(bytes([0x26]), None, protocol="hf14a", is_tty=False))
assert "Reader → Tag" in out
assert "Tag → Reader" not in out
def test_annotation_present_for_known_command(self):
# 0x30 = 14a READ; the decoder should annotate it (exact text may vary)
out = _plain(render_exchange(bytes([0x30, 0x04]), None, protocol="hf14a", is_tty=False))
assert "READ" in out.upper()
class TestCliDispatch:
def test_rawcli_subcommand_parsed(self):
args = build_parser().parse_args(["rawcli", "--port", "/dev/ttyACM0"])
assert args.command == "rawcli" and args.port == "/dev/ttyACM0"
def test_no_command_prints_help(self, capsys):
rc = main([])
assert rc == 1
assert "rawcli" in capsys.readouterr().out