BMW_E8x_EPS/gateway/gateway_app.py
Luca c32c4645b5 Reverse-engineer BMW E8x EPS for standalone operation
Tooling and findings for running an E8x/E9x electric power steering unit
outside its donor car, e.g. in an EV conversion.

Headline result: the EPS needs only two CAN messages plus a 12V enable
wire, not the 69-message set the car puts on the bus:
  0x130 CAS terminal status (100ms) brings the unit up
  0x1A0 DSC road speed (20ms) sets the assist level
Total required rate is 60 frames/s. Protocol write-up, including what is
proven vs. inferred and the open questions, is in eps-comms/.

Contents:
  adapters/   CANdapter (its SLCAN dialect differs) and generic SLCAN
  gateway/    car<->EPS relay, replay, message bench, EPS controller,
              4-tab Streamlit UI
  decoder/    PT-CAN frame decoding and live/replay sources
  can-io/     XIAO ESP32-S3 firmware: CAN IO board + USB-CAN bridge with
              a CAN-independent digital IO channel
  tools/      capture, bitrate scan, startup-order and session analysis,
              checksum solver
  captures/   reference working session + the replay set eps_control reads
2026-08-29 19:34:43 +02:00

1004 lines
43 KiB
Python

"""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()