Bump firmware pin to 3b6201d97, which bounds the 14a sim button / host-stop latency under continuous reader traffic (was multi-second, now ~10ms). SimSession now treats an HF_MIFARE_SIMULATE (0x0610) reply as end-of-sim for 14a: the sim starts with HF_ISO14443A_SIMULATE but the firmware tears down with HF_MIFARE_SIMULATE, so _trace_reader and _relay_loop watch for it and flip _active off on a button-driven stop (previously the host never noticed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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-friendly —
async/awaitcore with a synchronous proxy (Proxmark3.sync()) that supportsdir(),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.
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)
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.ledneeds our firmware fork (CMD_LED_CONTROL@0x011B). It is not in stock upstream firmware — there0x011AisCMD_SET_HF_FIELD_TIMEOUTandhw.ledis a no-op. With the fork flashed (thefirmware/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 (it captures raw samples; full EM410x/HID/AWID demodulation still needs the C client's DSP stack):
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
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 nowpm3.hf.mf(the backing module is stillpm3py/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 0–31 = 4 blocks, 32–39 = 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. Passlive=Falsefor the legacy BigBuf batch path (for batch decoding you can also usepm3.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=Truestreams decoded reader↔tag traffic (and reports RF field strength via the optionalon_field_strengthcallback).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_15693binds 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 theentriesaccumulator 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(andIcodeSlixTag,Icode3Tag,IcodeDnaTag,NxpIcodeTag) — NXP ICODE family with passwords, privacy mode, EAS/AFI, originality signature.Ntag5SwitchTag,Ntag5LinkTag,Ntag5BoostTag(baseNtag5PlatformTag) — 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 usesfunctools.wrapson every wrapped method, sodir(pm3), tab-completion, andhelp(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.
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
├── 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.pyhandles 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 liveCMD_HF_SNIFF_STREAMstreaming 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.