michael a24f0f803f feat(sim): reject wrong-protocol tags in start_14a/start_15693; export tags; uid getter
- start_14a now raises TypeError for a non-Tag14443A transponder, start_15693
  likewise for a non-Tag15693 (local imports to avoid the base<->sim circular
  import). Prevents silently starting a sim with an incompatible tag model.
- Re-export the full 14a/14b/15693 tag lineup (NTAG21x, Ultralight family, NTAG
  I2C, ST25*, OPTIGA, ISO14443B, TI parts) from pm3py.sim for `from pm3py.sim
  import NTAG213`-style use.
- Add a read-only `uid` property on the Transponder base (mirrors set_uid()).
- Tests for both guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:58:37 -07:00
2026-07-05 09:53:52 -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.

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.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 (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 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.

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.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.

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