"""Replay a captured CSV onto a live CAN bus (e.g. the EPS bus via the CAN-IO board's USB bridge) - see whether the EPS will power up/behave from a recording alone, with no car attached. A stepping stone before writing a synthetic frame generator: get a known-good replay working and filtered down to the minimum frame set first, then synthesize from there. Usage: python gateway/replay_to_bus.py captures/capture_Start-Stop.csv \\ --port /dev/cu.usbmodemXXXX --bitrate 500000 \\ [--speed 1.0] [--loop] [--block 0x1D6 0x380] [--rules myrules.json] \\ [--dio-log captures/dio_20260829.csv] [--dry-run] --dry-run replays the timing/filtering logic and prints a summary without opening a serial port - useful for testing with no adapter attached. --rules loads a plain FilterRules JSON (as saved by FilterRules.save(), i.e. {"default_allow": ..., "overrides": {...}}) - not the combined car_to_eps/ eps_to_car file gateway_app.py saves. --dio-log replays the CAN-IO board's own digital outputs (e.g. the 12V signal repeat) alongside the CAN traffic, from a timeline produced by tools/extract_dio_timeline.py. Without this, a replay only sends CAN frames - if the EPS also needs that physical signal to enable, replaying CAN alone won't reproduce what it saw live. """ import argparse import csv import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from adapters.slcan_adapter import SlcanAdapter # noqa: E402 from decoder.replay_source import load_capture # noqa: E402 from gateway.rules import FilterRules # noqa: E402 CAN_ID_COMMAND = 0x101 CMD_SET_ALL = 0x02 def load_dio_events(path: Path) -> list[tuple[float, int]]: """Read a (t, inputs, outputs) timeline, keep only the times outputs changed.""" events = [] prev = None with open(path, newline="") as f: for row in csv.DictReader(f): outputs = int(row["outputs"]) if outputs != prev: events.append((float(row["t"]), outputs)) prev = outputs return events def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("csv", help="Capture file to replay (timestamp_ms, arbitration_id, is_extended, dlc, data_hex)") parser.add_argument("--port", help="Serial port of the target adapter (e.g. CAN-IO board bridge)") parser.add_argument("--bitrate", type=int, default=500_000) parser.add_argument("--speed", type=float, default=1.0, help="Playback speed multiplier") parser.add_argument("--loop", action="store_true", help="Replay repeatedly until Ctrl+C") parser.add_argument("--block", nargs="*", default=[], help="Arbitration IDs (hex) to withhold from the bus") parser.add_argument("--rules", default=None, help="Load a FilterRules JSON file instead of/in addition to --block") parser.add_argument("--dio-log", default=None, help="Replay CAN-IO board output changes from tools/extract_dio_timeline.py's output") parser.add_argument("--dry-run", action="store_true", help="Print a summary instead of sending - no adapter needed") args = parser.parse_args() frames = load_capture(Path(args.csv)) if not frames: print("No frames in capture", file=sys.stderr) return 1 dio_events = load_dio_events(Path(args.dio_log)) if args.dio_log else [] rules = FilterRules() if args.rules: rules.load(Path(args.rules)) for tok in args.block: rules.set_allow(int(tok, 16), False) if args.dry_run: adapter = None else: if not args.port: print("--port is required unless --dry-run", file=sys.stderr) return 1 adapter = SlcanAdapter(args.port, args.bitrate) # Merge CAN frames and DIO output-change events into one time-ordered timeline. timeline = [(t, "frame", (aid, data)) for t, aid, data in frames] timeline += [(t, "dio", outputs) for t, outputs in dio_events] timeline.sort(key=lambda e: e[0]) print(f"Replaying {len(frames)} frames + {len(dio_events)} DIO events ({frames[-1][0]:.1f}s) at {args.speed}x" f"{' [dry run]' if args.dry_run else f' -> {args.port}'}. Ctrl+C to stop.\n") sent = skipped = dio_sent = 0 try: while True: t0 = time.monotonic() for t, kind, payload in timeline: if kind == "frame": aid, data = payload allowed = rules.allows(aid) if allowed: if adapter is not None: adapter.send_frame(aid, data) sent += 1 else: skipped += 1 else: # "dio": force OUT1/OUT2 to match the recorded state if adapter is not None: adapter.send_frame(CAN_ID_COMMAND, bytes([CMD_SET_ALL, 0x03, payload])) dio_sent += 1 target = t0 + t / args.speed delay = target - time.monotonic() if delay > 0: time.sleep(delay) print(f"pass complete: {sent} sent, {skipped} skipped, {dio_sent} DIO changes ({len(frames)} frames total)") if not args.loop: break sent = skipped = dio_sent = 0 except KeyboardInterrupt: print("\nStopped.") finally: if adapter is not None: adapter.close() return 0 if __name__ == "__main__": raise SystemExit(main())