You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
386 lines
13 KiB
386 lines
13 KiB
# ADN DMR Peer Server - tests harness session replay
|
|
#
|
|
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
|
#
|
|
###############################################################################
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software Foundation,
|
|
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
###############################################################################
|
|
|
|
"""JSONL session replay for deterministic bridge tests.
|
|
|
|
Session file format (one JSON object per line):
|
|
|
|
- ``meta`` — optional first line: ``config``, ``bridges``, ``apply_startup_subscriptions``, ``expect``
|
|
- ``ingress`` — inject one packet: ``channel`` (hbp|obp|unit), ``system``, ``dt`` (clock advance
|
|
seconds before inject), and either ``packet`` shortcut or ``spec`` fields for PacketSpec
|
|
- ``packet`` shortcuts: ``voice_head``, ``voice_burst``, ``voice_term`` (+ ``base``, ``seq``, …)
|
|
|
|
Capture tap (dev only; anonymize before commit)::
|
|
|
|
CAPTURE=1 pytest tests/replay/test_session_replay.py::test_capture_startup_voice_session -q
|
|
|
|
Writes ``tests/fixtures/sessions/_capture/<name>.jsonl`` (gitignored).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Iterator
|
|
|
|
from adn_server.domain import bytes_3, int_id
|
|
from adn_server.domain.hbp_protocol import (
|
|
HBPF_DATA_SYNC,
|
|
HBPF_SLT_VHEAD,
|
|
HBPF_SLT_VTERM,
|
|
HBPF_VOICE,
|
|
)
|
|
from tests.harness.assertions import assert_forwarded
|
|
from tests.harness.deterministic import (
|
|
DeterministicScenario,
|
|
PacketSpec,
|
|
minimal_config,
|
|
patch_routing_wall_time,
|
|
)
|
|
|
|
SESSION_VERSION = 1
|
|
FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" / "sessions"
|
|
_CAPTURE_DIR = FIXTURES_DIR / "_capture"
|
|
|
|
_FRAME_ALIASES: dict[str, tuple[int, int]] = {
|
|
"vhead": (HBPF_DATA_SYNC, HBPF_SLT_VHEAD),
|
|
"voice": (HBPF_VOICE, 0),
|
|
"vterm": (HBPF_DATA_SYNC, HBPF_SLT_VTERM),
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class SessionExpect:
|
|
forwards: dict[str, int] = field(default_factory=dict)
|
|
dst_id: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class SessionMeta:
|
|
version: int = SESSION_VERSION
|
|
name: str = ""
|
|
config: dict[str, Any] | None = None
|
|
routing_table: dict[str, list[dict[str, Any]]] | None = None
|
|
apply_startup_subscriptions: bool = False
|
|
expect: SessionExpect = field(default_factory=SessionExpect)
|
|
|
|
|
|
@dataclass
|
|
class IngressEvent:
|
|
channel: str
|
|
system: str
|
|
dt: float = 0.0
|
|
packet: str | None = None
|
|
base: dict[str, Any] = field(default_factory=dict)
|
|
spec: dict[str, Any] = field(default_factory=dict)
|
|
seq: int | None = None
|
|
dtype_vseq: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class SessionDefinition:
|
|
meta: SessionMeta
|
|
events: list[IngressEvent] = field(default_factory=list)
|
|
|
|
|
|
def bridges_from_json(raw: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
|
|
bridges: dict[str, list[dict[str, Any]]] = {}
|
|
for key, entries in raw.items():
|
|
out_entries: list[dict[str, Any]] = []
|
|
for ent in entries:
|
|
row = dict(ent)
|
|
tgid = row.get("TGID")
|
|
if isinstance(tgid, int):
|
|
row["TGID"] = bytes_3(tgid)
|
|
for list_field in ("ON", "OFF", "RESET"):
|
|
if list_field in row:
|
|
row[list_field] = [
|
|
bytes_3(v) if isinstance(v, int) else v for v in row[list_field]
|
|
]
|
|
out_entries.append(row)
|
|
bridges[key] = out_entries
|
|
return bridges
|
|
|
|
|
|
def bridges_to_json(bridges: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
|
|
out: dict[str, list[dict[str, Any]]] = {}
|
|
for key, entries in bridges.items():
|
|
serialized: list[dict[str, Any]] = []
|
|
for ent in entries:
|
|
row = dict(ent)
|
|
tgid = row.get("TGID")
|
|
if isinstance(tgid, bytes):
|
|
row["TGID"] = int_id(tgid)
|
|
for list_field in ("ON", "OFF", "RESET"):
|
|
if list_field in row:
|
|
row[list_field] = [
|
|
int_id(v) if isinstance(v, bytes) else v for v in row[list_field]
|
|
]
|
|
serialized.append(row)
|
|
out[key] = serialized
|
|
return out
|
|
|
|
|
|
def _parse_meta(obj: dict[str, Any]) -> SessionMeta:
|
|
expect_raw = obj.get("expect") or {}
|
|
expect = SessionExpect(
|
|
forwards=dict(expect_raw.get("forwards") or {}),
|
|
dst_id=expect_raw.get("dst_id"),
|
|
)
|
|
routing_raw = obj.get("routing_table") or obj.get("bridges")
|
|
apply_startup = obj.get("apply_startup_subscriptions")
|
|
if apply_startup is None:
|
|
apply_startup = obj.get("apply_startup_bridges", False)
|
|
return SessionMeta(
|
|
version=int(obj.get("version", SESSION_VERSION)),
|
|
name=str(obj.get("name", "")),
|
|
config=obj.get("config"),
|
|
routing_table=routing_raw,
|
|
apply_startup_subscriptions=bool(apply_startup),
|
|
expect=expect,
|
|
)
|
|
|
|
|
|
def _parse_ingress(obj: dict[str, Any]) -> IngressEvent:
|
|
return IngressEvent(
|
|
channel=str(obj["channel"]),
|
|
system=str(obj["system"]),
|
|
dt=float(obj.get("dt", 0.0)),
|
|
packet=obj.get("packet"),
|
|
base=dict(obj.get("base") or {}),
|
|
spec=dict(obj.get("spec") or {}),
|
|
seq=obj.get("seq"),
|
|
dtype_vseq=obj.get("dtype_vseq"),
|
|
)
|
|
|
|
|
|
def load_session(path: str | Path) -> SessionDefinition:
|
|
meta = SessionMeta()
|
|
events: list[IngressEvent] = []
|
|
path = Path(path)
|
|
with path.open(encoding="utf-8") as fh:
|
|
for line_no, line in enumerate(fh, 1):
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
obj = json.loads(line)
|
|
kind = obj.get("type")
|
|
if kind == "meta":
|
|
meta = _parse_meta(obj)
|
|
elif kind == "ingress":
|
|
events.append(_parse_ingress(obj))
|
|
else:
|
|
raise ValueError(f"{path}:{line_no}: unknown record type {kind!r}")
|
|
if not events:
|
|
raise ValueError(f"{path}: no ingress events")
|
|
return SessionDefinition(meta=meta, events=events)
|
|
|
|
|
|
def _spec_from_dict(raw: dict[str, Any]) -> PacketSpec:
|
|
kwargs = dict(raw)
|
|
if "frame" in kwargs:
|
|
frame_key = str(kwargs.pop("frame"))
|
|
ft, dv = _FRAME_ALIASES.get(frame_key, (kwargs.get("frame_type"), kwargs.get("dtype_vseq")))
|
|
if ft is not None:
|
|
kwargs["frame_type"] = ft
|
|
if dv is not None:
|
|
kwargs["dtype_vseq"] = dv
|
|
if "payload" in kwargs and isinstance(kwargs["payload"], str):
|
|
kwargs["payload"] = bytes.fromhex(kwargs["payload"])
|
|
return PacketSpec(**kwargs)
|
|
|
|
|
|
def _base_spec(event: IngressEvent) -> PacketSpec:
|
|
if event.spec:
|
|
return _spec_from_dict(event.spec)
|
|
return _spec_from_dict(event.base)
|
|
|
|
|
|
def _event_to_spec(event: IngressEvent) -> PacketSpec:
|
|
base = _base_spec(event)
|
|
shortcut = event.packet
|
|
if shortcut == "voice_head":
|
|
return DeterministicScenario.voice_head_spec(base)
|
|
if shortcut == "voice_burst":
|
|
return DeterministicScenario.voice_burst_spec(
|
|
base,
|
|
seq=int(event.seq if event.seq is not None else 1),
|
|
dtype_vseq=int(event.dtype_vseq if event.dtype_vseq is not None else 1),
|
|
)
|
|
if shortcut == "voice_term":
|
|
return DeterministicScenario.voice_term_spec(
|
|
base,
|
|
seq=int(event.seq if event.seq is not None else 99),
|
|
)
|
|
if shortcut:
|
|
raise ValueError(f"unknown packet shortcut {shortcut!r}")
|
|
return base
|
|
|
|
|
|
class SessionReplayer:
|
|
def __init__(self, session: SessionDefinition) -> None:
|
|
self.session = session
|
|
|
|
@classmethod
|
|
def from_path(cls, path: str | Path) -> SessionReplayer:
|
|
return cls(load_session(path))
|
|
|
|
def run(self) -> DeterministicScenario:
|
|
meta = self.session.meta
|
|
routing_table = (
|
|
bridges_from_json(meta.routing_table) if meta.routing_table else None
|
|
)
|
|
scenario = DeterministicScenario(
|
|
config=meta.config or minimal_config(("MASTER-A", "MASTER-B")),
|
|
routing_table=routing_table,
|
|
)
|
|
if meta.apply_startup_subscriptions:
|
|
scenario.routing.apply_startup_subscriptions()
|
|
|
|
with patch_routing_wall_time(scenario.clock):
|
|
for event in self.session.events:
|
|
if event.dt:
|
|
scenario.clock.advance(event.dt)
|
|
spec = _event_to_spec(event)
|
|
if event.channel == "hbp":
|
|
scenario.inject_hbp(event.system, spec)
|
|
elif event.channel == "obp":
|
|
scenario.inject_obp(event.system, spec)
|
|
elif event.channel == "unit":
|
|
scenario.inject_unit(event.system, spec)
|
|
else:
|
|
raise ValueError(f"unknown channel {event.channel!r}")
|
|
return scenario
|
|
|
|
def assert_expectations(self, scenario: DeterministicScenario) -> None:
|
|
expect = self.session.meta.expect
|
|
for system, count in expect.forwards.items():
|
|
assert_forwarded(
|
|
scenario,
|
|
system,
|
|
count=count,
|
|
dst_id=expect.dst_id,
|
|
)
|
|
|
|
|
|
def replay_session(path: str | Path) -> DeterministicScenario:
|
|
replayer = SessionReplayer.from_path(path)
|
|
scenario = replayer.run()
|
|
replayer.assert_expectations(scenario)
|
|
return scenario
|
|
|
|
|
|
def iter_fixture_sessions() -> Iterator[Path]:
|
|
if not FIXTURES_DIR.is_dir():
|
|
return
|
|
yield from sorted(FIXTURES_DIR.glob("*.jsonl"))
|
|
|
|
|
|
def config_to_json(config: dict[str, Any]) -> dict[str, Any]:
|
|
"""JSON-safe config (ints for SERVER_ID / NETWORK_ID bytes, empty passphrase strings)."""
|
|
out = copy.deepcopy(config)
|
|
global_cfg = out.get("GLOBAL")
|
|
if isinstance(global_cfg, dict):
|
|
sid = global_cfg.get("SERVER_ID")
|
|
if isinstance(sid, bytes):
|
|
global_cfg["SERVER_ID"] = int.from_bytes(sid, "big")
|
|
for syscfg in out.get("SYSTEMS", {}).values():
|
|
if not isinstance(syscfg, dict):
|
|
continue
|
|
nid = syscfg.get("NETWORK_ID")
|
|
if isinstance(nid, bytes):
|
|
syscfg["NETWORK_ID"] = int.from_bytes(nid, "big")
|
|
pp = syscfg.get("PASSPHRASE")
|
|
if isinstance(pp, bytes):
|
|
syscfg["PASSPHRASE"] = ""
|
|
return out
|
|
|
|
|
|
def _json_sanitize(val: Any) -> Any:
|
|
if isinstance(val, bytes):
|
|
return val.decode("utf-8", errors="replace") if val else ""
|
|
if isinstance(val, dict):
|
|
return {k: _json_sanitize(v) for k, v in val.items()}
|
|
if isinstance(val, list):
|
|
return [_json_sanitize(v) for v in val]
|
|
if isinstance(val, tuple):
|
|
return [_json_sanitize(v) for v in val]
|
|
return val
|
|
|
|
|
|
@dataclass
|
|
class SessionCapture:
|
|
meta: SessionMeta
|
|
events: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
def add_ingress(self, event: IngressEvent) -> None:
|
|
row: dict[str, Any] = {
|
|
"type": "ingress",
|
|
"channel": event.channel,
|
|
"system": event.system,
|
|
"dt": event.dt,
|
|
}
|
|
if event.packet:
|
|
row["packet"] = event.packet
|
|
if event.base:
|
|
row["base"] = event.base
|
|
if event.spec:
|
|
row["spec"] = event.spec
|
|
if event.seq is not None:
|
|
row["seq"] = event.seq
|
|
if event.dtype_vseq is not None:
|
|
row["dtype_vseq"] = event.dtype_vseq
|
|
self.events.append(row)
|
|
|
|
def write(self, path: str | Path) -> None:
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
meta_row: dict[str, Any] = {
|
|
"type": "meta",
|
|
"version": self.meta.version,
|
|
"name": self.meta.name,
|
|
"apply_startup_subscriptions": self.meta.apply_startup_subscriptions,
|
|
}
|
|
if self.meta.config is not None:
|
|
meta_row["config"] = config_to_json(self.meta.config)
|
|
if self.meta.routing_table is not None:
|
|
meta_row["routing_table"] = _json_sanitize(self.meta.routing_table)
|
|
if self.meta.expect.forwards or self.meta.expect.dst_id is not None:
|
|
meta_row["expect"] = {
|
|
"forwards": self.meta.expect.forwards,
|
|
"dst_id": self.meta.expect.dst_id,
|
|
}
|
|
with path.open("w", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(meta_row, separators=(",", ":")) + "\n")
|
|
for row in self.events:
|
|
fh.write(json.dumps(row, separators=(",", ":")) + "\n")
|
|
|
|
@staticmethod
|
|
def capture_path(name: str) -> Path:
|
|
safe = name.replace("/", "_").replace("::", "__")
|
|
return _CAPTURE_DIR / f"{safe}.jsonl"
|
|
|
|
|
|
def capture_enabled() -> bool:
|
|
return os.environ.get("CAPTURE", "").strip().lower() in ("1", "true", "yes")
|