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

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