jmri_core.testing.bus_replay

Recorded-bus replay format shared by driver test suites.

The Stage-0 scoping doc called for "capture real LocoNet traffic with wireshark/JMRI's monitor, replay against the Python TC, assert state matches." This module ships the on-disk file shape both the LocoNet and NCE test suites use.

Wire shape (JSONL — one JSON object per line)::

{"t": 0.000000, "dir": "in",  "frame_hex": "e703bf..."}
{"t": 0.043500, "dir": "out", "frame_hex": "8000..."}

Fields:

  • t — monotonic timestamp in seconds, relative to the first frame.
  • dir — "in" = from command station to us, "out" = from us to CS. Direction matters because a driver's assertion order depends on which side spoke first.
  • frame_hex — the frame bytes as hex, including any framing bytes (length prefix, address byte). No checksum stripping here — the wire form is the canonical form.

The format is line-oriented and plain-text on purpose: easy to diff, grep, and hand-edit into synthetic fixtures when you don't have real traffic to work with. Trailing whitespace and blank lines are ignored on read; comments (# at column 0) are permitted so test-authors can annotate captures.

Chose to live under jmri_core.testing rather than tests/_replay.py in each driver package because the format is one shared contract — a divergent LocoNet-only shape would defeat the point of the driver-agnostic capture tooling that would use this.

  1"""Recorded-bus replay format shared by driver test suites.
  2
  3The Stage-0 scoping doc called for "capture real LocoNet traffic
  4with wireshark/JMRI's monitor, replay against the Python TC, assert
  5state matches." This module ships the on-disk file shape both the
  6LocoNet and NCE test suites use.
  7
  8Wire shape (JSONL — one JSON object per line)::
  9
 10    {"t": 0.000000, "dir": "in",  "frame_hex": "e703bf..."}
 11    {"t": 0.043500, "dir": "out", "frame_hex": "8000..."}
 12
 13Fields:
 14
 15* ``t`` — monotonic timestamp in seconds, relative to the first frame.
 16* ``dir`` — ``"in"`` = from command station to us, ``"out"`` = from us
 17  to CS. Direction matters because a driver's assertion order depends
 18  on which side spoke first.
 19* ``frame_hex`` — the frame bytes as hex, including any framing bytes
 20  (length prefix, address byte). No checksum stripping here — the
 21  wire form is the canonical form.
 22
 23The format is line-oriented and plain-text on purpose: easy to diff,
 24grep, and hand-edit into synthetic fixtures when you don't have real
 25traffic to work with. Trailing whitespace and blank lines are
 26ignored on read; comments (``#`` at column 0) are permitted so
 27test-authors can annotate captures.
 28
 29Chose to live under ``jmri_core.testing`` rather than
 30``tests/_replay.py`` in each driver package because the format is
 31one shared contract — a divergent LocoNet-only shape would defeat
 32the point of the driver-agnostic capture tooling that would use
 33this.
 34"""
 35from __future__ import annotations
 36
 37import json
 38import pathlib
 39from collections.abc import Iterable, Iterator
 40from dataclasses import dataclass
 41from typing import Literal
 42
 43Direction = Literal["in", "out"]
 44
 45
 46@dataclass(frozen=True, slots=True)
 47class BusFrame:
 48    """One captured frame."""
 49
 50    t: float
 51    direction: Direction
 52    frame: bytes
 53
 54    def to_jsonl(self) -> str:
 55        return json.dumps({
 56            "t": round(self.t, 6),
 57            "dir": self.direction,
 58            "frame_hex": self.frame.hex(),
 59        })
 60
 61    @classmethod
 62    def from_jsonl(cls, line: str) -> BusFrame:
 63        obj = json.loads(line)
 64        direction = obj.get("dir", "in")
 65        if direction not in ("in", "out"):
 66            raise ValueError(
 67                f"invalid direction {direction!r}; must be 'in' or 'out'"
 68            )
 69        return cls(
 70            t=float(obj["t"]),
 71            direction=direction,   # type: ignore[arg-type]
 72            frame=bytes.fromhex(obj["frame_hex"]),
 73        )
 74
 75
 76def write_capture(path: str | pathlib.Path, frames: Iterable[BusFrame]) -> None:
 77    """Serialize *frames* to *path* as JSONL. Overwrites."""
 78    p = pathlib.Path(path)
 79    with p.open("w", encoding="utf-8") as f:
 80        for frame in frames:
 81            f.write(frame.to_jsonl() + "\n")
 82
 83
 84def read_capture(path: str | pathlib.Path) -> list[BusFrame]:
 85    """Load a capture. Blank lines and ``#``-prefixed comments are
 86    silently skipped so hand-authored fixtures can carry notes."""
 87    p = pathlib.Path(path)
 88    out: list[BusFrame] = []
 89    with p.open("r", encoding="utf-8") as f:
 90        for raw_line in f:
 91            line = raw_line.strip()
 92            if not line or line.startswith("#"):
 93                continue
 94            out.append(BusFrame.from_jsonl(line))
 95    return out
 96
 97
 98def iter_direction(
 99    frames: Iterable[BusFrame], direction: Direction,
100) -> Iterator[BusFrame]:
101    """Filter capture frames by direction. Handy when a test only
102    cares about one side of the conversation (e.g. "assert every
103    ``in`` frame decodes cleanly")."""
104    for f in frames:
105        if f.direction == direction:
106            yield f
Direction = typing.Literal['in', 'out']
@dataclass(frozen=True, slots=True)
class BusFrame:
47@dataclass(frozen=True, slots=True)
48class BusFrame:
49    """One captured frame."""
50
51    t: float
52    direction: Direction
53    frame: bytes
54
55    def to_jsonl(self) -> str:
56        return json.dumps({
57            "t": round(self.t, 6),
58            "dir": self.direction,
59            "frame_hex": self.frame.hex(),
60        })
61
62    @classmethod
63    def from_jsonl(cls, line: str) -> BusFrame:
64        obj = json.loads(line)
65        direction = obj.get("dir", "in")
66        if direction not in ("in", "out"):
67            raise ValueError(
68                f"invalid direction {direction!r}; must be 'in' or 'out'"
69            )
70        return cls(
71            t=float(obj["t"]),
72            direction=direction,   # type: ignore[arg-type]
73            frame=bytes.fromhex(obj["frame_hex"]),
74        )

One captured frame.

BusFrame(t: float, direction: Literal['in', 'out'], frame: bytes)
t: float
direction: Literal['in', 'out']
frame: bytes
def to_jsonl(self) -> str:
55    def to_jsonl(self) -> str:
56        return json.dumps({
57            "t": round(self.t, 6),
58            "dir": self.direction,
59            "frame_hex": self.frame.hex(),
60        })
@classmethod
def from_jsonl(cls, line: str) -> BusFrame:
62    @classmethod
63    def from_jsonl(cls, line: str) -> BusFrame:
64        obj = json.loads(line)
65        direction = obj.get("dir", "in")
66        if direction not in ("in", "out"):
67            raise ValueError(
68                f"invalid direction {direction!r}; must be 'in' or 'out'"
69            )
70        return cls(
71            t=float(obj["t"]),
72            direction=direction,   # type: ignore[arg-type]
73            frame=bytes.fromhex(obj["frame_hex"]),
74        )
def write_capture( path: str | pathlib._local.Path, frames: Iterable[BusFrame]) -> None:
77def write_capture(path: str | pathlib.Path, frames: Iterable[BusFrame]) -> None:
78    """Serialize *frames* to *path* as JSONL. Overwrites."""
79    p = pathlib.Path(path)
80    with p.open("w", encoding="utf-8") as f:
81        for frame in frames:
82            f.write(frame.to_jsonl() + "\n")

Serialize frames to path as JSONL. Overwrites.

def read_capture( path: str | pathlib._local.Path) -> list[BusFrame]:
85def read_capture(path: str | pathlib.Path) -> list[BusFrame]:
86    """Load a capture. Blank lines and ``#``-prefixed comments are
87    silently skipped so hand-authored fixtures can carry notes."""
88    p = pathlib.Path(path)
89    out: list[BusFrame] = []
90    with p.open("r", encoding="utf-8") as f:
91        for raw_line in f:
92            line = raw_line.strip()
93            if not line or line.startswith("#"):
94                continue
95            out.append(BusFrame.from_jsonl(line))
96    return out

Load a capture. Blank lines and #-prefixed comments are silently skipped so hand-authored fixtures can carry notes.

def iter_direction( frames: Iterable[BusFrame], direction: Literal['in', 'out']) -> Iterator[BusFrame]:
 99def iter_direction(
100    frames: Iterable[BusFrame], direction: Direction,
101) -> Iterator[BusFrame]:
102    """Filter capture frames by direction. Handy when a test only
103    cares about one side of the conversation (e.g. "assert every
104    ``in`` frame decodes cleanly")."""
105    for f in frames:
106        if f.direction == direction:
107            yield f

Filter capture frames by direction. Handy when a test only cares about one side of the conversation (e.g. "assert every in frame decodes cleanly").