feat(trace): scriptable on_frame callback + quiet mode + SimSession entries
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>
This commit is contained in:
@@ -50,6 +50,10 @@ class SimSession:
|
|||||||
self._tag_model: Transponder | None = None
|
self._tag_model: Transponder | None = None
|
||||||
self._formatter: TraceFormatter | None = None
|
self._formatter: TraceFormatter | None = None
|
||||||
self.on_field_strength: callable | None = None # callback(adc_mv: int)
|
self.on_field_strength: callable | None = None # callback(adc_mv: int)
|
||||||
|
self._entries: list[dict] = []
|
||||||
|
self._on_frame = None
|
||||||
|
self._quiet = False
|
||||||
|
self._print_frames = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SimSession":
|
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SimSession":
|
||||||
@@ -122,7 +126,8 @@ class SimSession:
|
|||||||
self._active = True
|
self._active = True
|
||||||
self._relay_task = asyncio.create_task(self._relay_loop(tag))
|
self._relay_task = asyncio.create_task(self._relay_loop(tag))
|
||||||
|
|
||||||
def start_15693(self, tag, compile: bool = True, trace: bool = False) -> None:
|
def start_15693(self, tag, compile: bool = True, trace: bool = False,
|
||||||
|
on_frame=None, quiet: bool = False) -> None:
|
||||||
"""Start 15693 sim (synchronous).
|
"""Start 15693 sim (synchronous).
|
||||||
|
|
||||||
Firmware handles standard commands (inventory, read, write) autonomously.
|
Firmware handles standard commands (inventory, read, write) autonomously.
|
||||||
@@ -131,8 +136,16 @@ class SimSession:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
trace: If True, print live reader↔tag communication to console.
|
trace: If True, print live reader↔tag communication to console.
|
||||||
|
on_frame: optional callback(frame: dict) invoked per decoded frame from
|
||||||
|
the reader thread — for scripting (see the `entries` property for the
|
||||||
|
dict shape). Passing it auto-enables trace streaming.
|
||||||
|
quiet: if True, don't print frames (callback + `entries` still fill).
|
||||||
"""
|
"""
|
||||||
self._tag_model = tag
|
self._tag_model = tag
|
||||||
|
self._on_frame = on_frame
|
||||||
|
self._quiet = quiet
|
||||||
|
self._print_frames = trace and not quiet
|
||||||
|
stream = trace or (on_frame is not None)
|
||||||
|
|
||||||
# Resolve the raw serial port
|
# Resolve the raw serial port
|
||||||
if self._port:
|
if self._port:
|
||||||
@@ -147,7 +160,7 @@ class SimSession:
|
|||||||
|
|
||||||
# Start sim with UID and block_size — same format as PM3 client
|
# Start sim with UID and block_size — same format as PM3 client
|
||||||
# Client sends UID as-is (firmware reverses internally)
|
# Client sends UID as-is (firmware reverses internally)
|
||||||
flags = 0x02 if trace else 0x00
|
flags = 0x02 if stream else 0x00
|
||||||
block_size = tag._block_size if hasattr(tag, '_block_size') else 4
|
block_size = tag._block_size if hasattr(tag, '_block_size') else 4
|
||||||
payload = tag._uid + bytes([block_size, flags])
|
payload = tag._uid + bytes([block_size, flags])
|
||||||
frame = encode_ng_frame(Cmd.HF_ISO15693_SIMULATE, payload)
|
frame = encode_ng_frame(Cmd.HF_ISO15693_SIMULATE, payload)
|
||||||
@@ -168,7 +181,7 @@ class SimSession:
|
|||||||
if compile:
|
if compile:
|
||||||
self._compile_and_upload_table(tag, ser)
|
self._compile_and_upload_table(tag, ser)
|
||||||
|
|
||||||
if trace:
|
if stream:
|
||||||
self._formatter = TraceFormatter(mode="sim", decoder=tag.decode_trace, crc_len=2)
|
self._formatter = TraceFormatter(mode="sim", decoder=tag.decode_trace, crc_len=2)
|
||||||
self._trace_thread = threading.Thread(
|
self._trace_thread = threading.Thread(
|
||||||
target=self._trace_reader, args=(ser,), daemon=True
|
target=self._trace_reader, args=(ser,), daemon=True
|
||||||
@@ -176,7 +189,7 @@ class SimSession:
|
|||||||
self._trace_thread.start()
|
self._trace_thread.start()
|
||||||
|
|
||||||
print(f"[SimSession] 15693 sim started, UID={tag._uid.hex()}"
|
print(f"[SimSession] 15693 sim started, UID={tag._uid.hex()}"
|
||||||
+ (" (trace ON)" if trace else ""))
|
+ (" (trace ON)" if stream else ""))
|
||||||
print(f"[SimSession] Modify tag in REPL, then call tag.sync()")
|
print(f"[SimSession] Modify tag in REPL, then call tag.sync()")
|
||||||
|
|
||||||
def _compile_and_upload_table(self, tag, ser) -> None:
|
def _compile_and_upload_table(self, tag, ser) -> None:
|
||||||
@@ -280,7 +293,23 @@ class SimSession:
|
|||||||
else:
|
else:
|
||||||
crc_fail = bool(data[0] & 0x80)
|
crc_fail = bool(data[0] & 0x80)
|
||||||
payload = data[1:]
|
payload = data[1:]
|
||||||
self._formatter.print(direction, payload, crc_fail=crc_fail)
|
entry = {
|
||||||
|
"protocol": 0, # ISO 15693
|
||||||
|
"direction": direction,
|
||||||
|
"timestamp": None,
|
||||||
|
"duration": None,
|
||||||
|
"data": payload,
|
||||||
|
"data_hex": payload.hex(),
|
||||||
|
"decoded": self._formatter.decode(
|
||||||
|
direction, payload, crc_fail),
|
||||||
|
"crc_fail": crc_fail,
|
||||||
|
}
|
||||||
|
self._entries.append(entry)
|
||||||
|
if self._on_frame is not None:
|
||||||
|
self._on_frame(entry)
|
||||||
|
if self._print_frames:
|
||||||
|
self._formatter.print(
|
||||||
|
direction, payload, crc_fail=crc_fail)
|
||||||
except Exception:
|
except Exception:
|
||||||
buf = buf[4:] # skip bad magic, try again
|
buf = buf[4:] # skip bad magic, try again
|
||||||
|
|
||||||
@@ -361,6 +390,20 @@ class SimSession:
|
|||||||
payload = struct.pack("<IH", offset, len(data)) + data
|
payload = struct.pack("<IH", offset, len(data)) + data
|
||||||
await self._t.send_ng_no_response(Cmd.HF_ISO15693_EML_SETMEM, payload)
|
await self._t.send_ng_no_response(Cmd.HF_ISO15693_EML_SETMEM, payload)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def entries(self) -> list[dict]:
|
||||||
|
"""Decoded reader↔tag frames captured while streaming (a copy).
|
||||||
|
|
||||||
|
Each: {'protocol', 'direction', 'timestamp', 'duration', 'data': bytes,
|
||||||
|
'data_hex': str, 'decoded': str|None, 'crc_fail': bool}. Populated when
|
||||||
|
the sim runs with trace=True or an on_frame callback.
|
||||||
|
"""
|
||||||
|
return list(self._entries)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Discard accumulated frames."""
|
||||||
|
self._entries.clear()
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Stop simulation."""
|
"""Stop simulation."""
|
||||||
self._active = False
|
self._active = False
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ class SniffSession:
|
|||||||
self._reader_thread: threading.Thread | None = None
|
self._reader_thread: threading.Thread | None = None
|
||||||
self._formatter: TraceFormatter | None = None
|
self._formatter: TraceFormatter | None = None
|
||||||
self._state = "idle"
|
self._state = "idle"
|
||||||
|
self._on_frame = None
|
||||||
|
self._quiet = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SniffSession":
|
def open(cls, port: str = DEFAULT_PORT, baudrate: int = 115200) -> "SniffSession":
|
||||||
@@ -88,14 +90,20 @@ class SniffSession:
|
|||||||
|
|
||||||
return cls(ser)
|
return cls(ser)
|
||||||
|
|
||||||
def start_15693(self, live: bool = True, idle_timeout_ms: int = 400) -> None:
|
def start_15693(self, live: bool = True, idle_timeout_ms: int = 400,
|
||||||
|
on_frame=None, quiet: bool = False) -> None:
|
||||||
"""Start 15693 sniff session.
|
"""Start 15693 sniff session.
|
||||||
|
|
||||||
live=True (default): streaming with button-toggle pause/resume.
|
live=True (default): streaming with button-toggle pause/resume.
|
||||||
live=False: legacy BigBuf batch mode, blocks until button exits.
|
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:
|
if self._active:
|
||||||
self.stop()
|
self.stop()
|
||||||
|
self._on_frame = on_frame
|
||||||
|
self._quiet = quiet
|
||||||
|
|
||||||
self._protocol = 0 # 15693
|
self._protocol = 0 # 15693
|
||||||
flags = 0
|
flags = 0
|
||||||
@@ -190,10 +198,13 @@ class SniffSession:
|
|||||||
"duration": duration,
|
"duration": duration,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
"data": payload,
|
"data": payload,
|
||||||
|
"data_hex": payload.hex(),
|
||||||
|
"decoded": self._formatter.decode(direction, payload) if self._formatter else None,
|
||||||
}
|
}
|
||||||
self._entries.append(entry)
|
self._entries.append(entry)
|
||||||
|
if self._on_frame is not None:
|
||||||
if self._formatter:
|
self._on_frame(entry)
|
||||||
|
if self._formatter and not self._quiet:
|
||||||
self._formatter.print(direction, payload)
|
self._formatter.print(direction, payload)
|
||||||
|
|
||||||
def _handle_status(self, data: bytes) -> None:
|
def _handle_status(self, data: bytes) -> None:
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ class TraceFormatter:
|
|||||||
return text
|
return text
|
||||||
return f"{code}{text}{_C_RESET}"
|
return f"{code}{text}{_C_RESET}"
|
||||||
|
|
||||||
|
def decode(self, direction: int, payload: bytes, crc_fail: bool = False) -> str | None:
|
||||||
|
"""Return the plain-text decoded annotation for a frame — no ANSI, no hex.
|
||||||
|
|
||||||
|
The same annotation ``format()`` renders (e.g. "INVENTORY", "READ BLOCK 4"),
|
||||||
|
exposed as structured data for scripting/callbacks.
|
||||||
|
"""
|
||||||
|
if self._crc_len > 0 and len(payload) > self._crc_len:
|
||||||
|
data = payload[:-self._crc_len]
|
||||||
|
else:
|
||||||
|
data = payload
|
||||||
|
annotation = self._decoder(direction, data) if self._decoder is not None else None
|
||||||
|
if crc_fail:
|
||||||
|
annotation = f"{annotation} BAD CRC" if annotation else "BAD CRC"
|
||||||
|
return annotation
|
||||||
|
|
||||||
def format(self, direction: int, payload: bytes, crc_fail: bool = False) -> str:
|
def format(self, direction: int, payload: bytes, crc_fail: bool = False) -> str:
|
||||||
"""Format a trace line with colors, decoding, and wrapping."""
|
"""Format a trace line with colors, decoding, and wrapping."""
|
||||||
self._line_count += 1
|
self._line_count += 1
|
||||||
|
|||||||
@@ -561,6 +561,7 @@ class TestSimSessionTraceWiring:
|
|||||||
session = SimSession()
|
session = SimSession()
|
||||||
session._formatter = mock_fmt
|
session._formatter = mock_fmt
|
||||||
session._active = True
|
session._active = True
|
||||||
|
session._print_frames = True # start_15693(trace=True) sets this
|
||||||
|
|
||||||
frame = self._make_trace_frame(0, bytes([0x26, 0x01, 0x00]))
|
frame = self._make_trace_frame(0, bytes([0x26, 0x01, 0x00]))
|
||||||
mock_serial = MagicMock()
|
mock_serial = MagicMock()
|
||||||
|
|||||||
104
tests/test_trace_scripting.py
Normal file
104
tests/test_trace_scripting.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""Scriptable trace access: on_frame callback, quiet mode, entries accumulator."""
|
||||||
|
import struct
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from pm3py.trace.format import TraceFormatter
|
||||||
|
from pm3py.trace import decode_15693
|
||||||
|
|
||||||
|
INV = bytes.fromhex("260100f60a") # ISO15693 INVENTORY request + CRC
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(mode="sniff"):
|
||||||
|
return TraceFormatter(mode=mode, decoder=decode_15693, crc_len=2, is_tty=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _sniff_trace(direction, payload):
|
||||||
|
# CMD_HF_SNIFF_STREAM entry: {protocol,direction,duration,timestamp} + payload
|
||||||
|
return struct.pack("<BBHI", 0, direction, 0, 0) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def _sim_trace_frame(direction, payload):
|
||||||
|
from pm3py.core.transport import RESP_POSTAMBLE_NOCRC
|
||||||
|
from pm3py.core.protocol import RESP_PREAMBLE_MAGIC
|
||||||
|
from pm3py.sim.sim_session import CMD_HF_ISO15693_SIM_TRACE
|
||||||
|
data = bytes([direction]) + payload
|
||||||
|
header = struct.pack("<IHbbH", RESP_PREAMBLE_MAGIC, len(data) | 0x8000,
|
||||||
|
0, 0, CMD_HF_ISO15693_SIM_TRACE)
|
||||||
|
return header + data + struct.pack("<H", RESP_POSTAMBLE_NOCRC)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sim_reader(session, frame):
|
||||||
|
ser = MagicMock()
|
||||||
|
ser.in_waiting = len(frame)
|
||||||
|
n = 0
|
||||||
|
def side(_):
|
||||||
|
nonlocal n
|
||||||
|
n += 1
|
||||||
|
if n == 1:
|
||||||
|
return frame
|
||||||
|
session._active = False
|
||||||
|
return b""
|
||||||
|
ser.read.side_effect = side
|
||||||
|
session._trace_reader(ser)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- SniffSession ----------------
|
||||||
|
|
||||||
|
def test_sniff_on_frame_and_quiet(capsys):
|
||||||
|
from pm3py.sniff.session import SniffSession
|
||||||
|
s = SniffSession(MagicMock())
|
||||||
|
s._formatter = _fmt()
|
||||||
|
frames = []
|
||||||
|
s._on_frame = frames.append
|
||||||
|
s._quiet = True
|
||||||
|
|
||||||
|
s._handle_trace(_sniff_trace(0, INV))
|
||||||
|
|
||||||
|
assert len(frames) == 1 and len(s.entries) == 1
|
||||||
|
f = frames[0]
|
||||||
|
assert f["direction"] == 0
|
||||||
|
assert f["data"] == INV and f["data_hex"] == INV.hex()
|
||||||
|
assert f["decoded"] == "INVENTORY"
|
||||||
|
assert capsys.readouterr().out == "" # quiet -> no console output
|
||||||
|
|
||||||
|
|
||||||
|
def test_sniff_default_still_prints(capsys):
|
||||||
|
from pm3py.sniff.session import SniffSession
|
||||||
|
s = SniffSession(MagicMock())
|
||||||
|
s._formatter = _fmt()
|
||||||
|
# defaults preserved: _on_frame=None, _quiet=False
|
||||||
|
s._handle_trace(_sniff_trace(0, INV))
|
||||||
|
assert len(s.entries) == 1
|
||||||
|
assert capsys.readouterr().out != "" # prints as before
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- SimSession ----------------
|
||||||
|
|
||||||
|
def test_sim_on_frame_and_quiet(capsys):
|
||||||
|
from pm3py.sim.sim_session import SimSession
|
||||||
|
s = SimSession()
|
||||||
|
s._formatter = _fmt(mode="sim")
|
||||||
|
s._active = True
|
||||||
|
frames = []
|
||||||
|
s._on_frame = frames.append
|
||||||
|
s._print_frames = False # quiet
|
||||||
|
|
||||||
|
_run_sim_reader(s, _sim_trace_frame(0, INV))
|
||||||
|
|
||||||
|
assert len(frames) == 1 and len(s.entries) == 1
|
||||||
|
f = frames[0]
|
||||||
|
assert f["direction"] == 0 and f["data"] == INV
|
||||||
|
assert f["data_hex"] == INV.hex()
|
||||||
|
assert f["decoded"] == "INVENTORY" and f["crc_fail"] is False
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_sim_entries_accumulate_and_clear():
|
||||||
|
from pm3py.sim.sim_session import SimSession
|
||||||
|
s = SimSession()
|
||||||
|
s._formatter = _fmt(mode="sim")
|
||||||
|
s._active = True
|
||||||
|
_run_sim_reader(s, _sim_trace_frame(0, INV))
|
||||||
|
assert len(s.entries) == 1
|
||||||
|
s.clear()
|
||||||
|
assert s.entries == []
|
||||||
Reference in New Issue
Block a user