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
@dataclass
class CapturedFrame:
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.

CapturedFrame(t: float, frame: bytes)
t: float
frame: bytes
def to_jsonl(self) -> str:
40    def to_jsonl(self) -> str:
41        return json.dumps({"t": round(self.t, 6), "frame_hex": self.frame.hex()})
@classmethod
def from_jsonl(cls, line: str) -> CapturedFrame:
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"]))
@dataclass
class BusCapture:
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.

BusCapture( frames: list[CapturedFrame] = <factory>, _started_at: float = <factory>)
frames: list[CapturedFrame]
def record(self, frame: bytes) -> None:
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).

def write(self, path: str | pathlib._local.Path) -> None:
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.

@classmethod
def read( cls, path: str | pathlib._local.Path) -> BusCapture:
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.

class BusReplay:
 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.

BusReplay( frames: Iterable[CapturedFrame], *, preserve_timing: bool = False)
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
@classmethod
def from_file( cls, path: str | pathlib._local.Path, *, preserve_timing: bool = False) -> BusReplay:
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)
async def play_into( self, write_port: jmri_ncemonitor.LoopbackPort) -> None:
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.

def start( self, write_port: jmri_ncemonitor.LoopbackPort) -> _asyncio.Task[None]:
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.

def make_replay_pair( capture: BusCapture, *, preserve_timing: bool = False) -> tuple[jmri_ncemonitor.LoopbackPort, BusReplay]:
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().