"""Live monitor + filter control for the car<->EPS gateway, plus a replay tab. "/Users/luca/Projects/Stuurhuis CAN/.venv/bin/python" -m streamlit run gateway/gateway_app.py Two tabs: - Live relay: starts the gateway fully transparent (every frame relayed both ways). Each side's bus monitor table has an "Allow" column - untick an ID to start blocking it in that direction, then check whether the EPS (or car) still behaves. Save/load a ruleset to come back to a known filter set. Also has the CAN-IO board IO test panel. - Replay: plays a capture (optionally with a digital-output timeline, see tools/extract_dio_timeline.py) onto the EPS port alone, no car needed. Build a replay file directly from a recorded gateway session, filter which IDs get sent, and monitor the board's digital output live. The live relay and the replay tab can't use the EPS port at the same time - stop one before starting the other. """ from __future__ import annotations import csv as csvmod import json import sys import time from pathlib import Path import pandas as pd import streamlit as st ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from adapters.candapter import Candapter # noqa: E402 from adapters.slcan_adapter import SlcanAdapter # noqa: E402 from decoder.ptcan_decoder import FrameStore, NAMES # noqa: E402 from gateway.dio_monitor import DioLogger, DioMonitor # noqa: E402 from gateway.eps_bench import ( # noqa: E402 PRESETS, EpsBench, TxEntry, entries_from_capture, required_rate, ) from gateway.eps_control import ( # noqa: E402 CAN_ID_ROAD_SPEED, CAN_ID_TERMINAL, TERMINAL_STATES, EpsController, decode_eps_message, ) from gateway.gateway import Gateway, GatewayLogger # noqa: E402 from gateway.replay_runner import ( # noqa: E402 HardwareReplayPlayer, extract_dio_events, gateway_log_to_capture, load_capture, load_dio_events, ) from gateway.rules import FilterRules # noqa: E402 CAPTURES_DIR = ROOT / "captures" DEFAULT_CAR_PORT = "/dev/cu.usbserial-DNBJV4F5" RULES_DIR = ROOT / "gateway" REFRESH_INTERVAL = 0.4 CAN_ID_STATUS = 0x100 CAN_ID_COMMAND = 0x101 STATUS_REASONS = {0: "periodic", 1: "change", 2: "request", 3: "boot"} st.set_page_config(page_title="Car <-> EPS Gateway", layout="wide") # ---------------------------------------------------------- shared state -- @st.cache_resource def get_car_store() -> FrameStore: return FrameStore() @st.cache_resource def get_eps_store() -> FrameStore: return FrameStore() @st.cache_resource def get_rules(): return {"car_to_eps": FilterRules(), "eps_to_car": FilterRules()} class GatewayManager: """Cached-resource holder: owns the current Gateway + adapters, if any.""" def __init__(self): self.gateway: Gateway | None = None self.car = None self.eps = None self.error: str | None = None def is_running(self) -> bool: return self.gateway is not None and self.gateway.is_running() def start(self, car_port, car_bitrate, eps_port, eps_bitrate, car_store, eps_store, rules, log_path): self.error = None try: self.car = Candapter(car_port, car_bitrate, timestamps=True) self.eps = SlcanAdapter(eps_port, eps_bitrate) except (OSError, ValueError) as exc: self.error = str(exc) return logger = GatewayLogger(log_path) if log_path else None self.gateway = Gateway( self.car, self.eps, car_to_eps_rules=rules["car_to_eps"], eps_to_car_rules=rules["eps_to_car"], car_store=car_store, eps_store=eps_store, logger=logger, ) self.gateway.start() def stop(self): if self.gateway is not None: self.gateway.stop() if self.car is not None: self.car.close() if self.eps is not None: self.eps.close() self.gateway = None class ReplayManager: """Cached-resource holder: owns the replay-only EPS adapter, if any.""" def __init__(self): self.eps = None self.error: str | None = None self.playing = False self.wall = None def is_connected(self) -> bool: return self.eps is not None def connect(self, port, bitrate): self.error = None try: self.eps = SlcanAdapter(port, bitrate) except (OSError, ValueError) as exc: self.error = str(exc) def disconnect(self): if self.eps is not None: self.eps.close() self.eps = None self.playing = False @st.cache_resource def get_manager() -> GatewayManager: return GatewayManager() class DioHolder: """Cached-resource holder for the current DioMonitor, if any.""" def __init__(self): self.monitor: DioMonitor | None = None def start(self, adapter, log_path, owns_reads: bool): self.stop() logger = DioLogger(log_path) if log_path else None self.monitor = DioMonitor(adapter, logger=logger, owns_reads=owns_reads) self.monitor.start() def stop(self): if self.monitor is not None: self.monitor.stop() self.monitor = None @st.cache_resource def get_dio() -> DioHolder: return DioHolder() class BenchHolder: """Cached-resource holder for the EPS bench session, if any.""" def __init__(self): self.adapter = None self.bench: EpsBench | None = None self.monitor: DioMonitor | None = None self.error: str | None = None def is_connected(self) -> bool: return self.adapter is not None def connect(self, port, bitrate, log_path, dio_log_path): self.error = None try: self.adapter = SlcanAdapter(port, bitrate) except (OSError, ValueError) as exc: self.error = str(exc) return self.bench = EpsBench(self.adapter, log_path) # The bench thread owns the serial reads, so the IO monitor only # requests and samples (see DioMonitor.owns_reads). self.monitor = DioMonitor(self.adapter, logger=DioLogger(dio_log_path) if dio_log_path else None, poll_interval=0.5, owns_reads=False) self.monitor.start() def disconnect(self): if self.bench is not None: self.bench.stop() if self.monitor is not None: self.monitor.stop() if self.adapter is not None: self.adapter.close() self.adapter = None self.bench = None self.monitor = None @st.cache_resource def get_bench() -> BenchHolder: return BenchHolder() class ControlHolder: """Cached-resource holder for the EPS control session, if any.""" def __init__(self): self.adapter = None self.ctrl: EpsController | None = None self.monitor: DioMonitor | None = None self.error: str | None = None def is_connected(self) -> bool: return self.adapter is not None def connect(self, port, bitrate, capture, log_path, dio_log_path): self.error = None try: self.adapter = SlcanAdapter(port, bitrate) except (OSError, ValueError) as exc: self.error = str(exc) return self.ctrl = EpsController(self.adapter, capture, log_path) self.monitor = DioMonitor(self.adapter, logger=DioLogger(dio_log_path) if dio_log_path else None, poll_interval=0.5, owns_reads=False) self.monitor.start() def disconnect(self): if self.ctrl is not None: self.ctrl.stop() if self.monitor is not None: self.monitor.stop() if self.adapter is not None: self.adapter.close() self.adapter = None self.ctrl = None self.monitor = None @st.cache_resource def get_control() -> ControlHolder: return ControlHolder() @st.cache_resource def get_replay_store() -> FrameStore: return FrameStore() @st.cache_resource def get_replay_rules() -> FilterRules: return FilterRules() @st.cache_resource def get_replay_manager() -> ReplayManager: return ReplayManager() @st.cache_resource def get_replay_player(capture_path: str, dio_path: str) -> HardwareReplayPlayer: frames = load_capture(Path(capture_path)) dio_events = load_dio_events(Path(dio_path)) if dio_path else [] return HardwareReplayPlayer(frames, dio_events) car_store = get_car_store() eps_store = get_eps_store() rules = get_rules() mgr = get_manager() replay_store = get_replay_store() replay_rules = get_replay_rules() rmgr = get_replay_manager() dio = get_dio() bench = get_bench() control = get_control() st.title("Car <-> EPS Gateway") tab_live, tab_replay, tab_eps, tab_control = st.tabs( ["Live relay", "Replay", "EPS bench", "EPS control"] ) # ------------------------------------------------------------- live tab -- def bus_table(store: FrameStore, out_rules: FilterRules, stats_passed, stats_blocked) -> pd.DataFrame: snap = store.snapshot() rows = [] for aid in sorted(snap["latest"]): entry = snap["latest"][aid] decoded = ", ".join( f"{k}={v[0]:.2f}{v[1]}" if isinstance(v[0], float) else f"{k}={v[0]}{v[1]}" for k, v in entry["decoded"].items() ) rows.append( { "ID": f"0x{aid:03X}", "_aid": aid, "Message": NAMES.get(aid, ""), "Hz": round(store.hz(aid), 1), "Passed": stats_passed.get(aid, 0), "Blocked": stats_blocked.get(aid, 0), "Data": entry["data"].hex(" ").upper(), "Decoded": decoded, "Allow": out_rules.allows(aid), } ) return pd.DataFrame(rows).sort_values("ID") if rows else pd.DataFrame( columns=["ID", "_aid", "Message", "Hz", "Passed", "Blocked", "Data", "Decoded", "Allow"] ) def io_test_panel(key_prefix: str) -> None: """Digital IO panel, driven by the board's CAN-independent USB channel. Deliberately does NOT read the 0x100 CAN status frame: that path dies whenever the CAN bus has no other powered node (every transmit times out and the shared TX queue starts dropping frames) - which is exactly the state you're in before the EPS gets its 12V enable from OUT1. See gateway/dio_monitor.py and can-io/firmware/src/usb_bridge.h. Firmware no longer auto-mirrors IN1->OUT1 either (it used to fight manual control within ~40ms); the "software passthrough" checkbox does that job from here, so it can be switched off for direct control. """ monitor = dio.monitor if monitor is None: st.info("Not connected — start the relay or connect the replay adapter first.") return in_bitmap, out_bitmap = monitor.inputs, monitor.outputs driven_key = f"{key_prefix}_passthrough_last" st.session_state.setdefault(driven_key, None) passthrough_on = st.checkbox( "Software passthrough: mirror IN1 -> OUT1 automatically", key=f"{key_prefix}_passthrough", help="Drives OUT1 to match IN1 over the direct USB IO channel, from here rather " "than in firmware. Turn off for uninterrupted manual control.", ) if not passthrough_on: st.session_state[driven_key] = None elif monitor.last_update is not None: desired = 1 if (in_bitmap & 0x01) else 0 if st.session_state[driven_key] != desired: if monitor.set_output_verified(0, bool(desired)): st.session_state[driven_key] = desired ioc1, ioc2, ioc3 = st.columns([1, 1, 2]) with ioc1: st.write("**Inputs** — car → this board") for i in range(4): st.write(f"{'🟢' if in_bitmap & (1 << i) else '⚪'} IN{i + 1}") with ioc2: st.write("**Outputs** — this board → EPS") for i in range(2): st.write(f"{'🟢' if out_bitmap & (1 << i) else '⚪'} OUT{i + 1}") b1, b2, b3 = st.columns(3) if b1.button("On", key=f"{key_prefix}_out{i}_on"): if not monitor.set_output_verified(i, True): st.warning(f"OUT{i + 1} did not confirm ON") if i == 0: st.session_state[driven_key] = 1 if b2.button("Off", key=f"{key_prefix}_out{i}_off"): if not monitor.set_output_verified(i, False): st.warning(f"OUT{i + 1} did not confirm OFF") if i == 0: st.session_state[driven_key] = 0 if b3.button("Toggle", key=f"{key_prefix}_out{i}_tgl"): monitor.set_output_verified(i, not bool(out_bitmap & (1 << i))) if i == 0: st.session_state[driven_key] = None # unknown now; passthrough resyncs next tick with ioc3: st.write("**Link**") if monitor.last_update is None: st.warning("No IO report received yet.") else: age = time.time() - monitor.last_update fresh = "🟢 live" if age < 2 else f"🟠 stale ({age:.1f}s)" st.write(f"{fresh} · board uptime {monitor.uptime_s}s") st.write(f"updates: {monitor.updates} · transitions logged: {monitor.changes}") if monitor.logger is not None: st.caption("Logging IO separately from CAN to captures/dio_live_*.csv") with tab_live: st.sidebar.title("Live relay") car_port = st.sidebar.text_input("Car port", DEFAULT_CAR_PORT) car_bitrate = st.sidebar.selectbox("Car bitrate", [500_000, 250_000, 125_000, 1_000_000], index=0) eps_port = st.sidebar.text_input("EPS port (CAN-IO board)", "/dev/cu.usbmodem101") eps_bitrate = st.sidebar.selectbox("EPS bitrate", [500_000, 250_000, 125_000, 1_000_000], index=0) log_enabled = st.sidebar.checkbox("Log every frame to CSV", value=True) c1, c2 = st.sidebar.columns(2) if c1.button("Start", disabled=mgr.is_running() or rmgr.is_connected(), width="stretch"): car_store.reset() eps_store.reset() stamp = time.strftime("%Y%m%d_%H%M%S") log_path = CAPTURES_DIR / f"gateway_{stamp}.csv" if log_enabled else None mgr.start(car_port, car_bitrate, eps_port, eps_bitrate, car_store, eps_store, rules, log_path) if mgr.is_running(): # Separate log, separate transport: the relay thread owns the serial # reads, so the monitor only writes '@G' requests and samples. dio_log = CAPTURES_DIR / f"dio_live_{stamp}.csv" if log_enabled else None dio.start(mgr.eps, dio_log, owns_reads=False) if c2.button("Stop", disabled=not mgr.is_running(), width="stretch"): dio.stop() mgr.stop() if rmgr.is_connected(): st.sidebar.warning("Replay tab is using the EPS port - disconnect it first.") elif mgr.error: st.sidebar.error(f"Adapter error: {mgr.error}") elif mgr.is_running(): st.sidebar.success("Gateway running") else: st.sidebar.caption("Stopped") st.sidebar.divider() st.sidebar.subheader("Quick actions") qc1, qc2 = st.sidebar.columns(2) if qc1.button("Allow all", width="stretch"): rules["car_to_eps"].allow_all() rules["eps_to_car"].allow_all() if qc2.button("Block all", width="stretch"): rules["car_to_eps"].block_all() rules["eps_to_car"].block_all() ruleset_name = st.sidebar.text_input("Ruleset file", "rules.default.json") sc1, sc2 = st.sidebar.columns(2) if sc1.button("Save ruleset", width="stretch"): (RULES_DIR / ruleset_name).write_text( json.dumps( {"car_to_eps": rules["car_to_eps"].snapshot(), "eps_to_car": rules["eps_to_car"].snapshot()}, indent=2, sort_keys=True, ) ) st.sidebar.success(f"Saved {ruleset_name}") if sc2.button("Load ruleset", width="stretch"): data = json.loads((RULES_DIR / ruleset_name).read_text()) rules["car_to_eps"].load_snapshot(data["car_to_eps"]) rules["eps_to_car"].load_snapshot(data["eps_to_car"]) st.sidebar.success(f"Loaded {ruleset_name}") passed, blocked = mgr.gateway.stats.snapshot() if mgr.gateway else ({}, {}) col_car, col_eps = st.columns(2) with col_car: st.subheader("Car bus (frames car -> EPS)") df_car = bus_table(car_store, rules["car_to_eps"], passed, blocked) edited_car = st.data_editor( df_car, width="stretch", height=380, hide_index=True, key="car_editor", column_config={"_aid": None, "Allow": st.column_config.CheckboxColumn()}, disabled=["ID", "Message", "Hz", "Passed", "Blocked", "Data", "Decoded"], ) for _, row in edited_car.iterrows(): rules["car_to_eps"].set_allow(int(row["_aid"]), bool(row["Allow"])) with col_eps: st.subheader("EPS bus (frames EPS -> car)") df_eps = bus_table(eps_store, rules["eps_to_car"], passed, blocked) edited_eps = st.data_editor( df_eps, width="stretch", height=380, hide_index=True, key="eps_editor", column_config={"_aid": None, "Allow": st.column_config.CheckboxColumn()}, disabled=["ID", "Message", "Hz", "Passed", "Blocked", "Data", "Decoded"], ) for _, row in edited_eps.iterrows(): rules["eps_to_car"].set_allow(int(row["_aid"]), bool(row["Allow"])) st.caption( f"Total passed: {sum(passed.values())} · Total blocked: {sum(blocked.values())} · " "Untick 'Allow' on an ID to block it in that direction, then check the EPS/car still behave." ) st.subheader("Digital IO — car signal in, EPS enable out") st.caption("Uses the board's direct USB IO channel, independent of the CAN bus, and logs " "to its own captures/dio_live_*.csv (separate from the CAN log).") if not mgr.is_running(): st.info("Start the gateway above (with the EPS/CAN-IO port set) to monitor and drive the IO.") else: io_test_panel("live") live_active = mgr.is_running() # ----------------------------------------------------------- replay tab -- with tab_replay: st.caption( "Plays a capture onto the EPS port alone (no car needed). Uses the EPS port/bitrate " "from the Live relay sidebar - stop the live relay first if it's running." ) if "replay_capture" not in st.session_state: existing = sorted(p.name for p in CAPTURES_DIR.glob("*.csv") if p.name.startswith(("capture", "replay"))) st.session_state.replay_capture = existing[-1] if existing else None st.session_state.replay_dio = None with st.expander("Build a replay file from a recorded gateway session", expanded=st.session_state.replay_capture is None): gw_logs = sorted(p.name for p in CAPTURES_DIR.glob("gateway_*.csv")) if not gw_logs: st.write("No gateway_*.csv session logs yet - run the live relay with logging on first.") else: gw_choice = st.selectbox("Gateway session log", gw_logs, index=len(gw_logs) - 1) direction = st.radio("Direction to replay", ["car->eps", "eps->car"], horizontal=True) st.caption( "The CAN frames come from the session log. For the DIO timeline, a matching " "dio_live_*.csv (recorded on the independent USB channel) is preferred; " "otherwise it falls back to the 0x100 status frames inside the CAN log." ) if st.button("Build & use this replay file"): stem = Path(gw_choice).stem cap_path = CAPTURES_DIR / f"replay_{stem}.csv" frames = gateway_log_to_capture(CAPTURES_DIR / gw_choice, direction=direction) with open(cap_path, "w", newline="") as f: w = csvmod.writer(f) w.writerow(["timestamp_ms", "arbitration_id", "is_extended", "dlc", "data_hex"]) for t, aid, data in frames: w.writerow([round(t * 1000), f"{aid:X}", 0, len(data), data.hex().upper()]) # Same timestamp suffix as the CAN log, if that session recorded one. sibling = CAPTURES_DIR / f"dio_live_{stem.removeprefix('gateway_')}.csv" if sibling.exists(): dio_name = sibling.name dio_note = f"using recorded {dio_name}" else: dio_path = CAPTURES_DIR / f"dio_{stem}.csv" dio_events = extract_dio_events(CAPTURES_DIR / gw_choice) with open(dio_path, "w", newline="") as f: w = csvmod.writer(f) w.writerow(["t", "inputs", "outputs"]) for t, outputs in dio_events: w.writerow([t, "", outputs]) dio_name = dio_path.name if dio_events else None dio_note = f"extracted {len(dio_events)} DIO changes from CAN status frames" st.session_state.replay_capture = cap_path.name st.session_state.replay_dio = dio_name st.success(f"Saved {cap_path.name} ({len(frames)} frames) · {dio_note}") st.rerun() cap_files = sorted(p.name for p in CAPTURES_DIR.glob("*.csv") if p.name.startswith(("capture", "replay"))) dio_files = sorted(p.name for p in CAPTURES_DIR.glob("dio_*.csv")) rc1, rc2 = st.columns(2) with rc1: cap_index = cap_files.index(st.session_state.replay_capture) if st.session_state.replay_capture in cap_files else 0 chosen_cap = st.selectbox("Capture file", cap_files, index=cap_index if cap_files else 0) if cap_files else None with rc2: dio_options = ["(none)"] + dio_files dio_index = dio_options.index(st.session_state.replay_dio) if st.session_state.replay_dio in dio_options else 0 chosen_dio = st.selectbox("DIO timeline (optional)", dio_options, index=dio_index) chosen_dio = None if chosen_dio == "(none)" else chosen_dio st.session_state.replay_capture = chosen_cap st.session_state.replay_dio = chosen_dio if not chosen_cap: st.info("No capture files in captures/ yet.") else: player = get_replay_player(str(CAPTURES_DIR / chosen_cap), str(CAPTURES_DIR / chosen_dio) if chosen_dio else "") pc1, pc2, pc3, pc4 = st.columns(4) with pc1: if not rmgr.is_connected(): if st.button("Connect", disabled=mgr.is_running(), width="stretch"): rmgr.connect(eps_port, eps_bitrate) if rmgr.is_connected(): replay_store.reset() # The replay loop only reads while playing, so the monitor # owns the serial reads here to stay live when paused. dio_log = CAPTURES_DIR / time.strftime("dio_replay_%Y%m%d_%H%M%S.csv") dio.start(rmgr.eps, dio_log, owns_reads=True) else: if st.button("Disconnect", width="stretch"): dio.stop() rmgr.disconnect() with pc2: speed = st.slider("Speed", 0.1, 10.0, 1.0, step=0.1, key="replay_speed") with pc3: label = "Pause" if rmgr.playing else "Play" if st.button(label, disabled=not rmgr.is_connected(), width="stretch"): rmgr.playing = not rmgr.playing rmgr.wall = time.monotonic() with pc4: if st.button("Restart", width="stretch"): player.reset() replay_store.reset() rmgr.playing = False if rmgr.error: st.error(f"Adapter error: {rmgr.error}") elif rmgr.is_connected(): st.success(f"Connected to {eps_port}") else: st.caption("Not connected - click Connect (uses the EPS port from the Live relay sidebar).") st.caption( f"{player.clock:.1f}s / {player.duration:.1f}s · sent {player.sent} · " f"blocked {player.skipped} · DIO changes sent {player.dio_sent}" ) if rmgr.playing and rmgr.is_connected(): now = time.monotonic() dt = min(now - (rmgr.wall or now), 1.0) rmgr.wall = now player.advance(dt, speed, replay_store, replay_rules, adapter=rmgr.eps) if player.at_end(): rmgr.playing = False st.subheader("Replay filter") st.caption("Untick 'Allow' to withhold an ID from this replay - independent of the Live relay's filters.") df_replay = bus_table(replay_store, replay_rules, {}, {}) edited_replay = st.data_editor( df_replay, width="stretch", height=380, hide_index=True, key="replay_editor", column_config={"_aid": None, "Allow": st.column_config.CheckboxColumn()}, disabled=["ID", "Message", "Hz", "Passed", "Blocked", "Data", "Decoded"], ) for _, row in edited_replay.iterrows(): replay_rules.set_allow(int(row["_aid"]), bool(row["Allow"])) st.subheader("Digital IO — replayed EPS enable") st.caption("Driven over the direct USB IO channel. Replayed OUT changes come from the " "selected DIO timeline; you can still override manually here.") if not rmgr.is_connected(): st.info("Connect above to monitor and command the board's IO during replay.") else: io_test_panel("replay") replay_active = rmgr.playing # -------------------------------------------------------------- EPS bench -- with tab_eps: st.caption( "Drive the EPS on its own and watch what it reports back. Unlike Replay, this " "transmits a chosen set of messages on a cycle - so you can add/remove IDs and " "edit payloads to find the minimum the EPS needs, without re-recording. " "Uses the EPS port/bitrate from the Live relay sidebar." ) bc1, bc2, bc3 = st.columns([1, 1, 2]) with bc1: if not bench.is_connected(): if st.button("Connect", disabled=mgr.is_running() or rmgr.is_connected(), width="stretch", key="bench_connect"): stamp = time.strftime("%Y%m%d_%H%M%S") bench.connect(eps_port, eps_bitrate, CAPTURES_DIR / f"eps_bench_{stamp}.csv", CAPTURES_DIR / f"dio_bench_{stamp}.csv") else: if st.button("Disconnect", width="stretch", key="bench_disconnect"): bench.disconnect() with bc2: if bench.bench is not None: if not bench.bench.is_running(): if st.button("Start TX", width="stretch", key="bench_start"): bench.bench.start() else: if st.button("Stop TX", width="stretch", key="bench_stop"): bench.bench.stop() with bc3: if bench.error: st.error(f"Adapter error: {bench.error}") elif bench.is_connected(): tx_on = bench.bench is not None and bench.bench.is_running() st.success(f"Connected to {eps_port} · transmitting: {'yes' if tx_on else 'no'}") else: st.caption("Not connected.") if bench.is_connected(): st.subheader("EPS enable (12V via OUT1)") mon = bench.monitor bench.bench.refresh_counters = st.checkbox( "Keep alive counters & checksums live", value=bench.bench.refresh_counters, help="Re-stamps each frame's alive counter (0..14, skipping 15) and recomputes its " "checksum on every send. A captured payload retransmitted verbatim has a frozen " "counter, which consumers treat as a stale sender - the usual reason static " "transmission fails where replay of the same bytes worked.", ) sc1, sc2, sc3 = st.columns([1, 1, 2]) with sc1: pre_bus = st.number_input("Bus-alive lead (s)", 0.0, 10.0, 1.0, step=0.5, help="How long to transmit before applying the 12V enable.") with sc2: settle = st.number_input("Settle (s)", 0.0, 15.0, 2.0, step=0.5, help="Hold time after the enable while the EPS initialises.") with sc3: st.write("") if st.button("Run startup sequence", width="stretch", key="bench_startup", disabled=not bench.bench.tx): with st.spinner("Bringing up the EPS..."): bench.bench.run_startup_sequence(mon, pre_bus_s=pre_bus, settle_s=settle) st.success("Sequence complete - check the EPS response table below.") st.caption("Transmits first so the bus looks alive, then applies the enable, " "then holds - rather than waking the EPS into silence.") ec1, ec2, ec3 = st.columns([1, 1, 2]) with ec1: if st.button("Enable EPS", width="stretch", key="bench_eps_on"): if not mon.set_output_verified(0, True): st.warning("OUT1 did not confirm ON") with ec2: if st.button("Disable EPS", width="stretch", key="bench_eps_off"): if not mon.set_output_verified(0, False): st.warning("OUT1 did not confirm OFF") with ec3: st.write(f"{'🟢' if mon.outputs & 1 else '⚪'} OUT1 (EPS enable) · " f"{'🟢' if mon.inputs & 1 else '⚪'} IN1 (car signal)") if mon.last_update is not None: age = time.time() - mon.last_update st.caption(f"{'🟢 live' if age < 2 else f'🟠 stale ({age:.1f}s)'} · uptime {mon.uptime_s}s") st.subheader("Transmit set") with st.expander("Load a transmit set from a capture", expanded=not bench.bench.tx): cap_choices = sorted(p.name for p in CAPTURES_DIR.glob("*.csv") if p.name.startswith(("capture", "replay"))) if cap_choices: src = st.selectbox("Source capture", cap_choices, index=len(cap_choices) - 1, key="bench_src") subset = st.radio( "Which IDs", ["Minimal candidate", "Core chassis + powertrain", "Everything in the capture"], horizontal=True, key="bench_subset", help="Fewer IDs means a lower frame rate to sustain - the full set needs ~1330/s, " "which this PC-side bench struggles to hold while the UI runs. Late frames look " "like a faulty sender to the EPS, so a smaller set is often more stable, not less.", ) st.caption("Each ID becomes one entry, cycling the payload sequence it actually sent " "in that capture (real counters and checksums).") if st.button("Load transmit set"): ids = PRESETS.get(subset) entries = entries_from_capture(CAPTURES_DIR / src, ids=ids) bench.bench.set_entries(entries) st.success(f"Loaded {len(entries)} IDs · needs {required_rate(entries):.0f} frames/s") st.rerun() else: st.write("No capture files available.") tx_map, rx_map = bench.bench.snapshot() if tx_map: enabled = [e for e in tx_map.values() if e.enabled] need = required_rate(enabled) actual = bench.bench.actual_rate() if actual is not None: pct = actual / need * 100 if need else 100 msg = f"sending {actual:.0f} of {need:.0f} frames/s needed ({pct:.0f}%)" if pct < 90: st.warning(f"{msg} — frames are arriving late, which the EPS reads as a faulty " "sender. Try a smaller ID set.") else: st.success(msg) else: st.caption(f"This set needs {need:.0f} frames/s when transmitting.") ac1, ac2 = st.columns(2) if ac1.button("Enable all IDs", width="stretch"): bench.bench.enable_all(True) if ac2.button("Disable all IDs", width="stretch"): bench.bench.enable_all(False) tx_rows = [ { "ID": f"0x{e.arbitration_id:03X}", "_aid": e.arbitration_id, "Message": NAMES.get(e.arbitration_id, ""), "Period (s)": e.period_s, "Payloads": len(e.sequence) or 1, "Payload": e.data.hex(" ").upper(), "Sent": e.sent, "Send": e.enabled, } for e in sorted(tx_map.values(), key=lambda x: x.arbitration_id) ] edited_tx = st.data_editor( pd.DataFrame(tx_rows), width="stretch", height=340, hide_index=True, key="bench_tx_editor", column_config={"_aid": None, "Send": st.column_config.CheckboxColumn()}, disabled=["ID", "Message", "Sent", "Payloads"], ) for _, row in edited_tx.iterrows(): aid = int(row["_aid"]) bench.bench.set_enabled(aid, bool(row["Send"])) entry = tx_map.get(aid) if entry is not None: try: new_data = bytes.fromhex(str(row["Payload"]).replace(" ", "")) if new_data != entry.data: bench.bench.set_payload(aid, new_data) entry.sequence = [] # explicit edit wins over the captured sequence except ValueError: pass try: new_period = float(row["Period (s)"]) if new_period > 0: entry.period_s = new_period except (TypeError, ValueError): pass st.caption( "'Payloads' is how many captured frames this ID cycles through - that replays its " "real alive-counter/checksum progression, which a single frozen payload can't. " "Untick 'Send' to drop an ID and see if the EPS still comes up. Editing 'Payload' " "switches that ID to the single static value you type (counters then come from the " "checkbox above)." ) else: st.info("No transmit set loaded yet.") st.subheader("EPS response") rc1, rc2 = st.columns([3, 1]) with rc2: if st.button("Clear stats", width="stretch"): bench.bench.reset_rx() if rx_map: rx_rows = [] for aid, info in sorted(rx_map.items()): changed = " ".join( f"b{i}" for i in range(len(info.last_data)) if info.changed_mask & (1 << i) ) rx_rows.append( { "ID": f"0x{aid:03X}", "Message": NAMES.get(aid, ""), "Count": info.count, "Hz": round(info.hz, 1), "Last data": info.last_data.hex(" ").upper(), "Bytes that move": changed or "-", "First seen (s)": round(info.first_seen, 2), } ) with rc1: st.dataframe(pd.DataFrame(rx_rows), width="stretch", height=300, hide_index=True) st.caption("'Bytes that move' marks positions that have ever changed - the quickest " "way to spot which bytes carry live signals vs. constants.") else: st.info("Nothing received from the EPS yet. Enable it above and start transmitting.") bench_active = bench.is_connected() # ------------------------------------------------------------ EPS control -- with tab_control: st.caption( "Direct control of the two signals that matter: CAS terminal status (0x130) to bring " "the unit up, DSC road speed (0x1A0) to set how much assist it gives. " "Uses the EPS port/bitrate from the Live relay sidebar." ) cc1, cc2, cc3 = st.columns([1, 1, 2]) with cc1: if not control.is_connected(): src_caps = sorted(p.name for p in CAPTURES_DIR.glob("*.csv") if p.name.startswith(("capture", "replay"))) if st.button("Connect", disabled=mgr.is_running() or rmgr.is_connected() or bench.is_connected(), width="stretch", key="ctrl_connect"): stamp = time.strftime("%Y%m%d_%H%M%S") control.connect(eps_port, eps_bitrate, CAPTURES_DIR / (src_caps[-1] if src_caps else ""), CAPTURES_DIR / f"eps_control_{stamp}.csv", CAPTURES_DIR / f"dio_control_{stamp}.csv") else: if st.button("Disconnect", width="stretch", key="ctrl_disconnect"): control.disconnect() with cc2: if control.ctrl is not None: if not control.ctrl.is_running(): if st.button("Start TX", width="stretch", key="ctrl_start"): control.ctrl.start() else: if st.button("Stop TX", width="stretch", key="ctrl_stop"): control.ctrl.stop() with cc3: if control.error: st.error(f"Adapter error: {control.error}") elif control.is_connected(): running = control.ctrl is not None and control.ctrl.is_running() st.success(f"Connected to {eps_port} · transmitting: {'yes' if running else 'no'}") else: st.caption("Not connected.") if control.is_connected() and control.ctrl is not None: ctrl = control.ctrl mon = control.monitor states = ctrl.available_states() st.subheader("Startup / shutdown sequence") seq1, seq2, seq3 = st.columns([1, 1, 2]) with seq1: if st.button("▶ Run startup", width="stretch", key="ctrl_startup"): with st.status("Bringing the EPS up...", expanded=True) as status: st.write("1/4 · transmitting terminal = off") ctrl.set_terminal(TERMINAL_STATES["off"]) if not ctrl.is_running(): ctrl.start() time.sleep(1.0) st.write("2/4 · applying 12V enable (OUT1)") mon.set_output_verified(0, True) time.sleep(0.5) for label, key in (("3/4 · terminal R", "terminal R"), ("4/4 · ignition (KL15)", "ignition (KL15)")): if key in states: st.write(f"{label}") ctrl.set_terminal(states[key]) time.sleep(1.5) if "engine running" in states: st.write("holding at 'engine running'") ctrl.set_terminal(states["engine running"]) time.sleep(2.0) status.update(label="Startup sequence complete", state="complete") with seq2: if st.button("⏹ Run shutdown", width="stretch", key="ctrl_shutdown"): with st.status("Shutting the EPS down...", expanded=True) as status: for label, key in (("terminal 15", "ignition (KL15)"), ("terminal R", "terminal R"), ("off", "off")): if key in states: st.write(f"terminal -> {label}") ctrl.set_terminal(states[key]) time.sleep(1.0) st.write("removing 12V enable") mon.set_output_verified(0, False) status.update(label="Shutdown complete", state="complete") with seq3: st.write(f"{'🟢' if mon.outputs & 1 else '⚪'} OUT1 (12V enable) · " f"terminal now: **0x{ctrl.terminal_state:02X}**") st.caption("The sequence walks terminal states using real captured frames for each " "state, so their counters and checksums stay genuine.") st.subheader("Live control") lc1, lc2 = st.columns([1, 1]) with lc1: names = list(states) current = next((n for n, v in states.items() if v == ctrl.terminal_state), names[0] if names else None) if names: chosen = st.selectbox("Terminal status (0x130)", names, index=names.index(current) if current in names else 0, key="ctrl_terminal") if states[chosen] != ctrl.terminal_state: ctrl.set_terminal(states[chosen]) else: st.warning("No captured 0x130 frames in the selected capture.") with lc2: speed = st.slider("Road speed (0x1A0) — km/h", 0.0, 120.0, float(ctrl.speed_kmh), step=1.0, key="ctrl_speed", help="Higher speed normally means less assist. Encoding is inferred, " "not verified - watch the EPS monitor below for a drop-out.") if abs(speed - ctrl.speed_kmh) > 0.01: ctrl.set_speed(speed) ctrl.send_speed = st.checkbox("Send road speed", value=ctrl.send_speed, key="ctrl_send_speed") rx_map, tx_count = ctrl.snapshot() st.caption(f"sent {tx_count} frames · receiving {sum(i.count for i in rx_map.values())} from " f"{len(rx_map)} IDs") st.subheader("EPS response") if rx_map: rows = [] now_t = time.monotonic() - ctrl._t0 if ctrl._t0 else 0 for aid, info in sorted(rx_map.items()): decoded = decode_eps_message(aid, info.last_data) rows.append({ "ID": f"0x{aid:03X}", "Count": info.count, "Age (s)": round(now_t - info.last_seen, 1), "Last data": info.last_data.hex(" ").upper(), "Moving bytes": " ".join(f"b{i}" for i in range(len(info.last_data)) if info.changed_mask & (1 << i)) or "-", "Decoded": ", ".join(f"{k}={v}" for k, v in decoded.items()) or "-", }) st.dataframe(pd.DataFrame(rows), width="stretch", hide_index=True) st.caption( "0x1FB is the EPS alive counter, 0x4B0 its status heartbeat, 0x5B0 appears at " "power-up. Fields ending in '?' are inferred from byte behaviour, not confirmed." ) else: st.info("Nothing from the EPS yet — run the startup sequence.") control_active = control.is_connected() active = live_active or replay_active or bench_active or control_active if active: time.sleep(REFRESH_INTERVAL) st.rerun()