jmri_ncemonitor.replay
Capture observed Cab Bus traffic to a file and replay it back.
Capture format is JSON Lines — one JSON object per observed message:
{"t": 12345.678, "frame_hex": "806c..."}
t— monotonic timestamp in seconds (relative to capture start).frame_hex— the raw frame bytes (including the address byte) as hex; replay reconstructs the bytes verbatim.
The format is line-oriented + plain-text so it's easy to diff, grep,
and edit by hand for test fixtures. BusReplay reads such a file
and feeds the bytes into a LoopbackPort so the same
CabBusMonitor decode pipeline runs unchanged.
1"""Capture observed Cab Bus traffic to a file and replay it back. 2 3Capture format is JSON Lines — one JSON object per observed message: 4 5 {"t": 12345.678, "frame_hex": "806c..."} 6 7* ``t`` — monotonic timestamp in seconds (relative to capture start). 8* ``frame_hex`` — the raw frame bytes (including the address byte) as 9 hex; replay reconstructs the bytes verbatim. 10 11The format is line-oriented + plain-text so it's easy to diff, grep, 12and edit by hand for test fixtures. :class:`BusReplay` reads such a file 13and feeds the bytes into a :class:`LoopbackPort` so the same 14:class:`CabBusMonitor` decode pipeline runs unchanged. 15""" 16from __future__ import annotations 17 18import asyncio 19import json 20import logging 21import pathlib 22import time 23from collections.abc import Iterable 24from dataclasses import dataclass, field 25 26from jmri_ncemonitor.codec import is_address_byte 27from jmri_ncemonitor.transport import LoopbackPort, loopback_pair 28 29_log = logging.getLogger(__name__) 30 31 32@dataclass 33class CapturedFrame: 34 """One captured frame: timestamp + raw bytes.""" 35 36 t: float 37 frame: bytes 38 39 def to_jsonl(self) -> str: 40 return json.dumps({"t": round(self.t, 6), "frame_hex": self.frame.hex()}) 41 42 @classmethod 43 def from_jsonl(cls, line: str) -> CapturedFrame: 44 obj = json.loads(line) 45 return cls(t=float(obj["t"]), frame=bytes.fromhex(obj["frame_hex"])) 46 47 48@dataclass 49class BusCapture: 50 """Accumulate observed frames; write them out as JSONL on demand.""" 51 52 frames: list[CapturedFrame] = field(default_factory=list) 53 _started_at: float = field(default_factory=time.monotonic) 54 55 def record(self, frame: bytes) -> None: 56 """Record one decoded frame (raw bytes including the address byte).""" 57 self.frames.append(CapturedFrame( 58 t=time.monotonic() - self._started_at, 59 frame=frame, 60 )) 61 62 def write(self, path: str | pathlib.Path) -> None: 63 """Persist the capture as JSONL.""" 64 p = pathlib.Path(path) 65 with p.open("w", encoding="utf-8") as f: 66 for cf in self.frames: 67 f.write(cf.to_jsonl() + "\n") 68 69 @classmethod 70 def read(cls, path: str | pathlib.Path) -> BusCapture: 71 """Load a previously-written capture.""" 72 p = pathlib.Path(path) 73 out = cls() 74 out._started_at = 0.0 # consumed; only frame timestamps matter 75 with p.open("r", encoding="utf-8") as f: 76 for raw_line in f: 77 line = raw_line.strip() 78 if not line: 79 continue 80 out.frames.append(CapturedFrame.from_jsonl(line)) 81 return out 82 83 84class BusReplay: 85 """Feed a captured stream into a :class:`LoopbackPort` for testing. 86 87 Construct with ``BusReplay(frames)`` (or via :meth:`from_file`); call 88 :meth:`run` to start a background task that writes frames to the 89 *write* side of a loopback pair. The *read* side is returned so callers 90 can hand it to a :class:`CabBusMonitor`. 91 92 Timing modes: 93 94 * ``preserve_timing=False`` (default) — emit all frames immediately, 95 one after the other. Good for unit tests. 96 * ``preserve_timing=True`` — sleep between frames according to the 97 captured timestamps. Useful when validating real-time behaviour. 98 99 The replay always appends a trailing address byte after the last 100 frame so the monitor's "read until next address" loop completes. 101 """ 102 103 def __init__( 104 self, 105 frames: Iterable[CapturedFrame], 106 *, 107 preserve_timing: bool = False, 108 ) -> None: 109 self._frames = list(frames) 110 self._preserve_timing = preserve_timing 111 self._task: asyncio.Task[None] | None = None 112 113 @classmethod 114 def from_file( 115 cls, path: str | pathlib.Path, *, preserve_timing: bool = False 116 ) -> BusReplay: 117 capture = BusCapture.read(path) 118 return cls(capture.frames, preserve_timing=preserve_timing) 119 120 async def play_into(self, write_port: LoopbackPort) -> None: 121 """Write every captured frame into *write_port*.""" 122 last_t: float | None = None 123 for cf in self._frames: 124 if self._preserve_timing and last_t is not None: 125 delay = cf.t - last_t 126 if delay > 0: 127 await asyncio.sleep(delay) 128 last_t = cf.t 129 if not cf.frame or not is_address_byte(cf.frame[0]): 130 _log.warning("dropping malformed captured frame: %s", cf.frame.hex()) 131 continue 132 await write_port.write(cf.frame) 133 # Tail boundary byte so the monitor's "read until next addr" returns. 134 await write_port.write(bytes([0x80])) 135 136 def start(self, write_port: LoopbackPort) -> asyncio.Task[None]: 137 """Kick off the replay as a background task. Returns the task.""" 138 self._task = asyncio.create_task(self.play_into(write_port), name="cabbus-replay") 139 return self._task 140 141 142def make_replay_pair( 143 capture: BusCapture, *, preserve_timing: bool = False 144) -> tuple[LoopbackPort, BusReplay]: 145 """Convenience helper: return ``(monitor_port, replay)``. 146 147 The caller passes ``monitor_port`` to :class:`CabBusMonitor` and calls 148 ``replay.start(write_port)`` where ``write_port`` is the other half of 149 a fresh ``loopback_pair()``. 150 """ 151 monitor_port, write_port = loopback_pair() 152 replay = BusReplay(capture.frames, preserve_timing=preserve_timing) 153 # Stash write_port on the replay so the test can drive it. 154 replay.__dict__["_write_port"] = write_port 155 return monitor_port, replay
33@dataclass 34class CapturedFrame: 35 """One captured frame: timestamp + raw bytes.""" 36 37 t: float 38 frame: bytes 39 40 def to_jsonl(self) -> str: 41 return json.dumps({"t": round(self.t, 6), "frame_hex": self.frame.hex()}) 42 43 @classmethod 44 def from_jsonl(cls, line: str) -> CapturedFrame: 45 obj = json.loads(line) 46 return cls(t=float(obj["t"]), frame=bytes.fromhex(obj["frame_hex"]))
One captured frame: timestamp + raw bytes.
49@dataclass 50class BusCapture: 51 """Accumulate observed frames; write them out as JSONL on demand.""" 52 53 frames: list[CapturedFrame] = field(default_factory=list) 54 _started_at: float = field(default_factory=time.monotonic) 55 56 def record(self, frame: bytes) -> None: 57 """Record one decoded frame (raw bytes including the address byte).""" 58 self.frames.append(CapturedFrame( 59 t=time.monotonic() - self._started_at, 60 frame=frame, 61 )) 62 63 def write(self, path: str | pathlib.Path) -> None: 64 """Persist the capture as JSONL.""" 65 p = pathlib.Path(path) 66 with p.open("w", encoding="utf-8") as f: 67 for cf in self.frames: 68 f.write(cf.to_jsonl() + "\n") 69 70 @classmethod 71 def read(cls, path: str | pathlib.Path) -> BusCapture: 72 """Load a previously-written capture.""" 73 p = pathlib.Path(path) 74 out = cls() 75 out._started_at = 0.0 # consumed; only frame timestamps matter 76 with p.open("r", encoding="utf-8") as f: 77 for raw_line in f: 78 line = raw_line.strip() 79 if not line: 80 continue 81 out.frames.append(CapturedFrame.from_jsonl(line)) 82 return out
Accumulate observed frames; write them out as JSONL on demand.
56 def record(self, frame: bytes) -> None: 57 """Record one decoded frame (raw bytes including the address byte).""" 58 self.frames.append(CapturedFrame( 59 t=time.monotonic() - self._started_at, 60 frame=frame, 61 ))
Record one decoded frame (raw bytes including the address byte).
63 def write(self, path: str | pathlib.Path) -> None: 64 """Persist the capture as JSONL.""" 65 p = pathlib.Path(path) 66 with p.open("w", encoding="utf-8") as f: 67 for cf in self.frames: 68 f.write(cf.to_jsonl() + "\n")
Persist the capture as JSONL.
70 @classmethod 71 def read(cls, path: str | pathlib.Path) -> BusCapture: 72 """Load a previously-written capture.""" 73 p = pathlib.Path(path) 74 out = cls() 75 out._started_at = 0.0 # consumed; only frame timestamps matter 76 with p.open("r", encoding="utf-8") as f: 77 for raw_line in f: 78 line = raw_line.strip() 79 if not line: 80 continue 81 out.frames.append(CapturedFrame.from_jsonl(line)) 82 return out
Load a previously-written capture.
85class BusReplay: 86 """Feed a captured stream into a :class:`LoopbackPort` for testing. 87 88 Construct with ``BusReplay(frames)`` (or via :meth:`from_file`); call 89 :meth:`run` to start a background task that writes frames to the 90 *write* side of a loopback pair. The *read* side is returned so callers 91 can hand it to a :class:`CabBusMonitor`. 92 93 Timing modes: 94 95 * ``preserve_timing=False`` (default) — emit all frames immediately, 96 one after the other. Good for unit tests. 97 * ``preserve_timing=True`` — sleep between frames according to the 98 captured timestamps. Useful when validating real-time behaviour. 99 100 The replay always appends a trailing address byte after the last 101 frame so the monitor's "read until next address" loop completes. 102 """ 103 104 def __init__( 105 self, 106 frames: Iterable[CapturedFrame], 107 *, 108 preserve_timing: bool = False, 109 ) -> None: 110 self._frames = list(frames) 111 self._preserve_timing = preserve_timing 112 self._task: asyncio.Task[None] | None = None 113 114 @classmethod 115 def from_file( 116 cls, path: str | pathlib.Path, *, preserve_timing: bool = False 117 ) -> BusReplay: 118 capture = BusCapture.read(path) 119 return cls(capture.frames, preserve_timing=preserve_timing) 120 121 async def play_into(self, write_port: LoopbackPort) -> None: 122 """Write every captured frame into *write_port*.""" 123 last_t: float | None = None 124 for cf in self._frames: 125 if self._preserve_timing and last_t is not None: 126 delay = cf.t - last_t 127 if delay > 0: 128 await asyncio.sleep(delay) 129 last_t = cf.t 130 if not cf.frame or not is_address_byte(cf.frame[0]): 131 _log.warning("dropping malformed captured frame: %s", cf.frame.hex()) 132 continue 133 await write_port.write(cf.frame) 134 # Tail boundary byte so the monitor's "read until next addr" returns. 135 await write_port.write(bytes([0x80])) 136 137 def start(self, write_port: LoopbackPort) -> asyncio.Task[None]: 138 """Kick off the replay as a background task. Returns the task.""" 139 self._task = asyncio.create_task(self.play_into(write_port), name="cabbus-replay") 140 return self._task
Feed a captured stream into a LoopbackPort for testing.
Construct with BusReplay(frames) (or via from_file()); call
run() to start a background task that writes frames to the
write side of a loopback pair. The read side is returned so callers
can hand it to a CabBusMonitor.
Timing modes:
preserve_timing=False(default) — emit all frames immediately, one after the other. Good for unit tests.preserve_timing=True— sleep between frames according to the captured timestamps. Useful when validating real-time behaviour.
The replay always appends a trailing address byte after the last frame so the monitor's "read until next address" loop completes.
121 async def play_into(self, write_port: LoopbackPort) -> None: 122 """Write every captured frame into *write_port*.""" 123 last_t: float | None = None 124 for cf in self._frames: 125 if self._preserve_timing and last_t is not None: 126 delay = cf.t - last_t 127 if delay > 0: 128 await asyncio.sleep(delay) 129 last_t = cf.t 130 if not cf.frame or not is_address_byte(cf.frame[0]): 131 _log.warning("dropping malformed captured frame: %s", cf.frame.hex()) 132 continue 133 await write_port.write(cf.frame) 134 # Tail boundary byte so the monitor's "read until next addr" returns. 135 await write_port.write(bytes([0x80]))
Write every captured frame into write_port.
137 def start(self, write_port: LoopbackPort) -> asyncio.Task[None]: 138 """Kick off the replay as a background task. Returns the task.""" 139 self._task = asyncio.create_task(self.play_into(write_port), name="cabbus-replay") 140 return self._task
Kick off the replay as a background task. Returns the task.
143def make_replay_pair( 144 capture: BusCapture, *, preserve_timing: bool = False 145) -> tuple[LoopbackPort, BusReplay]: 146 """Convenience helper: return ``(monitor_port, replay)``. 147 148 The caller passes ``monitor_port`` to :class:`CabBusMonitor` and calls 149 ``replay.start(write_port)`` where ``write_port`` is the other half of 150 a fresh ``loopback_pair()``. 151 """ 152 monitor_port, write_port = loopback_pair() 153 replay = BusReplay(capture.frames, preserve_timing=preserve_timing) 154 # Stash write_port on the replay so the test can drive it. 155 replay.__dict__["_write_port"] = write_port 156 return monitor_port, replay
Convenience helper: return (monitor_port, replay).
The caller passes monitor_port to CabBusMonitor and calls
replay.start(write_port) where write_port is the other half of
a fresh loopback_pair().