SniffSession/SimSession.start_15693 gain on_frame(frame) and quiet kwargs (defaults preserve today's print behavior). Each frame is a plain dict {protocol, direction, timestamp, duration, data, data_hex, decoded[, crc_fail]} with no ANSI. SimSession now accumulates frames (entries property + clear(), mirroring SniffSession) and auto-streams when trace or on_frame is set; printing is gated on trace and not quiet. TraceFormatter.decode() exposes the annotation without color; format()/print() untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
299 lines
9.4 KiB
Python
299 lines
9.4 KiB
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"
|
|
self._on_frame = None
|
|
self._quiet = False
|
|
|
|
@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,
|
|
on_frame=None, quiet: bool = False) -> None:
|
|
"""Start 15693 sniff session.
|
|
|
|
live=True (default): streaming with button-toggle pause/resume.
|
|
live=False: legacy BigBuf batch mode, blocks until button exits.
|
|
on_frame: optional callback(frame: dict) invoked per decoded frame from the
|
|
reader thread — for scripting (see the `entries` property for the dict shape).
|
|
quiet: if True, don't print frames to the console (callback + `entries` still fill).
|
|
"""
|
|
if self._active:
|
|
self.stop()
|
|
self._on_frame = on_frame
|
|
self._quiet = quiet
|
|
|
|
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:
|
|
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,
|
|
"data_hex": payload.hex(),
|
|
"decoded": self._formatter.decode(direction, payload) if self._formatter else None,
|
|
}
|
|
self._entries.append(entry)
|
|
if self._on_frame is not None:
|
|
self._on_frame(entry)
|
|
if self._formatter and not self._quiet:
|
|
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."""
|
|
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...")
|
|
# Legacy download not fully implemented in SniffSession.
|
|
# Use pm3.hf.iso15.sniff_decoded() for legacy batch mode.
|
|
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()
|