"""Hardware-aware replay engine, shared by gateway_app.py's Replay tab. Unlike decoder/replay_source.py's ReplayPlayer (which only feeds a FrameStore for visualization), this actually sends allowed frames to a real adapter, and can also replay the CAN-IO board's own digital output changes (see tools/extract_dio_timeline.py) - both driven by the same wall-clock advance() call so it fits Streamlit's rerun-loop pattern with no background thread needed. """ from __future__ import annotations import csv from pathlib import Path from typing import Optional CAN_ID_COMMAND = 0x101 CAN_ID_STATUS = 0x100 CMD_SET_ALL = 0x02 def load_capture(path: Path) -> list[tuple[float, int, bytes]]: """Same format/behaviour as decoder.replay_source.load_capture.""" rows = [] with open(path, newline="") as f: for row in csv.DictReader(f): rows.append((int(row["timestamp_ms"]), int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"]))) rows.sort(key=lambda r: r[0]) if not rows: return [] t0 = rows[0][0] return [((ms - t0) / 1000, aid, data) for ms, aid, data in rows] def load_dio_events(path: Path) -> list[tuple[float, int]]: """Read a DIO timeline, keeping only the times the outputs changed. Accepts both layouts we produce: - gateway/dio_monitor.py's DioLogger: t, uptime_s, in1..in4, out1, out2, event - the older tools/extract_dio_timeline.py: t, inputs, outputs """ events = [] prev = None with open(path, newline="") as f: reader = csv.DictReader(f) for row in reader: if "out1" in row: outputs = (int(row["out1"]) & 1) | ((int(row["out2"]) & 1) << 1) else: outputs = int(row["outputs"]) if outputs != prev: events.append((float(row["t"]), outputs)) prev = outputs return events def gateway_log_to_capture(gateway_log: Path, direction: str = "car->eps", include_blocked: bool = False) -> list[tuple[float, int, bytes]]: """Same idea as tools/gateway_log_to_capture.py, but in-process.""" rows = [] with open(gateway_log, newline="") as f: for row in csv.DictReader(f): if row["direction"] != direction: continue if not include_blocked and row["relayed"] != "1": continue rows.append((float(row["t"]), int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"]))) rows.sort(key=lambda r: r[0]) if not rows: return [] t0 = rows[0][0] return [(t - t0, aid, data) for t, aid, data in rows] def extract_dio_events(gateway_log: Path) -> list[tuple[float, int]]: """Same idea as tools/extract_dio_timeline.py, but in-process.""" samples = [] with open(gateway_log, newline="") as f: for row in csv.DictReader(f): if int(row["arbitration_id"], 16) != CAN_ID_STATUS: continue data = bytes.fromhex(row["data_hex"]) if len(data) < 2: continue samples.append((float(row["t"]), data[1])) events = [] prev = None for t, outputs in samples: if outputs != prev: events.append((t, outputs)) prev = outputs return events class HardwareReplayPlayer: """Steps through a merged (frames + DIO events) timeline, sending to a real adapter as it goes, driven by repeated advance() calls (Streamlit rerun loop) rather than a background thread.""" def __init__(self, frames: list[tuple[float, int, bytes]], dio_events: Optional[list[tuple[float, int]]] = None): self.frames = frames timeline = [(t, "frame", (aid, data)) for t, aid, data in frames] timeline += [(t, "dio", outputs) for t, outputs in (dio_events or [])] timeline.sort(key=lambda e: e[0]) self.timeline = timeline self.duration = frames[-1][0] if frames else 0.0 self.index = 0 self.clock = 0.0 self.sent = 0 self.skipped = 0 self.dio_sent = 0 def reset(self) -> None: self.index = 0 self.clock = 0.0 self.sent = self.skipped = self.dio_sent = 0 def at_end(self) -> bool: return self.index >= len(self.timeline) def advance(self, dt_wall: float, speed: float, store, rules, adapter=None) -> None: self.clock += dt_wall * speed while self.index < len(self.timeline) and self.timeline[self.index][0] <= self.clock: t, kind, payload = self.timeline[self.index] if kind == "frame": aid, data = payload allowed = rules.allows(aid) if allowed: if adapter is not None: adapter.send_frame(aid, data) self.sent += 1 store.feed(aid, data, t) else: self.skipped += 1 else: # "dio" if adapter is not None: # Direct USB IO channel, not a CAN command frame: works even # when the EPS bus has no other powered node to ACK traffic. for idx in range(2): adapter.set_output(idx, bool(payload & (1 << idx))) self.dio_sent += 1 self.index += 1 def seek(self, t: float, store) -> None: store.reset() self.reset() self.clock = t while self.index < len(self.timeline) and self.timeline[self.index][0] <= self.clock: frame_t, kind, payload = self.timeline[self.index] if kind == "frame": aid, data = payload store.feed(aid, data, frame_t) self.sent += 1 else: self.dio_sent += 1 self.index += 1