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:
5
pm3py/cli/__init__.py
Normal file
5
pm3py/cli/__init__.py
Normal 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
26
pm3py/cli/main.py
Normal 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
|
||||
6
pm3py/cli/rawcli/__init__.py
Normal file
6
pm3py/cli/rawcli/__init__.py
Normal 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
80
pm3py/cli/rawcli/app.py
Normal 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
|
||||
44
pm3py/cli/rawcli/session.py
Normal file
44
pm3py/cli/rawcli/session.py
Normal 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) + "]"
|
||||
24
pm3py/cli/rawcli/trace_view.py
Normal file
24
pm3py/cli/rawcli/trace_view.py
Normal 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)
|
||||
@@ -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
67
tests/test_rawcli.py
Normal 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
|
||||
Reference in New Issue
Block a user