# 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(" bytes: """Build a CMD_HF_SNIFF_STREAM response frame.""" payload = struct.pack(" bytes: """Build a CMD_HF_SNIFF_STATUS response frame.""" payload = struct.pack("= 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(" 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(" 0: del buf[:idx] if len(buf) < RESP_PREAMBLE_SIZE: return False length_ng = struct.unpack_from(" None: """Parse and print a trace entry from CMD_HF_SNIFF_STREAM.""" if len(data) < 8: return protocol, direction, duration, timestamp = struct.unpack_from(" None: """Handle a CMD_HF_SNIFF_STATUS state change.""" if len(data) < 3: return state, count = struct.unpack_from(" 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(" 0: buf = buf[idx:] if len(buf) < RESP_PREAMBLE_SIZE: continue length_ng = struct.unpack_from(" 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" ```