Files
pm3py/docs/plans/2026-03-19-live-sniff-plan.md
michael 615d4a6115 docs: roadmap index + custom-14a-command plans + backfill plan docs
- Add docs/plans/README.md as the roadmap index cataloguing all design/plan docs.
- Add the custom ISO14443-A command handling design + plan (L3 NTAG I2C
  SECTOR_SELECT / cross-sector reads + native auth; L4 static APDUs + WTX relay),
  produced from a multi-agent design workflow.
- Backfill previously-uncommitted plan docs (trace-formatter, ndef-trace-decode,
  live-sniff, firmware-upstream-rebase, 14a-live-trace) and NTAG5_SECURITY.md.
- Sync CLAUDE.md package structure with the committed transponder models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:58:48 -07:00

1109 lines
37 KiB
Markdown

# Live Sniff Streaming Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rewrite sniff from batch download to live-streaming with button-toggle pause/resume, auto-loaded in the REPL.
**Architecture:** Firmware sends trace entries as fire-and-forget `reply_ng()` packets on RF idle (reusing sim's ring buffer pattern). Python `SniffSession` receives in a background thread (mirroring `SimSession._trace_reader`). `.pythonstartup.py` auto-creates `session` in sniff mode.
**Tech Stack:** C (firmware ARM), Python 3.12 (pyserial, threading), pytest with mocking
**Design doc:** `docs/plans/2026-03-19-live-sniff-design.md`
---
### Task 1: Firmware — Add command IDs and structs
**Files:**
- Modify: `firmware/include/pm3_cmd.h:734` (after CMD_HF_ISO15693_SIM_TRACE)
- Modify: `pm3py/core/protocol.py:203` (after HF_ISO15693_EML_SETMEM)
**Step 1: Add firmware command definitions**
In `firmware/include/pm3_cmd.h`, after line 734 (`CMD_HF_ISO15693_SIM_TRACE`), add:
```c
#define CMD_HF_SNIFF_STREAM 0x0337 // FW->Host: live sniff trace entry
#define CMD_HF_SNIFF_STATUS 0x0338 // FW->Host: sniff state change
// Sniff streaming flags (sent in sniff command payload)
#define SNIFF_FLAG_STREAMING 0x01
#define SNIFF_FLAG_BUTTON_TOGGLE 0x02
// Sniff state values (in CMD_HF_SNIFF_STATUS payload)
#define SNIFF_STATE_STARTED 0
#define SNIFF_STATE_PAUSED 1
#define SNIFF_STATE_RESUMED 2
#define SNIFF_STATE_STOPPED 3
```
**Step 2: Add Python Cmd enum entries**
In `pm3py/core/protocol.py`, after `HF_ISO15693_EML_SETMEM = 0x0331` (line 203), add:
```python
HF_SNIFF_STREAM = 0x0337
HF_SNIFF_STATUS = 0x0338
```
Also add constants after the `Cmd` class:
```python
# Sniff streaming flags
SNIFF_FLAG_STREAMING = 0x01
SNIFF_FLAG_BUTTON_TOGGLE = 0x02
# Sniff states
SNIFF_STATE_STARTED = 0
SNIFF_STATE_PAUSED = 1
SNIFF_STATE_RESUMED = 2
SNIFF_STATE_STOPPED = 3
```
**Step 3: Commit**
```bash
git add firmware/include/pm3_cmd.h pm3py/core/protocol.py
git commit --no-gpg-sign -m "feat: add CMD_HF_SNIFF_STREAM/STATUS command IDs and constants"
```
---
### Task 2: Firmware — Streaming sniff loop
**Files:**
- Modify: `firmware/armsrc/iso15693.c:1654-1875` (SniffIso15693)
- Modify: `firmware/armsrc/appmain.c:1500-1503` (CMD_HF_ISO15693_SNIFF handler)
**Step 1: Modify appmain.c to pass payload to SniffIso15693**
Currently (line 1500-1503):
```c
case CMD_HF_ISO15693_SNIFF: {
SniffIso15693(0, NULL, false);
reply_ng(CMD_HF_ISO15693_SNIFF, PM3_SUCCESS, NULL, 0);
break;
}
```
Change to:
```c
case CMD_HF_ISO15693_SNIFF: {
uint16_t idle_timeout_ms = 0;
uint8_t sniff_flags = 0;
if (packet->length >= 3) {
idle_timeout_ms = packet->data.asBytes[0] | (packet->data.asBytes[1] << 8);
sniff_flags = packet->data.asBytes[2];
}
SniffIso15693(0, NULL, false, idle_timeout_ms, sniff_flags);
if (!(sniff_flags & SNIFF_FLAG_STREAMING)) {
// Legacy mode: send completion response (streaming mode sends STATUS)
reply_ng(CMD_HF_ISO15693_SNIFF, PM3_SUCCESS, NULL, 0);
}
break;
}
```
**Step 2: Update SniffIso15693 signature and add streaming mode**
In `firmware/armsrc/iso15693.c`, change the function signature (line 1654):
```c
void SniffIso15693(uint8_t jam_search_len, uint8_t *jam_search_string, bool iclass,
uint16_t idle_timeout_ms, uint8_t sniff_flags) {
```
After the existing init code (after line 1698, the DMA setup), add the streaming mode branch. The key change: when `sniff_flags & SNIFF_FLAG_STREAMING`, use a ring buffer + idle flush loop instead of LogTrace to BigBuf.
Insert after the DMA setup succeeds (after line 1697), before the state variables (line 1699):
```c
// --- Streaming mode ---
if (sniff_flags & SNIFF_FLAG_STREAMING) {
// Ring buffer (same pattern as SimTagIso15693)
#define SNIFF_TRACE_ENTRIES 8
#define SNIFF_TRACE_ENTRY_MAX 72 // 8-byte header + 64 data
static uint8_t sniff_ring[SNIFF_TRACE_ENTRIES][SNIFF_TRACE_ENTRY_MAX];
static uint16_t sniff_ring_len[SNIFF_TRACE_ENTRIES];
uint8_t sniff_wr = 0, sniff_rd = 0;
uint16_t frame_count = 0;
uint32_t last_activity = 0;
bool sniff_paused = false;
// Macro: push trace entry with full metadata
// Layout: [protocol(1) | direction(1) | duration(2) | timestamp(4) | data(*)]
#define SNIFF_PUSH(dir, data_ptr, data_len, ts, dur) do { \
if (((sniff_wr + 1) % SNIFF_TRACE_ENTRIES) != sniff_rd) { \
uint8_t *_e = sniff_ring[sniff_wr]; \
_e[0] = 0; /* protocol: 0=15693 */ \
_e[1] = (dir); \
_e[2] = (dur) & 0xFF; _e[3] = ((dur) >> 8) & 0xFF; \
_e[4] = (ts) & 0xFF; _e[5] = ((ts) >> 8) & 0xFF; \
_e[6] = ((ts) >> 16) & 0xFF; _e[7] = ((ts) >> 24) & 0xFF; \
uint16_t _dl = (data_len); \
if (_dl > SNIFF_TRACE_ENTRY_MAX - 8) _dl = SNIFF_TRACE_ENTRY_MAX - 8; \
memcpy(_e + 8, (data_ptr), _dl); \
sniff_ring_len[sniff_wr] = 8 + _dl; \
sniff_wr = (sniff_wr + 1) % SNIFF_TRACE_ENTRIES; \
} \
} while(0)
#define SNIFF_FLUSH() do { \
while (sniff_rd != sniff_wr) { \
reply_ng(CMD_HF_SNIFF_STREAM, PM3_SUCCESS, \
sniff_ring[sniff_rd], sniff_ring_len[sniff_rd]); \
sniff_rd = (sniff_rd + 1) % SNIFF_TRACE_ENTRIES; \
} \
} while(0)
// Send started status
uint8_t status_buf[3] = { SNIFF_STATE_STARTED, 0, 0 };
reply_ng(CMD_HF_SNIFF_STATUS, PM3_SUCCESS, status_buf, 3);
LED_A_ON(); // green = sniffing active
// --- Streaming sniff main loop ---
// (This duplicates the DMA decode logic from the legacy path below,
// but replaces LogTrace calls with SNIFF_PUSH and adds idle flush +
// button toggle + BREAK_LOOP check.)
bool tag_is_active = false;
bool reader_is_active = false;
bool expect_tag_answer = false;
bool expect_fsk_answer = false;
bool expect_fast_answer = true;
int dma_start_time = 0;
int samples = 0;
const uint16_t *upTo = dma->buf;
bool exit_sniff = false;
while (!exit_sniff) {
volatile int behind_by = ((uint16_t *)AT91C_BASE_PDC_SSC->PDC_RPR - upTo) & (DMA_BUFFER_SIZE - 1);
if (behind_by < 1) {
// No new DMA data — check idle flush, button, break
if (!sniff_paused && sniff_wr != sniff_rd && last_activity > 0) {
uint32_t now = GetTickCount();
if ((now - last_activity) > idle_timeout_ms) {
SNIFF_FLUSH();
}
}
// Check for Python BREAK_LOOP
if (data_available()) {
PacketCommandNG rx;
if (receive_ng(&rx) == PM3_SUCCESS && rx.cmd == CMD_BREAK_LOOP) {
SNIFF_FLUSH();
exit_sniff = true;
}
}
continue;
}
samples++;
if (samples == 1) {
dma_start_time = GetCountSspClk() & 0xfffffff0;
}
volatile uint16_t sniffdata = 0;
volatile uint16_t sniffdata_prev = sniffdata;
sniffdata = *upTo++;
if (upTo >= dma->buf + DMA_BUFFER_SIZE) {
upTo = dma->buf;
if (AT91C_BASE_SSC->SSC_SR & (AT91C_SSC_ENDRX)) {
if (AT91C_BASE_PDC_SSC->PDC_RCR == 0) {
AT91C_BASE_PDC_SSC->PDC_RPR = (uint32_t) dma->buf;
AT91C_BASE_PDC_SSC->PDC_RCR = DMA_BUFFER_SIZE;
}
if (AT91C_BASE_PDC_SSC->PDC_RNCR == 0) {
AT91C_BASE_PDC_SSC->PDC_RNPR = (uint32_t) dma->buf;
AT91C_BASE_PDC_SSC->PDC_RNCR = DMA_BUFFER_SIZE;
}
WDT_HIT();
// Button toggle
if (BUTTON_PRESS()) {
SpinDelay(200); // debounce
while (BUTTON_PRESS()) { WDT_HIT(); } // wait for release
SpinDelay(200); // debounce release
if (sniff_paused) {
sniff_paused = false;
LED_A_ON();
status_buf[0] = SNIFF_STATE_RESUMED;
status_buf[1] = frame_count & 0xFF;
status_buf[2] = (frame_count >> 8) & 0xFF;
reply_ng(CMD_HF_SNIFF_STATUS, PM3_SUCCESS, status_buf, 3);
} else {
SNIFF_FLUSH();
sniff_paused = true;
LED_A_OFF();
status_buf[0] = SNIFF_STATE_PAUSED;
status_buf[1] = frame_count & 0xFF;
status_buf[2] = (frame_count >> 8) & 0xFF;
reply_ng(CMD_HF_SNIFF_STATUS, PM3_SUCCESS, status_buf, 3);
}
}
// Check for Python BREAK_LOOP
if (data_available()) {
PacketCommandNG rx;
if (receive_ng(&rx) == PM3_SUCCESS && rx.cmd == CMD_BREAK_LOOP) {
SNIFF_FLUSH();
exit_sniff = true;
}
}
}
}
if (sniff_paused) continue;
// --- Reader decode (same as legacy) ---
if (tag_is_active == false) {
int extra_8s = 1;
if (Handle15693SampleFromReader((sniffdata & 0x02) >> 1, &dreader) ||
(++extra_8s && Handle15693SampleFromReader(sniffdata & 0x01, &dreader))) {
if (dreader.byteCount > 0) {
uint32_t eof_time = dma_start_time + (samples * 16) + (extra_8s * 8) - DELAY_READER_TO_ARM_SNIFF;
uint32_t sof_time = eof_time
- dreader.byteCount * (dreader.Coding == CODING_1_OUT_OF_4 ? 1024 : 16384)
- 256 - 128;
uint32_t ts_us = (sof_time * 4) / 14; // ssp_clk*4 -> ~us
uint16_t dur = (uint16_t)(((eof_time - sof_time) * 4) / 14);
SNIFF_PUSH(0, dreader.output, dreader.byteCount, ts_us, dur);
frame_count++;
last_activity = GetTickCount();
if (iclass == false) {
expect_fsk_answer = dreader.output[0] & ISO15_REQ_SUBCARRIER_TWO;
expect_fast_answer = dreader.output[0] & ISO15_REQ_DATARATE_HIGH;
}
}
DecodeTagReset(&dtag);
DecodeTagFSKReset(&dtagfsk);
reader_is_active = false;
expect_tag_answer = true;
} else {
reader_is_active = (dreader.state >= STATE_READER_RECEIVE_DATA_1_OUT_OF_4);
}
}
// --- Tag decode: single subcarrier (same as legacy) ---
if ((reader_is_active == false) && expect_tag_answer) {
if (expect_fsk_answer == false) {
if (Handle15693SamplesFromTag((sniffdata >> 4) << 2, &dtag, expect_fast_answer)) {
uint32_t eof_time = dma_start_time + (samples * 16) - DELAY_TAG_TO_ARM_SNIFF;
if (dtag.lastBit == SOF_PART2) {
eof_time -= (8 * 16);
}
uint32_t sof_time = eof_time
- dtag.len * 1024 - 512
- (dtag.lastBit != SOF_PART2 ? 512 : 0);
uint32_t ts_us = (sof_time * 4) / 14;
uint16_t dur = (uint16_t)(((eof_time - sof_time) * 4) / 14);
SNIFF_PUSH(1, dtag.output, dtag.len, ts_us, dur);
frame_count++;
last_activity = GetTickCount();
DecodeTagReset(&dtag);
DecodeTagFSKReset(&dtagfsk);
DecodeReaderReset(&dreader);
expect_tag_answer = false;
tag_is_active = false;
} else {
tag_is_active = (dtag.state >= STATE_TAG_RECEIVING_DATA);
}
} else {
// --- Tag decode: dual subcarrier FSK (same as legacy) ---
if (FREQ_IS_0((sniffdata >> 2) & 0x3)) {
sniffdata = sniffdata_prev;
}
if (Handle15693FSKSamplesFromTag((sniffdata >> 2) & 0x3, &dtagfsk, expect_fast_answer)) {
if (dtagfsk.len > 0) {
uint32_t eof_time = dma_start_time + (samples * 16) - DELAY_TAG_TO_ARM_SNIFF;
if (dtagfsk.lastBit == SOF) {
eof_time -= (8 * 16);
}
uint32_t sof_time = eof_time
- dtagfsk.len * 1016 - 512
- (dtagfsk.lastBit != SOF ? 512 : 0);
uint32_t ts_us = (sof_time * 4) / 14;
uint16_t dur = (uint16_t)(((eof_time - sof_time) * 4) / 14);
SNIFF_PUSH(1, dtagfsk.output, dtagfsk.len, ts_us, dur);
frame_count++;
last_activity = GetTickCount();
}
DecodeTagFSKReset(&dtagfsk);
DecodeReaderReset(&dreader);
expect_tag_answer = false;
tag_is_active = false;
expect_fsk_answer = false;
} else {
tag_is_active = (dtagfsk.state >= STATE_FSK_RECEIVING_DATA_484);
}
}
}
}
// Cleanup and send stopped status
FpgaDisableTracing();
switch_off();
status_buf[0] = SNIFF_STATE_STOPPED;
status_buf[1] = frame_count & 0xFF;
status_buf[2] = (frame_count >> 8) & 0xFF;
reply_ng(CMD_HF_SNIFF_STATUS, PM3_SUCCESS, status_buf, 3);
return;
}
// --- Legacy mode (existing code, unchanged from here) ---
```
Also update the function declaration in `iso15693.h` (if it exists) or any forward declarations.
**Step 3: Update any forward declarations**
Search for `SniffIso15693` declarations and update the signature to include the two new parameters.
**Step 4: Commit**
```bash
git add firmware/armsrc/iso15693.c firmware/armsrc/appmain.c
# Also add any header files modified
git commit --no-gpg-sign -m "feat(fw): streaming sniff with ring buffer, button-toggle, idle flush"
```
---
### Task 3: Python — SniffSession rewrite with streaming
**Files:**
- Modify: `pm3py/sniff/session.py` (full rewrite)
- Reference: `pm3py/sim/sim_session.py:54-83` (open pattern), `pm3py/sim/sim_session.py:237-290` (trace reader pattern)
**Step 1: Write tests for the new SniffSession**
Create `tests/test_sniff_session.py`:
```python
"""Tests for SniffSession — live streaming sniff with button-toggle."""
import struct
import threading
import time
from unittest.mock import MagicMock, patch, PropertyMock
import pytest
from pm3py.core.protocol import (
Cmd, SNIFF_FLAG_STREAMING, SNIFF_FLAG_BUTTON_TOGGLE,
SNIFF_STATE_STARTED, SNIFF_STATE_PAUSED,
SNIFF_STATE_RESUMED, SNIFF_STATE_STOPPED,
)
from pm3py.core.transport import (
encode_ng_frame, RESP_PREAMBLE_MAGIC, RESP_POSTAMBLE_NOCRC,
)
from pm3py.sniff.session import SniffSession
def _make_resp_frame(cmd: int, status: int, data: bytes) -> bytes:
"""Build a raw NG response frame (USB, no CRC)."""
payload_len = len(data)
preamble = struct.pack("<IHBBH",
RESP_PREAMBLE_MAGIC,
payload_len | 0x8000, # NG bit set
status & 0xFF,
0, # reason
cmd,
)
postamble = struct.pack("<H", RESP_POSTAMBLE_NOCRC)
return preamble + data + postamble
def _make_sniff_stream_frame(protocol: int, direction: int,
duration: int, timestamp: int,
data: bytes) -> bytes:
"""Build a CMD_HF_SNIFF_STREAM response frame."""
payload = struct.pack("<BBHI", protocol, direction, duration, timestamp) + data
return _make_resp_frame(Cmd.HF_SNIFF_STREAM.value, 0, payload)
def _make_status_frame(state: int, count: int) -> bytes:
"""Build a CMD_HF_SNIFF_STATUS response frame."""
payload = struct.pack("<BH", state, count)
return _make_resp_frame(Cmd.HF_SNIFF_STATUS.value, 0, payload)
class FakeSerial:
"""Mock serial port that feeds pre-built response frames."""
def __init__(self, frames: list[bytes]):
self._buf = bytearray()
for f in frames:
self._buf.extend(f)
self._pos = 0
self._closed = False
@property
def in_waiting(self):
return len(self._buf) - self._pos
def read(self, size=1):
if self._closed or self._pos >= len(self._buf):
time.sleep(0.01) # avoid busy spin
return b""
end = min(self._pos + size, len(self._buf))
data = bytes(self._buf[self._pos:end])
self._pos = end
return data
def write(self, data):
return len(data)
def reset_input_buffer(self):
pass
def reset_output_buffer(self):
pass
def close(self):
self._closed = True
class TestSniffSessionStreaming:
"""Tests for live streaming mode."""
def test_start_15693_sends_streaming_flags(self):
"""start_15693(live=True) sends correct payload."""
ser = FakeSerial([
_make_status_frame(SNIFF_STATE_STARTED, 0),
_make_status_frame(SNIFF_STATE_STOPPED, 0),
])
sent = bytearray()
orig_write = ser.write
def capture_write(data):
sent.extend(data)
return orig_write(data)
ser.write = capture_write
session = SniffSession(ser)
session.start_15693(live=True, idle_timeout_ms=400)
time.sleep(0.1)
session.stop()
# Verify payload contains flags
assert len(sent) > 0
# The payload should contain idle_timeout_ms (400=0x0190) and flags (0x03)
assert b"\x90\x01\x03" in bytes(sent)
def test_start_15693_legacy_mode(self):
"""start_15693(live=False) sends zero flags."""
ser = FakeSerial([
_make_resp_frame(Cmd.HF_ISO15693_SNIFF.value, 0, b""),
])
sent = bytearray()
orig_write = ser.write
def capture_write(data):
sent.extend(data)
return orig_write(data)
ser.write = capture_write
session = SniffSession(ser)
session.start_15693(live=False)
# Legacy should block and return
def test_receives_trace_entries(self):
"""Background thread decodes CMD_HF_SNIFF_STREAM frames."""
reader_cmd = bytes([0x26, 0x01, 0x00]) # INVENTORY
tag_resp = bytes([0x00, 0xE0, 0x04])
ser = FakeSerial([
_make_status_frame(SNIFF_STATE_STARTED, 0),
_make_sniff_stream_frame(0, 0, 100, 1000, reader_cmd),
_make_sniff_stream_frame(0, 1, 200, 2000, tag_resp),
_make_status_frame(SNIFF_STATE_STOPPED, 2),
])
session = SniffSession(ser)
session.start_15693(live=True)
time.sleep(0.3) # let background thread process
assert len(session.entries) == 2
assert session.entries[0]["direction"] == 0
assert session.entries[0]["data"] == reader_cmd
assert session.entries[0]["timestamp"] == 1000
assert session.entries[1]["direction"] == 1
assert session.entries[1]["data"] == tag_resp
def test_status_updates(self):
"""Status frames update session state."""
ser = FakeSerial([
_make_status_frame(SNIFF_STATE_STARTED, 0),
_make_sniff_stream_frame(0, 0, 100, 1000, b"\x26\x01\x00"),
_make_status_frame(SNIFF_STATE_PAUSED, 1),
_make_status_frame(SNIFF_STATE_RESUMED, 1),
_make_status_frame(SNIFF_STATE_STOPPED, 1),
])
session = SniffSession(ser)
session.start_15693(live=True)
time.sleep(0.3)
assert session.state == "stopped"
def test_stop_sends_break_loop(self):
"""stop() sends CMD_BREAK_LOOP to firmware."""
ser = FakeSerial([
_make_status_frame(SNIFF_STATE_STARTED, 0),
_make_status_frame(SNIFF_STATE_STOPPED, 0),
])
sent = bytearray()
orig_write = ser.write
def capture_write(data):
sent.extend(data)
return orig_write(data)
ser.write = capture_write
session = SniffSession(ser)
session.start_15693(live=True)
time.sleep(0.1)
session.stop()
# BREAK_LOOP = 0x0118
assert b"\x18\x01" in bytes(sent)
def test_entries_accumulate_across_cycles(self):
"""Entries persist across pause/resume cycles."""
ser = FakeSerial([
_make_status_frame(SNIFF_STATE_STARTED, 0),
_make_sniff_stream_frame(0, 0, 100, 1000, b"\x26\x01\x00"),
_make_status_frame(SNIFF_STATE_PAUSED, 1),
_make_status_frame(SNIFF_STATE_RESUMED, 1),
_make_sniff_stream_frame(0, 0, 100, 3000, b"\x26\x01\x00"),
_make_status_frame(SNIFF_STATE_STOPPED, 2),
])
session = SniffSession(ser)
session.start_15693(live=True)
time.sleep(0.3)
assert len(session.entries) == 2
def test_clear_entries(self):
"""clear() resets accumulated entries."""
ser = FakeSerial([
_make_status_frame(SNIFF_STATE_STARTED, 0),
_make_sniff_stream_frame(0, 0, 100, 1000, b"\x26\x01\x00"),
_make_status_frame(SNIFF_STATE_STOPPED, 1),
])
session = SniffSession(ser)
session.start_15693(live=True)
time.sleep(0.3)
assert len(session.entries) == 1
session.clear()
assert len(session.entries) == 0
```
**Step 2: Run tests to verify they fail**
```bash
cd /home/work/pm3py && python -m pytest tests/test_sniff_session.py -v
```
Expected: ImportError / AttributeError — new constants and SniffSession API don't exist yet.
**Step 3: Implement SniffSession**
Rewrite `pm3py/sniff/session.py`:
```python
"""Sniff sessions — live streaming with button-toggle, or legacy batch."""
import struct
import sys
import threading
import time
import serial
from ..core.protocol import (
Cmd, PM3_CMD_DATA_SIZE,
SNIFF_FLAG_STREAMING, SNIFF_FLAG_BUTTON_TOGGLE,
SNIFF_STATE_STARTED, SNIFF_STATE_PAUSED,
SNIFF_STATE_RESUMED, SNIFF_STATE_STOPPED,
)
from ..core.transport import (
encode_ng_frame, decode_response_frame,
RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE,
)
from ..trace import parse_tracelog
from ..trace.decode_iso15 import decode_15693
from ..trace.format import TraceFormatter, format_sniff_line
# Default serial port (same as SimSession)
DEFAULT_PORT = "/dev/ttyACM0"
_STATE_NAMES = {
SNIFF_STATE_STARTED: "sniffing",
SNIFF_STATE_PAUSED: "paused",
SNIFF_STATE_RESUMED: "sniffing",
SNIFF_STATE_STOPPED: "stopped",
}
_STATE_ICONS = {
SNIFF_STATE_STARTED: ">>",
SNIFF_STATE_PAUSED: "||",
SNIFF_STATE_RESUMED: ">>",
SNIFF_STATE_STOPPED: "[]",
}
_STATE_LABELS = {
SNIFF_STATE_STARTED: "Started",
SNIFF_STATE_PAUSED: "Paused",
SNIFF_STATE_RESUMED: "Resumed",
SNIFF_STATE_STOPPED: "Stopped",
}
class SniffSession:
"""Persistent sniff session with live streaming and button-toggle.
For live mode (default):
session = SniffSession.open()
session.start_15693() # streams trace live, button toggles pause
session.stop() # or let button cycles accumulate
session.entries # all captured frames
For legacy mode:
session.start_15693(live=False) # blocks until button, batch download
"""
def __init__(self, ser):
self._ser = ser
self._active = False
self._entries: list[dict] = []
self._reader_thread: threading.Thread | None = None
self._formatter: TraceFormatter | None = None
self._state = "idle"
@classmethod
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SniffSession":
"""Open serial connection to PM3. Auto-detects port if default."""
ser = serial.Serial()
ser.port = port
ser.baudrate = baudrate
ser.bytesize = 8
ser.parity = "N"
ser.stopbits = 1
ser.timeout = 0.1
ser.xonxoff = False
ser.rtscts = False
ser.dsrdtr = False
ser.open()
ser.reset_input_buffer()
ser.reset_output_buffer()
time.sleep(0.5)
ser.reset_input_buffer()
return cls(ser)
def start_15693(self, live: bool = True, idle_timeout_ms: int = 400) -> None:
"""Start 15693 sniff session.
live=True (default): streaming with button-toggle pause/resume.
live=False: legacy BigBuf batch mode, blocks until button exits.
"""
self._protocol = 0 # 15693
flags = 0
if live:
flags = SNIFF_FLAG_STREAMING | SNIFF_FLAG_BUTTON_TOGGLE
payload = struct.pack("<HB", idle_timeout_ms, flags)
frame = encode_ng_frame(Cmd.HF_ISO15693_SNIFF, payload)
self._ser.write(frame)
if live:
self._formatter = TraceFormatter(
mode="sniff", decoder=decode_15693, crc_len=2)
self._active = True
self._state = "starting"
self._reader_thread = threading.Thread(
target=self._stream_reader, daemon=True)
self._reader_thread.start()
else:
self._legacy_sniff()
def _stream_reader(self) -> None:
"""Background thread: read and print trace frames live."""
buf = bytearray()
while self._active:
try:
chunk = self._ser.read(self._ser.in_waiting or 1)
if not chunk:
continue
buf.extend(chunk)
while self._try_decode_frame(buf):
pass
except serial.SerialException:
break
except Exception:
continue
def _try_decode_frame(self, buf: bytearray) -> bool:
"""Decode one response frame from buf. Returns True if consumed."""
if len(buf) < RESP_PREAMBLE_SIZE + RESP_POSTAMBLE_SIZE:
return False
idx = buf.find(struct.pack("<I", RESP_PREAMBLE_MAGIC))
if idx < 0:
buf[:] = buf[-3:]
return False
if idx > 0:
del buf[:idx]
if len(buf) < RESP_PREAMBLE_SIZE:
return False
length_ng = struct.unpack_from("<H", buf, 4)[0]
payload_len = length_ng & 0x7FFF
frame_len = RESP_PREAMBLE_SIZE + payload_len + RESP_POSTAMBLE_SIZE
if len(buf) < frame_len:
return False
try:
resp = decode_response_frame(bytes(buf[:frame_len]))
del buf[:frame_len]
except Exception:
del buf[:4]
return True
cmd = resp["cmd"]
data = resp.get("data", b"")
if cmd == Cmd.HF_SNIFF_STREAM.value:
self._handle_trace(data)
elif cmd == Cmd.HF_SNIFF_STATUS.value:
self._handle_status(data)
elif cmd == Cmd.HF_ISO15693_SNIFF.value:
# Final response (legacy path shouldn't hit here, but safety)
self._active = False
return True
def _handle_trace(self, data: bytes) -> None:
"""Parse and print a trace entry from CMD_HF_SNIFF_STREAM."""
if len(data) < 8:
return
protocol, direction, duration, timestamp = struct.unpack_from("<BBHI", data)
payload = data[8:]
entry = {
"protocol": protocol,
"direction": direction,
"duration": duration,
"timestamp": timestamp,
"data": payload,
}
self._entries.append(entry)
if self._formatter:
self._formatter.print(direction, payload)
def _handle_status(self, data: bytes) -> None:
"""Handle a CMD_HF_SNIFF_STATUS state change."""
if len(data) < 3:
return
state, count = struct.unpack_from("<BH", data)
self._state = _STATE_NAMES.get(state, "unknown")
icon = _STATE_ICONS.get(state, "??")
label = _STATE_LABELS.get(state, "Unknown")
count_str = f" ({count} frames)" if count > 0 else ""
msg = f"\n[Snf] {icon} {label}{count_str}\n"
sys.stdout.write(msg)
sys.stdout.flush()
if state == SNIFF_STATE_STOPPED:
self._active = False
def _legacy_sniff(self) -> None:
"""Legacy batch mode: block until button, download BigBuf, decode."""
from ..core.transport import PM3Transport
print("[Snf] Sniffing ISO 15693... press PM3 button to stop.")
# Read frames until we get the sniff completion response
buf = bytearray()
while True:
chunk = self._ser.read(self._ser.in_waiting or 1)
if chunk:
buf.extend(chunk)
idx = buf.find(struct.pack("<I", RESP_PREAMBLE_MAGIC))
if idx < 0:
continue
if idx > 0:
buf = buf[idx:]
if len(buf) < RESP_PREAMBLE_SIZE:
continue
length_ng = struct.unpack_from("<H", buf, 4)[0]
payload_len = length_ng & 0x7FFF
frame_len = RESP_PREAMBLE_SIZE + payload_len + RESP_POSTAMBLE_SIZE
if len(buf) < frame_len:
continue
try:
resp = decode_response_frame(bytes(buf[:frame_len]))
buf = buf[frame_len:]
if resp["cmd"] == Cmd.HF_ISO15693_SNIFF.value:
break
except Exception:
buf = buf[4:]
print("[Snf] Sniff ended, downloading trace...")
# Download BigBuf (synchronous, blocking reads)
# Send DOWNLOAD_BIGBUF command
from ..core.protocol import PM3_CMD_DATA_SIZE as CHUNK
dl_frame = encode_ng_frame(Cmd.DOWNLOAD_BIGBUF,
struct.pack("<QQQ", 0, CHUNK, 0)[:24])
self._ser.write(dl_frame)
# This is simplified — for legacy mode, we recommend using
# the async PM3Transport path via pm3.hf.iso15.sniff_decoded()
print("[Snf] Legacy download not fully implemented in SniffSession.")
print("[Snf] Use pm3.hf.iso15.sniff_decoded() for legacy batch mode.")
def stop(self) -> None:
"""End sniff session from Python (sends BREAK_LOOP)."""
if self._active:
frame = encode_ng_frame(Cmd.BREAK_LOOP, b"")
self._ser.write(frame)
if self._reader_thread:
self._reader_thread.join(timeout=2.0)
self._active = False
self._state = "stopped"
def close(self) -> None:
"""Stop and close serial port."""
if self._active:
self.stop()
self._ser.close()
@property
def state(self) -> str:
"""Current session state: idle, sniffing, paused, stopped."""
return self._state
@property
def entries(self) -> list[dict]:
"""All captured trace entries across pause/resume cycles."""
return list(self._entries)
def clear(self) -> None:
"""Clear accumulated entries."""
self._entries.clear()
```
**Step 4: Run tests**
```bash
cd /home/work/pm3py && python -m pytest tests/test_sniff_session.py -v
```
Expected: All pass.
**Step 5: Commit**
```bash
git add pm3py/sniff/session.py tests/test_sniff_session.py pm3py/core/protocol.py
git commit --no-gpg-sign -m "feat: rewrite SniffSession with live streaming and button-toggle"
```
---
### Task 4: Python — Update .pythonstartup.py and sniff/__init__.py
**Files:**
- Modify: `.pythonstartup.py:57-63` (SNIFF imports), `.pythonstartup.py:98-105` (auto-connect)
- Modify: `pm3py/sniff/__init__.py`
**Step 1: Update .pythonstartup.py**
In the SNIFF import group (lines 57-63), the SniffSession import stays. The key change is in the auto-connect section.
After the existing `if _MODE == "core":` block (lines 98-105), add sniff mode auto-session:
```python
if _MODE == "sniff":
try:
session = SniffSession.open()
print("Sniff session ready -- session.start_15693()")
except Exception as e:
print(f" (Sniff session failed: {e})")
```
**Step 2: Update sniff/__init__.py exports**
No changes needed — `SniffSession` is already exported. The new constants are in `core/protocol.py`.
**Step 3: Run existing tests to verify nothing broke**
```bash
cd /home/work/pm3py && python -m pytest tests/ -v
```
Expected: All existing tests pass.
**Step 4: Commit**
```bash
git add .pythonstartup.py
git commit --no-gpg-sign -m "feat: auto-create sniff session in REPL sniff mode"
```
---
### Task 5: Verify end-to-end with mocked serial
**Files:**
- Modify: `tests/test_sniff_session.py` (add integration-style test)
**Step 1: Add end-to-end test simulating full REPL workflow**
Add to `tests/test_sniff_session.py`:
```python
class TestSniffSessionEndToEnd:
"""Integration-style test: full start → stream → pause → resume → stop."""
def test_full_workflow(self):
"""Simulate complete sniff session lifecycle."""
inv_cmd = bytes([0x26, 0x01, 0x00])
inv_resp = bytes([0x00, 0xE0, 0x04, 0x12, 0x34, 0x56, 0x78])
rdbl_cmd = bytes([0x22, 0x20, 0x04])
rdbl_resp = bytes([0x00, 0xE1, 0x10, 0x06, 0x01])
frames = [
_make_status_frame(SNIFF_STATE_STARTED, 0),
# First burst
_make_sniff_stream_frame(0, 0, 100, 1000, inv_cmd),
_make_sniff_stream_frame(0, 1, 200, 1500, inv_resp),
_make_sniff_stream_frame(0, 0, 100, 2000, rdbl_cmd),
_make_sniff_stream_frame(0, 1, 200, 2500, rdbl_resp),
# Pause
_make_status_frame(SNIFF_STATE_PAUSED, 4),
# Resume
_make_status_frame(SNIFF_STATE_RESUMED, 4),
# Second burst
_make_sniff_stream_frame(0, 0, 100, 5000, inv_cmd),
_make_sniff_stream_frame(0, 1, 200, 5500, inv_resp),
# Stop
_make_status_frame(SNIFF_STATE_STOPPED, 6),
]
ser = FakeSerial(frames)
session = SniffSession(ser)
session.start_15693(live=True)
time.sleep(0.5)
assert session.state == "stopped"
assert len(session.entries) == 6
# Verify timestamps are monotonically increasing
timestamps = [e["timestamp"] for e in session.entries]
assert timestamps == sorted(timestamps)
# Verify directions alternate (reader/tag pairs)
directions = [e["direction"] for e in session.entries]
assert directions == [0, 1, 0, 1, 0, 1]
def test_close_cleans_up(self):
"""close() stops session and closes serial port."""
ser = FakeSerial([
_make_status_frame(SNIFF_STATE_STARTED, 0),
_make_status_frame(SNIFF_STATE_STOPPED, 0),
])
session = SniffSession(ser)
session.start_15693(live=True)
time.sleep(0.1)
session.close()
assert session.state == "stopped"
assert ser._closed
```
**Step 2: Run all tests**
```bash
cd /home/work/pm3py && python -m pytest tests/test_sniff_session.py -v
```
Expected: All pass.
**Step 3: Run full test suite**
```bash
cd /home/work/pm3py && python -m pytest tests/ -v
```
Expected: All pass, no regressions.
**Step 4: Commit**
```bash
git add tests/test_sniff_session.py
git commit --no-gpg-sign -m "test: add end-to-end sniff session workflow tests"
```
---
### Task 6: Firmware build verification
**Files:**
- No new files — verify firmware compiles
**Step 1: Build firmware**
```bash
cd /home/work/pm3py/firmware && make armsrc/obj/iso15693.o
```
Expected: Compiles without errors or warnings. If there are compile errors, fix them (likely missing includes or function signature mismatches).
**Step 2: Check for any forward declaration mismatches**
```bash
grep -rn "SniffIso15693" /home/work/pm3py/firmware/armsrc/*.h /home/work/pm3py/firmware/include/
```
Update any declarations to match the new 5-parameter signature.
**Step 3: Commit any fixes**
```bash
git add firmware/
git commit --no-gpg-sign -m "fix(fw): update SniffIso15693 forward declarations"
```