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]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest"]
|
dev = ["pytest"]
|
||||||
|
rawcli = ["prompt_toolkit>=3.0"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
pm3flash = "pm3py.flash_cli:main"
|
pm3flash = "pm3py.flash_cli:main"
|
||||||
|
pm3py = "pm3py.cli:main"
|
||||||
|
|
||||||
# pyws workspace plugin — discovered by the pyws engine (installed alongside) via this group.
|
# pyws workspace plugin — discovered by the pyws engine (installed alongside) via this group.
|
||||||
[project.entry-points."pyws.plugins"]
|
[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