From 95133843a47f1c0f20ce177ed08cc4c8fc191e6a Mon Sep 17 00:00:00 2001 From: michael Date: Sun, 15 Mar 2026 22:15:51 -0700 Subject: [PATCH] feat(pm3py): LF commands (tune, read, sniff, sim, config, search, t55xx) --- pm3py/client.py | 2 + pm3py/lf.py | 180 +++++++++++++++++++++++++++++++++++++++++++++++ tests/test_lf.py | 27 +++++++ 3 files changed, 209 insertions(+) create mode 100644 pm3py/lf.py create mode 100644 tests/test_lf.py diff --git a/pm3py/client.py b/pm3py/client.py index 7db0737..8093458 100644 --- a/pm3py/client.py +++ b/pm3py/client.py @@ -1,6 +1,7 @@ """Main Proxmark3 client with nested API.""" from .transport import PM3Transport, PM3Response, ProgressCallback from .hw import HWCommands +from .lf import LFCommands class Proxmark3: @@ -18,6 +19,7 @@ class Proxmark3: def __init__(self, port: str, baudrate: int = 115200): self._transport = PM3Transport(port, baudrate) self.hw = HWCommands(self._transport) + self.lf = LFCommands(self._transport) async def connect(self) -> None: await self._transport.connect() diff --git a/pm3py/lf.py b/pm3py/lf.py new file mode 100644 index 0000000..122c9b7 --- /dev/null +++ b/pm3py/lf.py @@ -0,0 +1,180 @@ +"""Low frequency commands: lf.*""" +import struct +import asyncio +from .protocol import Cmd +from .transport import PM3Transport, PM3Error, ProgressCallback + + +class T55xxCommands: + """T55xx tag commands: lf.t55.""" + + def __init__(self, transport: PM3Transport): + self._t = transport + + async def readbl(self, block: int, page: int = 0, password: int | None = None, + downlink_mode: int = 0) -> dict: + """Read a T55xx block.""" + pwd = password if password is not None else 0 + pwd_mode = 1 if password is not None else 0 + payload = struct.pack(" dict: + """Write a T55xx block.""" + pwd = password if password is not None else 0 + pwd_mode = 1 if password is not None else 0 + payload = struct.pack(" dict: + """Wake up T55xx with password.""" + payload = struct.pack(" dict: + """Get T55xx timing configuration.""" + resp = await self._t.send_ng(Cmd.LF_T55XX_SET_CONFIG) + # Response is t55xx_configurations_t: 4 modes x 7 uint16_t each + modes = [] + for i in range(4): + offset = i * 14 + if offset + 14 <= len(resp.data): + fields = struct.unpack_from(" dict: + """Measure LF antenna voltage at a specific frequency. + divisor: LF frequency divisor (19-255). 95=125kHz, 88=134kHz. + Freq = 12000/(divisor+1) kHz. + """ + # Send init + payload = struct.pack("= 4 else 0 + freq_khz = round(12000.0 / (divisor + 1), 2) + + result = { + "voltage_mV": voltage, + "voltage_V": round(voltage / 1000.0, 3), + "frequency_kHz": freq_khz, + "divisor": divisor, + } + + if on_progress: + ret = on_progress({"type": "lf_tune", "result": result}) + if asyncio.iscoroutine(ret): + await ret + + return result + + async def read(self, samples: int = 12288, verbose: bool = False, + on_progress: ProgressCallback = None) -> dict: + """Read LF ADC samples into device buffer.""" + val = (samples & 0x3FFFFFFF) + if verbose: + val |= (1 << 31) + payload = struct.pack("= 4 else 0, + } + + async def sniff(self, samples: int = 12288, + on_progress: ProgressCallback = None) -> dict: + """Sniff LF signal.""" + return await self.read(samples=samples, on_progress=on_progress) + + async def sim(self, gap: int = 0, data: bytes = b"", + on_progress: ProgressCallback = None) -> dict: + """Simulate LF tag from sample buffer.""" + payload = struct.pack(" dict: + """Get or set LF sampling configuration. + Call with no args to get current config. + """ + if all(v is None for v in [decimation, bits_per_sample, averaging, + divisor, trigger_threshold, samples_to_skip]): + # GET config + resp = await self._t.send_ng(Cmd.LF_SAMPLING_GET_CONFIG) + if len(resp.data) >= 12: + dec, bps, avg, div, thresh, skip, verbose = struct.unpack_from(" 0 else 0, + "trigger_threshold": thresh, + "samples_to_skip": skip, + } + return {"raw": resp.data.hex()} + else: + # SET config -- first get current, then override + current = await self.config() + payload = struct.pack(" dict: + """Search for LF tags. Reads samples then returns raw data for analysis. + Note: Demodulation/tag identification must be done client-side. + """ + if on_progress: + ret = on_progress({"type": "lf_search", "phase": "reading"}) + if asyncio.iscoroutine(ret): + await ret + + read_result = await self.read(samples=30000, on_progress=on_progress) + + if on_progress: + ret = on_progress({"type": "lf_search", "phase": "complete"}) + if asyncio.iscoroutine(ret): + await ret + + return { + "status": read_result["status"], + "samples_captured": read_result.get("samples", 0), + } diff --git a/tests/test_lf.py b/tests/test_lf.py new file mode 100644 index 0000000..9ad20eb --- /dev/null +++ b/tests/test_lf.py @@ -0,0 +1,27 @@ +import struct +import asyncio +from unittest.mock import AsyncMock +from pm3py.protocol import Cmd +from pm3py.transport import PM3Response +from pm3py.lf import LFCommands + +def make_response(cmd, status, data): + return PM3Response(cmd=cmd, status=status, reason=0, ng=True, data=data) + +def test_lf_tune(): + t = AsyncMock() + lf = LFCommands(t) + # First call: init, second call: poll returns data + t.send_ng.return_value = make_response(Cmd.MEASURE_ANTENNA_TUNING_LF, 0, + struct.pack("