Lands F1 (CMD_HF_ISO14443A_SIM_TRACE 0x0339 + FLAG_SIM_TRACE) and F2+F3 (trace ring + hooks in SimulateIso14443aTagEx). Builds clean for PM3GENERIC. Co-Authored-By: Claude Fable 5 <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
cd pm3py
pip install -e .
Requires Python 3.10+ and pyserial-asyncio>=0.6 (installed automatically).
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.
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.