From 03569d2361e84b60564e79ca001ac4f00bfb4f12 Mon Sep 17 00:00:00 2001 From: michael Date: Sun, 15 Mar 2026 22:12:53 -0700 Subject: [PATCH] feat(pm3py): transport layer with NG/MIX frame encoding and async serial I/O --- .gitignore | 4 + pm3py/transport.py | 218 ++++++++++++++++++++++++++++++++++++++++ tests/test_transport.py | 59 +++++++++++ 3 files changed, 281 insertions(+) create mode 100644 .gitignore create mode 100644 pm3py/transport.py create mode 100644 tests/test_transport.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e33d94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.egg-info/ +.pytest_cache/ diff --git a/pm3py/transport.py b/pm3py/transport.py new file mode 100644 index 0000000..9640dfb --- /dev/null +++ b/pm3py/transport.py @@ -0,0 +1,218 @@ +"""Proxmark3 NG wire protocol frame encoding/decoding and async serial I/O.""" +import asyncio +import struct +from dataclasses import dataclass +from typing import Callable, Awaitable + +from .protocol import ( + Cmd, PM3Status, crc16_a, + CMD_PREAMBLE_MAGIC, RESP_PREAMBLE_MAGIC, + CMD_POSTAMBLE_NOCRC, RESP_POSTAMBLE_NOCRC, + CMD_PREAMBLE_SIZE, CMD_POSTAMBLE_SIZE, + RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE, + PM3_CMD_DATA_SIZE, MIX_ARGS_SIZE, +) + + +def encode_ng_frame(cmd: int, payload: bytes = b"", use_crc: bool = True) -> bytes: + """Encode an NG command frame.""" + if len(payload) > PM3_CMD_DATA_SIZE: + raise ValueError(f"Payload too large: {len(payload)} > {PM3_CMD_DATA_SIZE}") + length_ng = len(payload) | 0x8000 # set ng bit + preamble = struct.pack(" bytes: + """Encode a MIX command frame (NG wire format with OLD-style args).""" + args = struct.pack(" PM3_CMD_DATA_SIZE: + raise ValueError(f"MIX payload too large: {len(full_payload)} > {PM3_CMD_DATA_SIZE}") + length_ng = len(full_payload) & 0x7FFF # ng bit NOT set + preamble = struct.pack(" dict: + """Decode a response frame into a dict.""" + if len(data) < RESP_PREAMBLE_SIZE + RESP_POSTAMBLE_SIZE: + raise ValueError(f"Frame too short: {len(data)}") + + magic, length_ng, status, reason, cmd = struct.unpack_from("= MIX_ARGS_SIZE: + arg0, arg1, arg2 = struct.unpack_from(" "PM3Response": + return cls( + cmd=d["cmd"], status=d["status"], reason=d["reason"], + ng=d["ng"], data=d["data"], oldarg=d.get("oldarg"), + ) + + +ProgressCallback = Callable[[dict], Awaitable[None]] | Callable[[dict], None] | None + + +class PM3Transport: + """Async serial transport for Proxmark3 NG protocol.""" + + def __init__(self, port: str, baudrate: int = 115200): + self.port = port + self.baudrate = baudrate + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + self._lock = asyncio.Lock() + + async def connect(self) -> None: + import serial_asyncio + self._reader, self._writer = await serial_asyncio.open_serial_connection( + url=self.port, baudrate=self.baudrate, + ) + + async def disconnect(self) -> None: + if self._writer: + self._writer.close() + await self._writer.wait_closed() + self._writer = None + self._reader = None + + async def send_frame(self, frame: bytes) -> None: + if not self._writer: + raise PM3Error("Not connected") + self._writer.write(frame) + await self._writer.drain() + + async def read_response(self, timeout: float = 2.0) -> PM3Response: + """Read and decode one response frame from the device.""" + if not self._reader: + raise PM3Error("Not connected") + + try: + # Read until we find response magic + raw = await asyncio.wait_for( + self._read_response_frame(), timeout=timeout, + ) + d = decode_response_frame(raw) + return PM3Response.from_dict(d) + except asyncio.TimeoutError: + raise PM3Error("Response timeout", status=-4) + + async def _read_response_frame(self) -> bytes: + """Read a complete response frame, scanning for magic bytes.""" + assert self._reader is not None + buf = bytearray() + + # Scan for response magic + while True: + b = await self._reader.read(1) + if not b: + raise PM3Error("Connection closed") + buf.append(b[0]) + if len(buf) >= 4: + magic = struct.unpack_from(" PM3Response: + """Send NG command and wait for response.""" + async with self._lock: + frame = encode_ng_frame(cmd, payload) + await self.send_frame(frame) + return await self.read_response(timeout=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: + """Send MIX command and wait for response.""" + async with self._lock: + frame = encode_mix_frame(cmd, arg0, arg1, arg2, payload) + await self.send_frame(frame) + return await self.read_response(timeout=timeout) + + async def send_ng_no_response(self, cmd: int, payload: bytes = b"") -> None: + """Send NG command without waiting for response (e.g. BREAK_LOOP).""" + async with self._lock: + frame = encode_ng_frame(cmd, payload) + await self.send_frame(frame) diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..f3a32ee --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,59 @@ +import struct +from pm3py.transport import encode_ng_frame, decode_response_frame, encode_mix_frame +from pm3py.protocol import Cmd, CMD_PREAMBLE_MAGIC, RESP_PREAMBLE_MAGIC, crc16_a + +def test_encode_ng_frame_ping_no_payload(): + frame = encode_ng_frame(Cmd.PING, b"") + # 8 byte preamble + 0 payload + 2 postamble = 10 bytes + assert len(frame) == 10 + magic, length_ng, cmd = struct.unpack_from("