From 10761b1d5fe7f7580194fdc27ef116be5da4533c Mon Sep 17 00:00:00 2001 From: michael Date: Sun, 15 Mar 2026 22:14:12 -0700 Subject: [PATCH] feat(pm3py): client shell and hw commands (ping, version, status, led, tune, etc) --- pm3py/__init__.py | 5 + pm3py/__pycache__/__init__.cpython-312.pyc | Bin 197 -> 414 bytes pm3py/client.py | 44 ++++++ pm3py/hw.py | 147 +++++++++++++++++++++ tests/test_hw.py | 47 +++++++ 5 files changed, 243 insertions(+) create mode 100644 pm3py/client.py create mode 100644 pm3py/hw.py create mode 100644 tests/test_hw.py diff --git a/pm3py/__init__.py b/pm3py/__init__.py index 0359d17..072a19a 100644 --- a/pm3py/__init__.py +++ b/pm3py/__init__.py @@ -1 +1,6 @@ """pm3py - Python wire protocol library for Proxmark3.""" +from .client import Proxmark3 +from .transport import PM3Error, PM3Response +from .protocol import PM3Status, Cmd + +__all__ = ["Proxmark3", "PM3Error", "PM3Response", "PM3Status", "Cmd"] diff --git a/pm3py/__pycache__/__init__.cpython-312.pyc b/pm3py/__pycache__/__init__.cpython-312.pyc index e8f3fd331bde25a74086bc50662561e34023f6fb..6bb31a3a96f0adb960cb8494ca65d41952e885e5 100644 GIT binary patch literal 414 zcmYLEyH3L}6t(kCQ4tdxl7(0zK|5qZs#p+gpbEAuR;UAwYRA!ys}eRo0Ty;PzJQP5 z3$TPL!Ni8>)(NL-=^c*G@xABR_pRM-AybbJulcVzzZ$VBYe{B3L!J;r31XO891AP4 zaKmkG3ny`eo45<@a8LM&FM=e%=ms(O87E=wMgCn}iny%F0qNIjC<`*lAtNP7B?pod zo?k%MzbWK}7PDl?7H$V-)~ai#5J z!6FxIV+W@Jw*jvK-@tj)(mth34k*>$fah7E7Q;GJkQOEp)Y_+m)i>7(jXmX@(ib!{ zfoQ(l9ZHdPClW^8#imQ7G%s>RX{W5`K~G3F=GhT!8B4QUwQb None: + await self._transport.connect() + + async def disconnect(self) -> None: + await self._transport.disconnect() + + async def __aenter__(self) -> "Proxmark3": + await self.connect() + return self + + async def __aexit__(self, *args) -> None: + await self.disconnect() + + async def send_ng(self, cmd: int, payload: bytes = b"", + timeout: float = 2.0) -> PM3Response: + """Raw NG command -- escape hatch for unwrapped commands.""" + return await self._transport.send_ng(cmd, payload, timeout) + + async def send_mix(self, cmd: int, arg0: int = 0, arg1: int = 0, + arg2: int = 0, payload: bytes = b"", + timeout: float = 2.0) -> PM3Response: + """Raw MIX command -- escape hatch for unwrapped commands.""" + return await self._transport.send_mix(cmd, arg0, arg1, arg2, payload, timeout) diff --git a/pm3py/hw.py b/pm3py/hw.py new file mode 100644 index 0000000..5d6952a --- /dev/null +++ b/pm3py/hw.py @@ -0,0 +1,147 @@ +"""Hardware commands: hw.*""" +import struct +import asyncio +from .protocol import Cmd, PM3Status +from .transport import PM3Transport, PM3Response, PM3Error, ProgressCallback + + +class HWCommands: + """Proxmark3 hardware commands.""" + + def __init__(self, transport: PM3Transport): + self._t = transport + + async def ping(self, length: int = 32) -> dict: + """Send ping and verify echo. Returns {success, length, data}.""" + data = bytes(i & 0xFF for i in range(length)) + resp = await self._t.send_ng(Cmd.PING, data) + match = resp.data == data + return {"success": match, "length": len(resp.data), "data": resp.data.hex()} + + async def version(self) -> dict: + """Get firmware version. Returns {chip_id, section_size, version_string}.""" + resp = await self._t.send_ng(Cmd.VERSION, timeout=3.0) + if len(resp.data) < 12: + raise PM3Error("Version response too short") + chip_id, section_size, vstr_len = struct.unpack_from(" dict: + """Get device status. Returns {status}.""" + if speed_test_timeout > 0: + payload = struct.pack(" dict: + """Control LEDs. led: bitmask (1=A,2=B,4=C,8=D). action: on/off/toggle/pwm/pulse/fade/blink.""" + actions = {"off": 0, "on": 1, "toggle": 2, "pwm": 3, "pulse": 4, "fade": 5, "blink": 6} + if action not in actions: + raise ValueError(f"Unknown action: {action}. Use one of: {list(actions.keys())}") + payload = struct.pack(" dict: + """Get or set debug level. None=get, 0-4=set.""" + if level is None: + resp = await self._t.send_ng(Cmd.GET_DBGMODE) + if len(resp.data) >= 1: + return {"level": resp.data[0]} + return {"level": 0} + else: + payload = struct.pack(" dict: + """Set tearoff parameters.""" + payload = struct.pack(" None: + """Send break to stop long-running operations.""" + await self._t.send_ng_no_response(Cmd.BREAK_LOOP) + + async def standalone(self) -> dict: + """Start standalone mode.""" + resp = await self._t.send_ng(Cmd.STANDALONE, timeout=5.0) + return {"status": resp.status} + + async def reset(self) -> None: + """Reset the device.""" + await self._t.send_ng_no_response(Cmd.HARDWARE_RESET) + + async def capabilities(self) -> dict: + """Get device capabilities.""" + resp = await self._t.send_ng(Cmd.CAPABILITIES) + if len(resp.data) < 9: + raise PM3Error("Capabilities response too short") + version = resp.data[0] + baudrate = struct.unpack_from(" 9: + flags = struct.unpack_from("= 13 else resp.data[9] + return { + "version": version, + "baudrate": baudrate, + "bigbuf_size": bigbuf_size, + "raw_flags": flags, + } + + async def tune(self, on_progress: ProgressCallback = None) -> dict: + """Run full antenna tuning. Returns LF/HF voltages, peak, Q factor.""" + resp = await self._t.send_ng(Cmd.MEASURE_ANTENNA_TUNING, timeout=20.0) + if resp.status != 0: + raise PM3Error("Antenna tuning failed", status=resp.status) + if len(resp.data) < 28: + raise PM3Error("Tune response too short") + + v_lf134, v_lf125, v_lfconf, v_hf, peak_v, peak_f, divisor = struct.unpack_from("= 284 else [] + + ANTENNA_ERROR = 1.09 + LF_DIVISOR_125 = 95 + LF_DIVISOR_134 = 88 + + def div2freq(d): + return 12000.0 / (d + 1) if d > 0 else 0 + + result = { + "lf": { + "125kHz_mV": v_lf125, + "125kHz_V": round(v_lf125 * ANTENNA_ERROR / 1000, 2), + "134kHz_mV": v_lf134, + "134kHz_V": round(v_lf134 * ANTENNA_ERROR / 1000, 2), + "conf_mV": v_lfconf, + "peak_mV": peak_v, + "peak_V": round(peak_v * ANTENNA_ERROR / 1000, 2), + "peak_freq_kHz": round(div2freq(peak_f), 2), + "peak_divisor": peak_f, + "divisor": divisor, + }, + "hf": { + "13.56MHz_mV": v_hf, + "13.56MHz_V": round(v_hf * ANTENNA_ERROR / 1000, 2), + }, + "scan_results": results, + } + + if on_progress: + event = {"type": "tune_complete", "result": result} + ret = on_progress(event) + if asyncio.iscoroutine(ret): + await ret + + return result diff --git a/tests/test_hw.py b/tests/test_hw.py new file mode 100644 index 0000000..3220dfc --- /dev/null +++ b/tests/test_hw.py @@ -0,0 +1,47 @@ +import struct +import asyncio +from unittest.mock import AsyncMock +from pm3py.protocol import Cmd +from pm3py.transport import PM3Response +from pm3py.hw import HWCommands + +def make_ng_response(cmd: int, status: int, payload: bytes) -> PM3Response: + return PM3Response(cmd=cmd, status=status, reason=0, ng=True, data=payload) + +def make_mock_transport(): + transport = AsyncMock() + return transport + +def test_ping(): + transport = make_mock_transport() + hw = HWCommands(transport) + ping_data = bytes(range(32)) + transport.send_ng.return_value = make_ng_response(Cmd.PING, 0, ping_data) + result = asyncio.get_event_loop().run_until_complete(hw.ping(32)) + assert result["success"] is True + assert result["length"] == 32 + +def test_version(): + transport = make_mock_transport() + hw = HWCommands(transport) + # Build version response: id(4) + section_size(4) + versionstr_len(4) + versionstr + vstr = b"Proxmark3 OS\x00" + payload = struct.pack("