Adds a 'Structured / scriptable access' subsection under Live Sniff & Simulation covering the on_frame callback, quiet mode, the ANSI-free frame dict shape, and push vs pull (entries) usage for both SniffSession and SimSession. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
555 lines
26 KiB
Markdown
555 lines
26 KiB
Markdown
# pm3py
|
||
|
||
Pure-Python async client for the [Proxmark3](https://github.com/RfidResearchGroup/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`/`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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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.*`
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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 firmware with `CMD_LED_CONTROL`** (our LED port — currently a
|
||
> pending follow-up). On stock/current firmware `0x011A` is `CMD_SET_HF_FIELD_TIMEOUT`,
|
||
> so `hw.led` is presently a no-op. The API below is what it will drive once that
|
||
> firmware lands.
|
||
|
||
**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:
|
||
|
||
```python
|
||
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.*`
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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):
|
||
|
||
```python
|
||
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.*`:**
|
||
|
||
```python
|
||
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.
|
||
|
||
```python
|
||
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.*`
|
||
|
||
```python
|
||
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.*`
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
# 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:
|
||
|
||
```python
|
||
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.
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
pm3.hf.mf.cident()
|
||
# → {'status': 0, 'magic': 0, 'is_magic': False}
|
||
```
|
||
|
||
**Sniff / simulate:**
|
||
|
||
```python
|
||
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 |
|
||
|
||
```python
|
||
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`.
|
||
|
||
```python
|
||
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'}
|
||
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.
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
{'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:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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.
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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
|
||
|
||
```bash
|
||
cd pm3py
|
||
python -m pytest tests/ -v
|
||
```
|
||
|
||
All tests use mock transports — **no hardware required**.
|