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>
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.
- Interactive raw console —
pm3py rawclidrops into a Proxmark-style TUI for raw command entry: a live annotated trace, hex/binary toggle, and function-style transponder commands. - Autocompleting client —
pm3py clientwraps 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.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:
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 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.
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-/ toggles0x/0band 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, so30then toggle then00000100reads as30 00000100and sends0x30 0x04. A0x/0bprefix 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. identifyprobes 14a → 15693 → LF and keeps the tag selected;transponder/closemanage 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 authenticatedREAD(block[, key][, A|B])/WRITE), ISO 15693, and LF T5577 (READ/WRITE/DETECT).helplists them,help READshows 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 / NDEF —
tlv <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 (
hf→hf mf→hf 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
_pm3bindings (console()+ captured output); needs the client'sexperimental_libextension built, and a connected device. - pexpect — spawns the
proxmark3binary and reads its prompts; no build required, and runs offline. Used automatically when_pm3isn't importable, or forced with--driver pexpect.
- SWIG — the in-process
- 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.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.