michael 907522f0ae feat(lf): test-mode T55xx write (opcode 01) for recovery
Add a `test` flag to lf.t55.writebl that sets the firmware test-mode bit, sending
the datasheet's opcode-01 reconfiguration write (§5.10.3) instead of the standard
opcode-10 write. This is the path that can rewrite a tag a normal write can't
(corrupted/locked config), when the master key still permits it. Matches the C
client's `lf t55 write -t`. Default off; routine writes are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:01:24 -07:00

pm3py

Pure-Python async client for the Proxmark3, speaking its NG wire protocol directly over USB serial. Structured Python dicts instead of parsed terminal text, with a sync proxy for REPL use.

  • Pure Python — no dependency on the C client binary; speaks the wire protocol directly over USB serial.
  • Structured results — every command returns a Python dict, not scraped terminal output.
  • Async-first, REPL-friendlyasync/await core with a synchronous proxy (Proxmark3.sync()) that supports dir(), help(), and tab-completion.
  • Live sniff & simulation — stream decoded reader↔tag frames into your REPL in real time; edit an emulated tag and push it live.
  • Software-defined tags — pure-Python transponder models (ISO 14443-A, ISO 15693, MIFARE Classic, NTAG/Type-2, NXP ICODE/SLIX2/NTAG5, LF) served from a firmware response table.
  • Interactive raw consolepm3py rawcli drops into a Proxmark-style TUI for raw command entry: a live annotated trace, hex/binary toggle, and function-style transponder commands.
  • Autocompleting clientpm3py client wraps the stock Proxmark3 client (so it works with any Proxmark/firmware) with full command-tree completion, press-→ help, and the client's own colored output — and can spin off a single-file drop-in for anyone on the stock repo.

Install

Not on PyPI yet — install from a clone. Use --recurse-submodules so the firmware fork (firmware/) comes with it (needed for flashing and the live-sim features):

git clone --recurse-submodules <repo-url> pm3py
cd pm3py
pip install -e .            # or `pip install .`

Requires Python 3.10+; dependencies (pyserial-asyncio, bitarray, pycryptodome, prompt_toolkit, pexpect) install automatically. It's pure Python + pyserial, so it also runs on a Raspberry Pi (aarch64) — see Flashing firmware for the Pi bring-up.

Quick Start

pm3py is async-first, but the sync REPL proxy is the easiest way to start. Proxmark3.sync() auto-detects the serial port, connects, probes the firmware, and returns a proxy where every command runs synchronously:

from pm3py import Proxmark3

pm3 = Proxmark3.sync()                 # auto-detect; or Proxmark3.sync("/dev/ttyACM0")
print(pm3.firmware)                    # version + compatibility, populated on connect
print(pm3.hw.ping())                   # {'success': True, 'length': 32, 'data': '00010203...'}
print(pm3.hw.version()["version_string"])
print(pm3.hf.iso14a.scan())            # scan a 14443-A card
pm3.close()                            # disconnect + close the event loop

The sync proxy is REPL-friendly: dir(pm3), dir(pm3.hf), tab-completion, and help(pm3.hf.iso15.rdbl) all work and report each command's real signature (the proxy uses functools.wraps + __dir__, so wrapped methods keep their __doc__ and parameters instead of showing (*args, **kwargs)).

Prefer async? Use the context manager directly:

import asyncio
from pm3py import Proxmark3

async def main():
    async with Proxmark3() as pm3:          # auto-detect, or Proxmark3("/dev/ttyACM0")
        print(await pm3.hw.ping())
        print(await pm3.hf.iso14a.scan())

asyncio.run(main())

All command methods return structured Python dicts, not parsed terminal text. The command tree mirrors the C client:

Sub-module Protocol
pm3.hw.* Hardware / device control
pm3.lf.*, pm3.lf.t55.* Low frequency + T55xx
pm3.hf.* HF core (tune, search, sniff, dropfield)
pm3.hf.iso14a.* ISO 14443-A
pm3.hf.iso15.* ISO 15693
pm3.hf.mf.* MIFARE Classic
pm3.hf.mfu.* MIFARE Ultralight / NTAG

API

Hardware — pm3.hw.*

pm3.hw.ping()                    # {'success': True, 'length': 32, 'data': '...'}
pm3.hw.ping(length=64)           # ping with a custom echo payload length
pm3.hw.version()                 # {'chip_id': '0x...', 'section_size': ..., 'version_string': '...'}
pm3.hw.status()                  # {'status': 0}
pm3.hw.capabilities()            # {'version', 'baudrate', 'bigbuf_size', 'is_rdv4',
                                 #  'compiled_with_lf', 'compiled_with_iso14443a',
                                 #  'compiled_with_iso15693'}
pm3.hw.tune()                    # full antenna tuning — nested {'lf': {...}, 'hf': {...}}
pm3.hw.dbg()                     # get debug level -> {'level': N}
pm3.hw.dbg(level=2)              # set debug level 0-4
pm3.hw.led("green", on=True)     # LED control by color name
pm3.hw.led("all", off=True)      # turn everything off
pm3.hw.break_loop()              # stop a long-running firmware operation
pm3.hw.reset()                   # reset the device (fire-and-forget)

hw.tune() runs the full LF+HF antenna sweep and returns both bands at once:

t = pm3.hw.tune()
print(t["lf"]["125kHz_V"], t["lf"]["134kHz_V"], t["lf"]["peak_freq_kHz"])
print(t["hf"]["13.56MHz_V"])

hw.led needs our firmware fork (CMD_LED_CONTROL @ 0x011B). It is not in stock upstream firmware — there 0x011A is CMD_SET_HF_FIELD_TIMEOUT and hw.led is a no-op. With the fork flashed (the firmware/ submodule) it drives the LEDs as below.

LED control is platform-aware — color names map to the correct physical LED on both PM3 Easy and RDV4. Accepts color names ("green", "red", "orange", "blue"/"red2", "all"), letters ("a"-"d"), comma-separated combos ("green,red"), or a bitmask int. Pick one action kwarg:

pm3.hw.led("red", on=True)              # on
pm3.hw.led("a", toggle=True)            # toggle
pm3.hw.led("green", blink=True, speed=300, count=10)   # blink (all LEDs)
pm3.hw.led("green", pulse=True)         # PWM pulse — PWM-capable LEDs only
pm3.hw.led("green", brightness=50)      # PWM brightness 0-100

PWM effects (brightness, pulse, fade) validate hardware support and raise PM3Error if the target LED can't do PWM (Easy: A,B; RDV4: A,D).

Also available: pm3.hw.tearoff(delay_us=..., on=True) and pm3.hw.standalone(mode="14a") for tear-off timing and standalone modes.

Low Frequency — pm3.lf.*

pm3.lf.tune()                    # LF antenna voltage @125kHz (divisor 95)
pm3.lf.tune(divisor=88)          # @134kHz — see return shape below
pm3.lf.read(samples=12288)       # capture ADC samples into device buffer
pm3.lf.sniff()                   # alias for read()
pm3.lf.config()                  # get current sampling config
pm3.lf.config(divisor=88)        # set config (134 kHz); fire-and-forget then re-read
pm3.lf.search()                  # capture + return an LFSearchResult
pm3.lf.sim(gap=0, data=b"...")   # simulate an LF tag from the sample buffer

lf.tune(divisor=...) sends the START then MEASURE steps and returns the antenna voltage:

pm3.lf.tune(divisor=95)
# {'voltage_mV': 24875, 'voltage_V': 24.875, 'frequency_kHz': 125.0, 'divisor': 95}

Frequency is 12000 / (divisor + 1) kHz — 95 = 125 kHz, 88 = 134 kHz.

lf.search() returns an LFSearchResult with callable next steps:

result = pm3.lf.search()
print(result)                    # prints available actions + sample count
raw = result.download()          # raw ADC samples as bytes (1 byte/sample, 0-255)
result.tune()                    # check antenna voltage (tag present?)
result.read_t55xx(block=0)       # try reading it as a T55xx

Pure-Python LF demodulation — pm3py.lf. Demod no longer needs the C client's DSP stack: pm3py.lf ports the ASK/FSK/biphase pipeline and decodes the captured samples directly (EM4100, HID, AWID, FDX-B, and the T55xx config block). Hardware-validated.

from pm3py.lf import identify_lf, read_config
identify_lf(pm3)                 # -> {'label': 'T5577 (EM4100 20260716FF)', 'chip': ..., 'emitted': {...}}
read_config(pm3)                 # decode a T55xx block-0 config (modulation, bit-rate, block count)

T55xx tags — pm3.lf.t55.*:

pm3.lf.t55.readbl(block=0)                          # -> {'status', 'data': hex, 'raw': bytes}
pm3.lf.t55.readbl(block=1, password=0x12345678)     # password read
pm3.lf.t55.writebl(block=1, data=0xDEADBEEF)        # write a 32-bit block
pm3.lf.t55.writebl(block=1, data=0x00, password=0x12345678)
pm3.lf.t55.wakeup(password=0x12345678)              # password wakeup
pm3.lf.t55.config()                                 # downlink timing table (client-side)

High Frequency — pm3.hf.*

Core HF operations. tune() runs the firmware START/MEASURE/STOP state machine internally and reports the antenna voltage; search() probes 14443-A then 15693.

pm3.hf.tune()          # → {'voltage_mV': 4521, 'voltage_V': 4.521}
pm3.hf.search()        # try 14a then 15693 → {'iso14443a': {...}, 'found': True}
pm3.hf.sniff()         # sniff raw HF, returns {'status', 'samples'}
pm3.hf.dropfield()     # turn the HF field off (fire-and-forget)

search() returns a dict with an iso14443a and/or iso15693 key when a tag answers, plus found: bool. sniff() accepts samples_to_skip, triggers_to_skip, skip_mode, skip_ratio and returns the captured sample count; for decoded, protocol-aware sniffing (including live streaming) use SniffSession instead.

ISO 14443-A — pm3.hf.iso14a.*

pm3.hf.iso14a.scan()                        # → {'found', 'uid', 'atqa', 'sak', 'ats', ...}
pm3.hf.iso14a.raw(b"\x30\x00")              # raw frame → {'status', 'data', 'raw'}
pm3.hf.iso14a.sniff()                       # sniff 14443-A, returns {'status'}
pm3.hf.iso14a.sim(tagtype=1, uid=b"\x01\x02\x03\x04")   # simulate a tag

scan() selects the card and returns UID/ATQA/SAK plus ats and a select_status (1=OK+ATS, 2=OK no ATS, 3=proprietary). raw() sends a raw frame with the RAW + NO_DISCONNECT flags already set; pass extra flags and a timeout_14a if needed. sim() emulates a tag — tagtype picks the model (1=MIFARE Classic 1k, 2=Ultralight, 3=DESFire, 4=ISO14443-4, 7=MFU EV1/NTAG215, 8=MFC 4k, 11=JCOP, 13=ULC, 14=ULAES, ...), uid is 4/7/10 raw bytes (empty = take UID from emulator memory), and exit_after stops after N reader reads.

For a full software-defined tag served from the firmware response table (NDEF, custom commands, live trace), use SimSession with an NfcType2Tag — see the simulation section.

ISO 15693 — pm3.hf.iso15.*

pm3.hf.iso15.scan()                 # quick single-tag → {'found', 'uid', 'dsfid', 'response_flags'}
pm3.hf.iso15.inventory()            # 16-slot anti-collision → list of tag dicts
pm3.hf.iso15.rdbl(0)                # read block 0
pm3.hf.iso15.wrbl(0, "00112233")    # write block 0
pm3.hf.iso15.sniff()                # sniff 15693 (blocks until button/timeout)

scan() is the "is one tag present" convenience and returns a single dict. inventory() runs a real anti-collision round:

# 16-slot round (default) → LIST of tags (empty list if none)
pm3.hf.iso15.inventory()
# → [{'uid': 'e0040150...', 'dsfid': 0, 'flags': 0, 'slot': 3}, ...]

# single slot → ONE tag dict (or None)
pm3.hf.iso15.inventory(slots=1)
# → {'uid': 'e0040150...', 'dsfid': 0, 'flags': 0}

# filter by AFI (request-level, only matching tags answer)
pm3.hf.iso15.inventory(afi=0x00)

# filter by DSFID (client-side, on the returned records)
pm3.hf.iso15.inventory(dsfid=0)

UIDs come back in display order (already reversed from wire order). Block I/O optionally takes a UID for addressed mode:

pm3.hf.iso15.rdbl(4)                                  # unaddressed read
pm3.hf.iso15.rdbl(4, uid="e004015012345678")          # addressed read
# → {'success': True, 'block': 4, 'locked': False, 'data': '00112233', 'raw': b'...'}

pm3.hf.iso15.wrbl(4, "00112233")                      # unaddressed write (bytes or hex str)
pm3.hf.iso15.wrbl(4, b"\x00\x11\x22\x33", uid="e004015012345678")
# → {'success': True, 'block': 4}

raw_inventory(inv_flags=0x26) is a diagnostic that returns the unparsed device response (0x26 = 1 slot, 0x06 = 16 slots). For decoded/live sniffing use SniffSession.

MIFARE Classic — pm3.hf.mf.*

Renamed: the MIFARE Classic sub-module used to be pm3.hf.mfc. It is now pm3.hf.mf (the backing module is still pm3py/core/hf_mfc.py). Update any old scripts.

Keys are 6 bytes, given as a hex string or bytes. key_type is 0 for key A (default) or 1 for key B.

pm3 = Proxmark3.sync()

# Read one block (default key FFFFFFFFFFFF, key A)
pm3.hf.mf.rdbl(0)
# → {'success': True, 'block': 0, 'data': '0403...', 'raw': b'\x04\x03...'}

pm3.hf.mf.rdbl(4, key="A0A1A2A3A4A5", key_type=1)   # key B

# Write a block (data must be 16 bytes)
pm3.hf.mf.wrbl(4, data="00112233445566778899AABBCCDDEEFF",
               key="FFFFFFFFFFFF", key_type=0)
# → {'success': True, 'block': 4}

# Read a whole sector (sectors 031 = 4 blocks, 3239 = 16 blocks)
pm3.hf.mf.rdsc(0)          # → {'sector': 0, 'first_block': 0, 'num_blocks': 4, 'blocks': {...}}

# Key-check a block against a list of candidate keys
pm3.hf.mf.chk(0, keys=["FFFFFFFFFFFF", "A0A1A2A3A4A5", "D3F7D3F7D3F7"])
# → {'found': True, 'key': 'ffffffffffff', 'key_type': 0}   (or {'found': False})

Nested attack — recover an unknown key using one known key on the card:

pm3.hf.mf.nested(block=0, key="FFFFFFFFFFFF", key_type=0,
                 target_block=4, target_key_type=1, calibrate=True)
# → {'success': True, 'is_ok': 0, 'block': 4, 'key_type': 1,
#    'cuid': '...', 'nt_a': '...', 'ks_a': '...', 'nt_b': '...', 'ks_b': '...'}

Magic-card identification — detect gen1/gen2 "magic" cards:

pm3.hf.mf.cident()
# → {'status': 0, 'magic': 0, 'is_magic': False}

Sniff / simulate:

pm3.hf.mf.sniff()          # routed through the ISO14443-A sniffer

# Simulate a card. size ∈ {"mini","1k","2k","4k"}; UID is 4, 7, or 10 bytes.
pm3.hf.mf.sim(uid="01020304", size="1k")
pm3.hf.mf.sim(uid="04AABBCCDDEE80", size="4k",
              atqa="0044", sak=0x18, exit_after=1, interactive=True)

For full software-defined MIFARE Classic simulation (response table served from firmware, live trace), see the sim framework section and MifareClassicTag.

MIFARE Ultralight / NTAG — pm3.hf.mfu.*

New sub-module for ISO14443-3A tags with 4-byte pages (MIFARE Ultralight, UL-C, UL-EV1, UL-AES, and the NTAG21x family). A READ returns 4 consecutive pages (16 bytes) at once; a WRITE sets a single 4-byte page.

keytype selects the auth scheme (pass the matching key):

keytype scheme key length
0 none (default)
1 UL-C (3DES) 16 bytes
2 UL-EV1 / NTAG password 4 bytes
3 UL-AES 16 bytes
pm3 = Proxmark3.sync()

# Read starting at page 4 → 16 bytes (pages 4,5,6,7)
pm3.hf.mfu.rdbl(4)
# → {'success': True, 'block': 4, 'data': '01020304...', 'raw': b'\x01\x02...'}

# Password-authenticated read (UL-EV1 / NTAG, 4-byte pwd)
pm3.hf.mfu.rdbl(4, key="FFFFFFFF", keytype=2)

# Write one 4-byte page
pm3.hf.mfu.wrbl(4, data="DEADBEEF")
# → {'success': True, 'block': 4}

# Dump pages (each underlying READ pulls 4 pages; stops early on failure)
pm3.hf.mfu.dump(start=0, pages=16)
# → {'success': True, 'pages': 16, 'data': '...', 'raw': b'...'}

For software-defined NTAG/Type-2 simulation (NDEF, served from a firmware response table with live trace), use NfcType2Tag with SimSession — see the sim framework section.

Live Sniff & Simulation

pm3py can drive the Proxmark3 as a live sniffer and as a software-defined card simulator, with reader↔tag traffic streamed into your REPL in real time. Both use custom firmware (the firmware/ submodule / proxmark3-pm3py) and open the serial port directly, so they run from the plain sync REPL — no asyncio needed.

Live sniff — SniffSession

SniffSession starts a firmware sniff and streams decoded frames to the console from a background thread. The Proxmark3 button toggles pause/resume while streaming, so you can walk a card up to the field, pause, inspect, and resume without touching the keyboard. Captured frames accumulate across pause/resume cycles in session.entries.

from pm3py.sniff.session import SniffSession

s = SniffSession.open()          # opens /dev/ttyACM0 by default
s.start_15693(live=True)         # streams ISO 15693 trace live; PM3 button pauses/resumes
# ... present a tag to the antenna; decoded reader/tag frames print as they arrive ...
s.stop()                         # end the sniff (sends BREAK_LOOP)

print(len(s.entries))            # all captured frames across pause/resume cycles
print(s.state)                   # 'sniffing' | 'paused' | 'stopped'
s.entries[0]                     # {'protocol','direction','duration','timestamp','data','data_hex','decoded'}
s.close()                        # stop (if needed) and close the serial port
  • SniffSession.open(port="/dev/ttyACM0", baudrate=115200) — open the port.
  • start_15693(live=True, idle_timeout_ms=400) — live streaming with button-toggle. Pass live=False for the legacy BigBuf batch path (for batch decoding you can also use pm3.hf.iso15.sniff()).
  • s.state, s.entries, s.clear() — inspect and reset accumulated capture.
  • s.stop() / s.close() — halt streaming / release the port.

Status transitions (Started / Paused / Resumed / Stopped) print inline as the button is pressed.

Live sim — SimSession

SimSession compiles a Python tag model into a firmware response table, uploads it, and starts the tag simulation. The firmware answers standard commands (inventory, read, write) autonomously at wire speed. With trace=True, every reader↔tag frame is decoded and streamed live from a background thread. You can then edit the tag in the REPL and push the changes live with tag.sync() — no restart of the sim required.

from pm3py.sim import SimSession, IcodeSlix2Tag, ndef_uri

# Build a tag model (uid=None auto-generates a valid E0 04 02.. SLIX2 UID)
tag = IcodeSlix2Tag(uid=None, ndef_message=ndef_uri("https://dngr.us"))

s = SimSession.open()                       # opens /dev/ttyACM0 by default
s.start_15693(tag, compile=True, trace=True)  # upload table, start sim, stream frames live
# ... a phone / reader now reads the emulated tag; frames print as they arrive ...

# Live edit: change the NDEF payload and push it to the firmware emulator memory
tag.set_ndef(ndef_uri("https://example.org/new"))
tag.sync()                                  # firmware now serves the updated content

s.stop()                                    # stop the sim (sends BREAK_LOOP)
s.close()                                   # stop (if needed) and close the port
  • SimSession.open(port="/dev/ttyACM0", baudrate=115200) — open the port for a synchronous sim.
  • start_15693(tag, compile=True, trace=False) — compile+upload the response table and start the 15693 sim; trace=True streams decoded reader↔tag traffic (and reports RF field strength via the optional on_field_strength callback).
  • tag.sync() — push in-REPL edits (UID, memory, NDEF, access conditions) to firmware emulator memory. It does a smart sync: EML memory push for data changes, response-table recompile for access-condition changes. The tag must be bound to a live session (start_15693 binds it automatically).
  • s.stop() / s.close() — halt the sim / release the port.

A 14443-A sim path (await s.start_14a(tag), WTX relay) also exists but is async and experimental — it requires the async PM3Transport (not the synchronous SimSession.open() port above) and is still under development.

Structured / scriptable access

For scripting you usually want the frames as data, not printed colored text. Both start_15693(...) methods take two optional kwargs (defaults preserve the console behavior above):

  • on_frame=callback — invoked per decoded frame (from the reader thread) with a plain dict. Passing it also auto-enables trace streaming on the sim.
  • quiet=True — don't print frames to the console (the callback and the entries accumulator still fill).

Every frame is an ANSI-free dict:

{'protocol': int, 'direction': int,          # 0 = reader→tag, 1 = tag→reader
 'timestamp': int|None, 'duration': int|None,
 'data': bytes, 'data_hex': str,
 'decoded': str|None}                          # e.g. 'INVENTORY', 'READ BLOCK 4'

(SimSession frames also carry 'crc_fail': bool; sniff frames carry timestamp/duration.)

Push model — react to frames as they arrive, no printing:

from pm3py.sniff.session import SniffSession

reads = []
s = SniffSession.open()
s.start_15693(on_frame=lambda f: reads.append(f["decoded"]), quiet=True)
# ... run a reader against a tag ...
s.stop(); s.close()
print(reads)     # ['INVENTORY', 'READ BLOCK 0', ...] — pure data, no ANSI

Pull model — run quiet, then read the accumulator afterward:

from pm3py.sim import SimSession, IcodeSlix2Tag

s = SimSession.open()
s.start_15693(IcodeSlix2Tag(uid=None), trace=True, quiet=True)   # capture, no printing
# ... phone reads the tag ...
s.stop()
for f in s.entries:                       # list of frame dicts (a copy)
    print(f["direction"], f["data_hex"], f["decoded"])
s.clear(); s.close()

SimSession.entries mirrors SniffSession.entries; both have .clear().

Card simulation

The simulation framework (pm3py/sim/ infrastructure, pm3py/transponders/ tag models) provides pure-Python transponder models served from the firmware response table. Import everything from pm3py.sim.

ISO 15693 (start_15693)

  • NfcType5Tag — generic NFC Forum Type 5 tag with NDEF (ndef_text, ndef_uri, ndef_mime).
  • IcodeSlix2Tag (and IcodeSlixTag, Icode3Tag, IcodeDnaTag, NxpIcodeTag) — NXP ICODE family with passwords, privacy mode, EAS/AFI, originality signature.
  • Ntag5SwitchTag, Ntag5LinkTag, Ntag5BoostTag (base Ntag5PlatformTag) — NXP NTAG 5 dual-interface family.

ISO 14443-A (start_14a, async/experimental)

  • NfcType2Tag — NFC Forum Type 2 (MIFARE Ultralight / NTAG-style) with NDEF.
  • NfcType4Tag, MifareClassicTag, DesfireTag — Type 4, MIFARE Classic (Crypto1), and DESFire models.
from pm3py.sim import SimSession, NfcType5Tag, ndef_uri

# uid=None auto-generates a valid E0-prefixed 15693 UID
tag = NfcType5Tag(uid=None, ndef_message=ndef_uri("https://dngr.us"))
s = SimSession.open()
s.start_15693(tag, trace=True)   # emulate an NFC Type 5 tag to a 15693 reader
s.stop(); s.close()

NDEF helpers ndef_text(text, lang="en"), ndef_uri(uri), and ndef_mime(mime_type, data) build the message bytes passed as ndef_message= (or via tag.set_ndef(...) for live edits). uid=None on the 15693 models auto-generates a valid E0-prefixed UID with the correct manufacturer/IC-type bytes so phones identify the IC correctly.

Raw Commands

For anything not yet wrapped, drop down to the raw NG/MIX frame senders. Both are regular client methods, so they work through the sync REPL proxy or await in async code:

from pm3py import Cmd

# NG frame: cmd + raw payload bytes
resp = pm3.send_ng(Cmd.STATUS)
resp = pm3.send_ng(Cmd.CAPABILITIES)

# MIX frame: cmd + three uint64 args + optional payload
resp = pm3.send_mix(Cmd.HF_ISO14443A_READER, arg0=0x0103)

# resp is a PM3Response: resp.cmd, resp.status, resp.data (bytes)
print(resp.status, resp.data.hex(), len(resp.data))

Signatures: send_ng(cmd, payload=b"", timeout=2.0) and send_mix(cmd, arg0=0, arg1=0, arg2=0, payload=b"", timeout=2.0). Both return a PM3Response (fields: cmd, status, reason, ng, data, oldarg). Use these only for commands the firmware replies to — fire-and-forget commands (e.g. HF_DROPFIELD) send no response and would block until the timeout; call the wrapped method (pm3.hf.dropfield()) for those.

REPL discovery: the sync proxy returned by Proxmark3.sync() implements __dir__ and uses functools.wraps on every wrapped method, so dir(pm3), tab-completion, and help(pm3.hf.iso15.rdbl) all surface the real sub-modules, method names, and signatures (e.g. rdbl(block, uid=None)) instead of an opaque (*args, **kwargs) wrapper.

pm3py rawcli — interactive raw-command console

pm3py rawcli opens an interactive, Proxmark-style console for driving a connected device at the raw level — a live annotated trace, flexible hex/binary entry, and function-style transponder commands. It's a separate tool from the Python REPL: its own command grammar, not Python.

pm3py rawcli                     # auto-detect the device (runs offline if none attached)
[raw / 0x] identify                    # probe the field; sets HF/LF + transponder, keeps the link
NTAG213  (HF / 14a)  UID 04A1B2C3

[raw / hf / NTAG213 / 0x] READ(4)      # function-style command from the tag's catalog
[Rdr] Reader → Tag:  30 04          READ page 4
[Rdr] Tag → Reader:  01 02 03 04 …

[raw / hf / NTAG213 / 0x] 30 04        # …or send raw bytes directly
[raw / hf / NTAG213 / 0b] 30 00000100  # per-byte base: hex 30 + a binary byte → sends 0x30 0x04
[raw / hf / NTAG213 / 0x] tlv 00 03 09 D1 01 05 54 02 65 6E 48 69 FE   # decode a TLV / NDEF block
  • Breadcrumb prompt [raw / hf|lf / <transponder> / 0x|0b] reflects the field, the identified tag, and the current numeric entry mode.
  • Per-byte base entry — type hex (30 04) or binary; Ctrl-/ toggles 0x/0b and digits auto-group into bytes (a byte seals at 2 hex / 8 binary). Base is tracked per byte: the toggle only changes the base of the next byte, so 30 then toggle then 00000100 reads as 30 00000100 and sends 0x30 0x04. A 0x/0b prefix overrides for one token; function-call args are base-10 (READ(4), READ(0x2B)). Mixing a command with raw bytes on one line is an error, not a transmit.
  • identify probes 14a → 15693 → LF and keeps the tag selected; transponder / close manage the connection.
  • Function-style commands from the identified tag's catalog: NTAG/Ultralight (READ, FAST_READ, GET_VERSION, WRITE, …), MIFARE Classic (CHK(block) to find the sector key, then authenticated READ(block[, key][, A|B]) / WRITE), ISO 15693, and LF T5577 (READ/WRITE/DETECT). help lists them, help READ shows one.
  • Completion + memory map — command names, opcodes matched by the hex/binary value you type, and the identified tag's datasheet memory map all surface in the completion menu, rendered in whichever base you're entering. Page/block roles are named (UID, CC/OTP, user memory, CFG0/CFG1, PWD/PACK on NTAG21x/Ultralight; manufacturer block and sector trailers — Key A / access bits / Key B — on MIFARE Classic).
  • Live annotated trace — every exchange prints the reader↔tag frames with decoded annotations.
  • TLV / NDEFtlv <hex> prints a structured multi-line breakdown; a multi-line editor composes long TLVs.

pm3py client — autocompleting console for the stock Proxmark3 client

Where rawcli speaks pm3py's own protocol stack, pm3py client wraps the standard Proxmark3 C client — so it works with any Proxmark3 and any firmware the stock client supports. You get the real client's exact behavior and its native colored output, with autocompletion and inline help layered over its full command tree.

pm3py client                     # auto-detect the device and the client
pm3py client --driver pexpect    # force the binary backend (no SWIG build needed)
pm3py client --port /dev/ttyACM0 --commands /path/to/commands.json
[client / usb] pm3py --> hf mf rd⇥        # Tab completes to `hf mf rdbl`, then descends to its options
[client / usb] pm3py --> hf mf rdbl --blk 0 -k FFFFFFFFFFFF
 [+]   0 | 04 A1 B2 C3 D4 08 04 00 62 63 64 65 66 67 68 69 |   # the client's own colored output
[client / usb] pm3py --> hf 14a in⇥       # → on the highlighted `info` opens its full help in the bar
  • Full command-tree completion — completes each level (hfhf mfhf mf rdbl) and a command's option flags (--blk, -k), each with its description as the tooltip. Tab commits the highlighted item and descends to the next level.
  • Press-→ help — with a completion highlighted, expands its full help (description, usage, options, notes) in the bottom bar; / Esc / Ctrl-g backs out. Otherwise the bar shows a one-line hint for the current command.
  • The client's own output — commands run through the real client and print its native colored output verbatim; the breadcrumb [client / usb] reflects the live connection.
  • Two backends (--driver auto, the default):
    • SWIG — the in-process _pm3 bindings (console() + captured output); needs the client's experimental_lib extension built, and a connected device.
    • pexpect — spawns the proxmark3 binary and reads its prompts; no build required, and runs offline. Used automatically when _pm3 isn't importable, or forced with --driver pexpect.
  • Command tree from the client — the menu is built from the checkout's doc/commands.json (the same set the client itself exposes), auto-located from the repo; override with --commands.

Distributing a drop-in

pm3py client --pack spins off a self-contained copy for people who use the stock Iceman/RRG Proxmark3 repo but not pm3py:

pm3py client --pack pm3cli.py     # one readable file — needs `pip install prompt_toolkit pexpect`
pm3py client --pack pm3cli.pyz    # a zipapp with those deps bundled — runs on bare `python3`

Drop the file into (or beside) a Proxmark3 checkout and run it (python3 pm3cli.py). It finds the checkout's doc/commands.json and client automatically — walking up from where it's dropped, or via $PM3_REPO — auto-detects /dev/ttyACM*, and gives the same console. The file is generated from pm3py/cli/clientcli/ (the tested source of truth), so it never drifts.

Firmware Compatibility

On connect, pm3py pings the device and fetches the firmware version. The result is on pm3.firmware:

pm3 = Proxmark3.sync()
print(pm3.firmware.compatible)       # True if the NG protocol handshake worked
print(pm3.firmware.version_string)   # firmware version string
print(pm3.firmware.chip_id)          # e.g. "0x270B0A40"
print(pm3.firmware.warnings)         # any issues detected during probe

Core reader/sniff/HW commands work against stock RfidResearchGroup/proxmark3 firmware. The live streaming features (streaming sniff and the response-table / trace card simulation) require our firmware fork, tracked as the firmware/ git submodule (proxmark3-pm3py, rebased onto upstream RfidResearchGroup/proxmark3). Those add the CMD_HF_SNIFF_STREAM live-frame path and the BigBuf response-table + WTX relay used by SimSession. Everything else degrades gracefully on stock firmware. To put the fork on your device, use pm3flash (below).

Flashing firmware (pm3flash)

The live features (streaming sniff, response-table/trace card sim, hw.led) need the firmware fork (proxmark3-pm3py, the firmware/ submodule). pm3py flashes it for you over USB — a pure-Python flasher, no C pm3-flash/DFU/JTAG. Each pm3py release is pinned to a firmware build, and pm3.firmware.matches_pin tells you whether your device runs it.

pm3flash                        # detect the device; flash the pinned firmware if it differs
pm3flash --image fullimage.elf  # flash a specific prebuilt image
pm3flash --build PM3GENERIC     # build from the submodule (Easy), then flash
pm3flash --force                # flash even if the device already matches the pin

pm3flash auto-detects the port, compares the device firmware to the pin, then (fullimage only) flashes and verifies. It's bootrom-safe — it never writes the bootloader, so a bad flash is recoverable, not a brick.

Image resolution order: --image, then $PM3PY_FIRMWARE, then a local build at firmware/armsrc/obj/fullimage.elf. Build one with:

make -C firmware PLATFORM=PM3GENERIC armsrc/all   # PM3 Easy / generic
make -C firmware PLATFORM=PM3RDV4  armsrc/all     # RDV4

The firmware is an ARM cross-compile (needs gcc-arm-none-eabi + make; the FPGA bitstream is committed, so no FPGA toolchain), and the fullimage.elf it produces is the same regardless of build host. Choose PLATFORM yourself — pm3py can't infer it, because a device's is_rdv4 flag reports the firmware it's running, not the physical board.

On a Raspberry Pi: pip install pm3py on the Pi (works on aarch64), then either cross-build on another Linux box and copy the image over —

make -C firmware PLATFORM=PM3GENERIC armsrc/all      # dev box
scp firmware/armsrc/obj/fullimage.elf pi@rpi:~/
pm3flash --image ~/fullimage.elf                     # on the Pi

— or build on the Pi itself (with gcc-arm-none-eabi + make): pm3flash --build PM3GENERIC.

Programmatic (the CLI wraps pm3.flasher):

pm3 = Proxmark3.sync()
pm3.firmware.matches_pin            # False -> not on the pinned build
pm3.firmware.expected_firmware      # the pinned firmware id
pm3.flasher.flash("firmware/armsrc/obj/fullimage.elf")   # enter bootloader, write, reset, verify

Architecture

pm3py/
├── core/            # wire protocol + device command modules
│   ├── protocol.py      # constants, CRC-16/A, Cmd enum, PM3Status
│   ├── transport.py     # NG/MIX frame encode/decode, PM3Transport (async serial)
│   ├── client.py        # Proxmark3, _SyncProxy, FirmwareInfo
│   ├── hw.py            # hw.* (ping, version, tune, LEDs, dbg, reset)
│   ├── hf.py            # hf.* core (tune, search, sniff, dropfield)
│   ├── hf_iso14a.py    # hf.iso14a.* (ISO 14443-A)
│   ├── hf_iso15.py     # hf.iso15.* (ISO 15693)
│   ├── hf_mfc.py       # hf.mf.*  (MIFARE Classic)
│   ├── hf_mfu.py       # hf.mfu.* (MIFARE Ultralight / NTAG)
│   └── lf.py           # lf.* + lf.t55.* (T55xx) + LFSearchResult
├── trace/           # firmware trace parsing + annotation (shared by sniff & sim)
│   ├── trace.py         # parse_tracelog
│   ├── decode_iso14a.py / decode_iso15.py   # per-protocol decoders
│   ├── ndef.py          # NDEF TLV/record decode
│   └── format.py        # ANSI color trace formatting
├── cli/            # `pm3py` console-script: rawcli (raw-command TUI) + clientcli (`pm3py client`)
├── sniff/           # SniffSession — protocol detection, live streaming lifecycle
├── sim/             # card-sim infrastructure: SimSession, response-table compiler,
│                    #   Medium/Reader models, MCU bridge, replay/relay/fuzzer
└── transponders/    # pure-Python tag models
    ├── hf/iso14443a/    # Tag14443A, MifareClassic, DESFire, NfcType2/4
    ├── hf/iso15693/     # Tag15693, NfcType5, ICODE/SLIX2/DNA/NTAG5
    └── lf/              # EM4100, HID, T5577
  • core/ is the wire stack: transport.py handles serial I/O and NG/MIX frame encode/decode; command modules send structured commands and parse responses into dicts. USB connections skip CRC (matching the C client); FPC UART uses CRC-16/A with byte-swapped wire encoding.
  • trace/ parses the firmware trace log and annotates it (per-protocol decoders + NDEF), used by both sniff and sim.
  • sniff/ orchestrates protocol detection and the live CMD_HF_SNIFF_STREAM streaming loop.
  • sim/ + transponders/ implement software-defined tags served from a firmware BigBuf response table, with a WTX relay for real-time Layer-4 crypto.

Tests

cd pm3py
python -m pytest tests/ -v

All tests use mock transports — no hardware required.

License

MIT — see LICENSE.

Description
No description provided
Readme MIT 2.8 MiB
Languages
Python 100%