diff --git a/pm3py/cli/__init__.py b/pm3py/cli/__init__.py new file mode 100644 index 0000000..80857d7 --- /dev/null +++ b/pm3py/cli/__init__.py @@ -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"] diff --git a/pm3py/cli/main.py b/pm3py/cli/main.py new file mode 100644 index 0000000..72b0b9b --- /dev/null +++ b/pm3py/cli/main.py @@ -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 diff --git a/pm3py/cli/rawcli/__init__.py b/pm3py/cli/rawcli/__init__.py new file mode 100644 index 0000000..2b676be --- /dev/null +++ b/pm3py/cli/rawcli/__init__.py @@ -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. +""" diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py new file mode 100644 index 0000000..15c1672 --- /dev/null +++ b/pm3py/cli/rawcli/app.py @@ -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 + 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 diff --git a/pm3py/cli/rawcli/session.py b/pm3py/cli/rawcli/session.py new file mode 100644 index 0000000..c20515c --- /dev/null +++ b/pm3py/cli/rawcli/session.py @@ -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 / / / 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) + "]" diff --git a/pm3py/cli/rawcli/trace_view.py b/pm3py/cli/rawcli/trace_view.py new file mode 100644 index 0000000..1b009fe --- /dev/null +++ b/pm3py/cli/rawcli/trace_view.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 5e251c1..074570b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py new file mode 100644 index 0000000..62e6688 --- /dev/null +++ b/tests/test_rawcli.py @@ -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