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
1004 lines
43 KiB
Python
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()
|