feat(trace): live terminal output under bpython via a shared emit()
Live sniff/sim stream trace frames from a background thread. bpython replaces sys.stdout with a capture object whose write() does a greenlet switch back to the main thread — which raises off-thread; the text buffers but only repaints on the next keypress, so streamed frames 'pile up until you touch a key'. New trace/output.py emit() buffers via the repl's on_write and fires bpython's threadsafe repaint trigger; a plain write everywhere else, with a safe fallback on any internals mismatch. Route TraceFormatter.print(), SimSession's sim-ended status, and SniffSession's state message through emit(). Flip the trace-line newline convention from leading to trailing so each emitted line self-terminates (format_sniff_line + test updated to match). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ from .frame import RFFrame
|
|||||||
from .table_compiler import ResponseTable, TableCompiler
|
from .table_compiler import ResponseTable, TableCompiler
|
||||||
from pm3py.trace.format import TraceFormatter
|
from pm3py.trace.format import TraceFormatter
|
||||||
from pm3py.trace.decode_iso14a import decode_14443a
|
from pm3py.trace.decode_iso14a import decode_14443a
|
||||||
|
from pm3py.trace.output import emit
|
||||||
from .transponder import Transponder
|
from .transponder import Transponder
|
||||||
from ..core.protocol import Cmd
|
from ..core.protocol import Cmd
|
||||||
from ..core.transport import encode_ng_frame, decode_response_frame, RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE
|
from ..core.transport import encode_ng_frame, decode_response_frame, RESP_PREAMBLE_MAGIC, RESP_PREAMBLE_SIZE, RESP_POSTAMBLE_SIZE
|
||||||
@@ -394,7 +395,7 @@ class SimSession:
|
|||||||
# HF_ISO14443A_SIMULATE (0x0384) but its teardown reply
|
# HF_ISO14443A_SIMULATE (0x0384) but its teardown reply
|
||||||
# is HF_MIFARE_SIMULATE (0x0610) — watch for the latter.
|
# is HF_MIFARE_SIMULATE (0x0610) — watch for the latter.
|
||||||
self._active = False
|
self._active = False
|
||||||
print(f"[SimSession] 14a sim ended, status={resp['status']}")
|
emit(f"[SimSession] 14a sim ended, status={resp['status']}\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
if resp["cmd"] in _TRACE_CMDS:
|
if resp["cmd"] in _TRACE_CMDS:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Sniff sessions — live streaming with button-toggle, or legacy batch."""
|
"""Sniff sessions — live streaming with button-toggle, or legacy batch."""
|
||||||
import struct
|
import struct
|
||||||
import sys
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -19,6 +18,7 @@ from ..core.transport import (
|
|||||||
from ..trace import parse_tracelog
|
from ..trace import parse_tracelog
|
||||||
from ..trace.decode_iso15 import decode_15693
|
from ..trace.decode_iso15 import decode_15693
|
||||||
from ..trace.format import TraceFormatter, format_sniff_line
|
from ..trace.format import TraceFormatter, format_sniff_line
|
||||||
|
from ..trace.output import emit
|
||||||
|
|
||||||
# Default serial port (same as SimSession)
|
# Default serial port (same as SimSession)
|
||||||
DEFAULT_PORT = "/dev/ttyACM0"
|
DEFAULT_PORT = "/dev/ttyACM0"
|
||||||
@@ -220,8 +220,7 @@ class SniffSession:
|
|||||||
count_str = f" ({count} frames)" if count > 0 else ""
|
count_str = f" ({count} frames)" if count > 0 else ""
|
||||||
|
|
||||||
msg = f"\n[Snf] {icon} {label}{count_str}\n"
|
msg = f"\n[Snf] {icon} {label}{count_str}\n"
|
||||||
sys.stdout.write(msg)
|
emit(msg)
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
if state == SNIFF_STATE_STOPPED:
|
if state == SNIFF_STATE_STOPPED:
|
||||||
self._active = False
|
self._active = False
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import signal
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .decode_iso15 import decode_15693
|
from .decode_iso15 import decode_15693
|
||||||
|
from .output import emit
|
||||||
|
|
||||||
# ---- ANSI colors ----
|
# ---- ANSI colors ----
|
||||||
_C_CYAN = "\033[36m"
|
_C_CYAN = "\033[36m"
|
||||||
@@ -150,7 +151,7 @@ class TraceFormatter:
|
|||||||
line = f"{prefix_colored}{hex_colored}{' ' * gap}{ann_colored}"
|
line = f"{prefix_colored}{hex_colored}{' ' * gap}{ann_colored}"
|
||||||
else:
|
else:
|
||||||
line = f"{prefix_colored}{hex_colored}"
|
line = f"{prefix_colored}{hex_colored}"
|
||||||
return f"\n{line}"
|
return f"{line}\n"
|
||||||
|
|
||||||
# Multi-line
|
# Multi-line
|
||||||
pad = " " * prefix_len
|
pad = " " * prefix_len
|
||||||
@@ -168,7 +169,7 @@ class TraceFormatter:
|
|||||||
ann_pad = max(avail - len(al), 0)
|
ann_pad = max(avail - len(al), 0)
|
||||||
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, al)}")
|
parts.append(f"{pad}{' ' * ann_pad}{self._color(ann_color, al)}")
|
||||||
|
|
||||||
return "\n" + "\n".join(parts)
|
return "\n".join(parts) + "\n"
|
||||||
|
|
||||||
def _color_hex_line(self, line: str, data_hex: str, crc_hex: str) -> str:
|
def _color_hex_line(self, line: str, data_hex: str, crc_hex: str) -> str:
|
||||||
if not crc_hex:
|
if not crc_hex:
|
||||||
@@ -229,9 +230,8 @@ class TraceFormatter:
|
|||||||
return lines or [""]
|
return lines or [""]
|
||||||
|
|
||||||
def print(self, direction: int, payload: bytes, crc_fail: bool = False) -> None:
|
def print(self, direction: int, payload: bytes, crc_fail: bool = False) -> None:
|
||||||
"""Format and print a trace line to stdout."""
|
"""Format and print a trace line, live even under bpython."""
|
||||||
sys.stdout.write(self.format(direction, payload, crc_fail=crc_fail))
|
emit(self.format(direction, payload, crc_fail=crc_fail))
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def format_sniff_line(entry: dict, width: int = 120, is_tty: bool = True) -> str:
|
def format_sniff_line(entry: dict, width: int = 120, is_tty: bool = True) -> str:
|
||||||
@@ -243,5 +243,5 @@ def format_sniff_line(entry: dict, width: int = 120, is_tty: bool = True) -> str
|
|||||||
width=width, is_tty=is_tty, crc_len=2)
|
width=width, is_tty=is_tty, crc_len=2)
|
||||||
direction = 1 if entry["is_response"] else 0
|
direction = 1 if entry["is_response"] else 0
|
||||||
result = fmt.format(direction, entry["data"])
|
result = fmt.format(direction, entry["data"])
|
||||||
# Strip leading newline that TraceFormatter adds
|
# Strip the trailing newline that TraceFormatter adds
|
||||||
return result.lstrip("\n")
|
return result.rstrip("\n")
|
||||||
|
|||||||
75
pm3py/trace/output.py
Normal file
75
pm3py/trace/output.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Terminal output that stays live under interactive REPLs.
|
||||||
|
|
||||||
|
Live sniff and sim stream trace frames from a background thread. Writing those
|
||||||
|
straight to ``sys.stdout`` is fine in a plain REPL, but bpython replaces stdout
|
||||||
|
with a capture object whose ``write`` hands control back to the main REPL thread
|
||||||
|
via a greenlet switch — which raises from any other thread. The text is buffered
|
||||||
|
first, so it survives, but the display only repaints on the next keypress, so
|
||||||
|
streamed frames appear to "pile up until you touch a key".
|
||||||
|
|
||||||
|
bpython exposes exactly the escape hatch we need: ``_interrupting_refresh_callback``
|
||||||
|
is a curtsies ``threadsafe_event_trigger`` built to wake the input loop from
|
||||||
|
another thread. So under bpython we buffer the text directly (via the repl's
|
||||||
|
``on_write``, skipping the greenlet-switching ``write``) and then fire that
|
||||||
|
trigger to force an immediate repaint. Everywhere else — plain python, IPython —
|
||||||
|
it is just a direct write. All of the bpython plumbing is internal, so any
|
||||||
|
mismatch falls back to a plain write and the sniff keeps working (just
|
||||||
|
keypress-flushed) rather than breaking.
|
||||||
|
|
||||||
|
The detection is intentionally bpython-only: the "wake the render loop" step is
|
||||||
|
per-REPL and there is no universal hook, so we special-case the one REPL we use
|
||||||
|
and let every other environment take the direct-write default.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _bpython_sink():
|
||||||
|
"""Return ``(on_write, wake)`` for the running bpython repl, else ``None``.
|
||||||
|
|
||||||
|
``sys.stdout`` under bpython is a ``FakeOutput`` whose ``on_write`` is a bound
|
||||||
|
method of the running repl; that repl carries the thread-safe repaint trigger.
|
||||||
|
Every access here is bpython-internal, so a miss just yields ``None``.
|
||||||
|
"""
|
||||||
|
out = sys.stdout
|
||||||
|
if type(out).__module__.split(".", 1)[0] != "bpython":
|
||||||
|
return None
|
||||||
|
on_write = getattr(out, "on_write", None)
|
||||||
|
repl = getattr(on_write, "__self__", None)
|
||||||
|
wake = getattr(repl, "_interrupting_refresh_callback", None)
|
||||||
|
if callable(on_write) and callable(wake):
|
||||||
|
return on_write, wake
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def emit(text: str) -> None:
|
||||||
|
"""Write trace ``text`` to the terminal, live even under bpython.
|
||||||
|
|
||||||
|
Safe to call from a background thread. Under bpython the text is buffered and
|
||||||
|
an immediate repaint is triggered; otherwise it is a plain stdout write.
|
||||||
|
"""
|
||||||
|
sink = None
|
||||||
|
try:
|
||||||
|
sink = _bpython_sink()
|
||||||
|
except Exception:
|
||||||
|
sink = None
|
||||||
|
|
||||||
|
if sink is not None:
|
||||||
|
on_write, wake = sink
|
||||||
|
try:
|
||||||
|
on_write(text)
|
||||||
|
except Exception:
|
||||||
|
pass # fall through to a plain write below
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
wake()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout.write(text)
|
||||||
|
sys.stdout.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -232,10 +232,10 @@ class TestTraceFormatterBasic:
|
|||||||
import re
|
import re
|
||||||
return re.sub(r'\033\[[0-9;]*m', '', s)
|
return re.sub(r'\033\[[0-9;]*m', '', s)
|
||||||
|
|
||||||
def test_starts_with_newline(self):
|
def test_ends_with_newline(self):
|
||||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
||||||
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
|
result = fmt.format(0, bytes([0x26, 0x01, 0x00]))
|
||||||
assert result.startswith("\n")
|
assert result.endswith("\n")
|
||||||
|
|
||||||
def test_sim_mode_tag(self):
|
def test_sim_mode_tag(self):
|
||||||
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
fmt = TraceFormatter(mode="sim", decoder=decode_15693)
|
||||||
|
|||||||
Reference in New Issue
Block a user