BMW_E8x_EPS/dashboard/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

248 lines
10 KiB
Python

"""Live/replay dashboard for the BMW E8x PT-CAN capture.
"/Users/luca/Projects/Stuurhuis CAN/.venv/bin/python" -m streamlit run dashboard/app.py
Two data sources, selectable in the sidebar:
- Live capture: reads the CANdapter in the background and feeds the dashboard
in real time. Optionally records the session to a new CSV.
- Replay file: steps through an existing capture_*.csv at an adjustable
speed, so you can scrub through what happened without the car running.
Decoding uses decoder/ptcan_decoder.py, which wraps the signal definitions from
files/decode_ptcan.py / files/PTCAN_protocol.md / files/bmw_e8x_ptcan.dbc.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from plotly.subplots import make_subplots
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from decoder.live_source import LiveCapture # noqa: E402
from decoder.ptcan_decoder import FrameStore, NAMES # noqa: E402
from decoder.replay_source import ReplayPlayer, load_capture # noqa: E402
CAPTURES_DIR = ROOT / "captures"
DEFAULT_PORT = "/dev/cu.usbserial-DNBJV4F5"
REFRESH_INTERVAL = 0.25 # seconds between dashboard reruns while a source is active
st.set_page_config(page_title="PT-CAN — BMW E8x", layout="wide")
@st.cache_resource
def get_store() -> FrameStore:
return FrameStore()
@st.cache_resource
def get_live() -> LiveCapture:
return LiveCapture()
@st.cache_resource
def get_player(csv_path: str) -> ReplayPlayer:
return ReplayPlayer(load_capture(Path(csv_path)))
store = get_store()
live = get_live()
# ---------------------------------------------------------------- sidebar --
st.sidebar.title("PT-CAN — BMW E8x")
mode = st.sidebar.radio("Source", ["Live capture", "Replay file"])
active = False # whether the dashboard should keep auto-rerunning
if mode == "Live capture":
port = st.sidebar.text_input("Port", DEFAULT_PORT)
bitrate = st.sidebar.selectbox("Bitrate", [500_000, 250_000, 125_000, 1_000_000], index=0)
record = st.sidebar.checkbox("Record session to CSV", value=True)
c1, c2 = st.sidebar.columns(2)
if c1.button("Start", disabled=live.is_running(), width="stretch"):
store.reset()
record_path = CAPTURES_DIR / time.strftime("capture_%Y%m%d_%H%M%S.csv") if record else None
live.start(port, bitrate, store, record_path=record_path)
if record_path:
st.sidebar.success(f"Recording to {record_path.name}")
if c2.button("Stop", disabled=not live.is_running(), width="stretch"):
live.stop()
if live.error:
st.sidebar.error(f"Adapter error: {live.error}")
elif live.is_running():
st.sidebar.success(f"Connected — {live.frame_count} frames")
else:
st.sidebar.caption("Stopped")
active = live.is_running()
else:
files = sorted(p.name for p in CAPTURES_DIR.glob("capture*.csv"))
if not files:
st.sidebar.warning("No capture_*.csv files found in captures/.")
st.stop()
chosen = st.sidebar.selectbox("Capture file", files)
player = get_player(str(CAPTURES_DIR / chosen))
if "replay_playing" not in st.session_state:
st.session_state.replay_playing = False
st.session_state.replay_wall = None
speed = st.sidebar.slider("Playback speed", 0.25, 8.0, 1.0, step=0.25)
c1, c2, c3 = st.sidebar.columns(3)
label = "Pause" if st.session_state.replay_playing else "Play"
if c1.button(label, width="stretch"):
st.session_state.replay_playing = not st.session_state.replay_playing
st.session_state.replay_wall = time.monotonic()
if c2.button("Restart", width="stretch"):
player.reset()
store.reset()
st.session_state.replay_playing = False
if c3.button("Seek", width="stretch"):
pass # handled via the slider below, this just forces a rerun
seek_to = st.sidebar.slider("Position (s)", 0.0, max(player.duration, 0.1), min(player.clock, player.duration), step=0.5)
if abs(seek_to - player.clock) > 0.5:
player.seek(seek_to, store)
if st.session_state.replay_playing:
now = time.monotonic()
dt = min(now - (st.session_state.replay_wall or now), 1.0)
st.session_state.replay_wall = now
player.advance(dt, speed, store)
if player.at_end():
st.session_state.replay_playing = False
st.sidebar.caption(f"{player.clock:.1f}s / {player.duration:.1f}s")
active = st.session_state.replay_playing
st.sidebar.divider()
window_s = st.sidebar.slider("Chart window (s)", 5, 120, 30)
if st.sidebar.button("Clear dashboard data"):
store.reset()
# --------------------------------------------------------------- helpers --
COLORS = {"rpm": "#FFA733", "steer": "#FF5C4D", "speed": "#56AEF2", "term": "#5FD6A0", "volt": "#B48CF2", "fuel": "#E8C87A"}
def signal_value(snapshot: dict, arbitration_id: int, name: str):
entry = snapshot["latest"].get(arbitration_id)
if not entry:
return None, ""
return entry["decoded"].get(name, (None, ""))
snap = store.snapshot()
# ------------------------------------------------------------------ KPIs --
st.subheader("Key signals")
k1, k2, k3, k4, k5, k6 = st.columns(6)
angle, _ = signal_value(snap, 0x0C4, "steering_angle")
rate, _ = signal_value(snap, 0x0C4, "steering_rate")
rpm, _ = signal_value(snap, 0x0AA, "engine_speed")
speed_kmh, _ = signal_value(snap, 0x1A0, "road_speed")
volt, _ = signal_value(snap, 0x0A9, "system_voltage")
fuel, _ = signal_value(snap, 0x1D0, "fuel_accum")
terminal, _ = signal_value(snap, 0x130, "terminal")
k1.metric("Steering angle (0x0C4)", f"{angle:.1f}°" if angle is not None else "—")
k2.metric("Steering rate (0x0C4)", f"{rate:.0f}°/s" if rate is not None else "—")
k3.metric("Engine speed (0x0AA)", f"{rpm:.0f} rpm" if rpm is not None else "—")
k4.metric("Road speed (0x1A0)", f"{speed_kmh:.1f} km/h" if speed_kmh is not None else "—")
k5.metric("System voltage (0x0A9)", f"{volt:.2f} V" if volt is not None else "—")
k6.metric("Terminal (0x130)", str(terminal) if terminal is not None else "—")
# --------------------------------------------------------------- charts --
st.subheader("Decoded channels over time")
fig = make_subplots(
rows=4,
cols=1,
shared_xaxes=True,
vertical_spacing=0.04,
specs=[[{}], [{"secondary_y": True}], [{}], [{}]],
subplot_titles=("Engine speed (rpm)", "Steering angle / rate", "System voltage (V)", "Road speed (km/h)"),
)
xs, ys = store.series(0x0AA, "engine_speed", window_s)
fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=COLORS["rpm"]), name="rpm"), row=1, col=1)
xs, ys = store.series(0x0C4, "steering_angle", window_s)
fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=COLORS["steer"]), name="angle (deg)"), row=2, col=1, secondary_y=False)
xs, ys = store.series(0x0C4, "steering_rate", window_s)
fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color="#7a4238"), name="rate (deg/s)"), row=2, col=1, secondary_y=True)
xs, ys = store.series(0x0A9, "system_voltage", window_s)
fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=COLORS["volt"]), name="voltage"), row=3, col=1)
xs, ys = store.series(0x1A0, "road_speed", window_s)
fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=COLORS["speed"]), name="speed"), row=4, col=1)
fig.update_layout(height=650, showlegend=False, margin=dict(l=40, r=20, t=30, b=20), template="plotly_dark")
st.plotly_chart(fig, width="stretch")
# --------------------------------------------------------------- monitor --
st.subheader("Bus monitor")
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}",
"Message": NAMES.get(aid, ""),
"Hz": round(store.hz(aid), 1),
"Count": snap["counts"].get(aid, 0),
"Data": entry["data"].hex(" ").upper(),
"Decoded": decoded,
}
)
if rows:
df = pd.DataFrame(rows).sort_values("ID")
st.dataframe(df, width="stretch", height=380, hide_index=True)
else:
st.info("No frames yet — start a live capture or play a replay file.")
# ------------------------------------------------------------- bit view --
st.subheader("Bit inspector")
st.caption("Pick an ID to see its raw bytes bit-by-bit. Highlighted bits changed since the previous frame — the fastest way to spot an unidentified signal (candidates from the protocol notes: 0x1B6 and 0x335).")
ids_present = sorted(snap["latest"])
if ids_present:
default_idx = ids_present.index(0x1B6) if 0x1B6 in ids_present else 0
picked = st.selectbox("Arbitration ID", ids_present, index=default_idx, format_func=lambda i: f"0x{i:03X} {NAMES.get(i, '')}")
entry = snap["latest"][picked]
data, changed = entry["data"], entry["changed"]
html = ["<div style='font-family:monospace;font-size:13px'>"]
html.append("<table style='border-collapse:collapse'><tr><th style='padding:2px 8px'>byte</th>" + "".join(f"<th style='padding:2px 6px'>{b}</th>" for b in range(7, -1, -1)) + "<th style='padding:2px 8px'>hex</th></tr>")
for i, byte in enumerate(data):
cells = []
chg_byte = changed[i] if changed else 0
for bit in range(7, -1, -1):
on = (byte >> bit) & 1
toggled = (chg_byte >> bit) & 1
bg = "#FF5C4D" if toggled else ("#3a4a58" if on else "#151D25")
fg = "#0E1419" if on or toggled else "#485A67"
cells.append(f"<td style='padding:3px 6px;text-align:center;background:{bg};color:{fg};border:1px solid #28343E'>{on}</td>")
html.append(f"<tr><td style='padding:2px 8px;color:#6E8290'>{i}</td>{''.join(cells)}<td style='padding:2px 8px;color:#C9D7E0'>0x{byte:02X}</td></tr>")
html.append("</table></div>")
st.markdown("".join(html), unsafe_allow_html=True)
if entry["decoded"]:
st.write({name: f"{val:.3f}{unit}" if isinstance(val, float) else f"{val}{unit}" for name, (val, unit) in entry["decoded"].items()})
else:
st.info("No frames yet.")
# ----------------------------------------------------------- keep alive --
if active:
time.sleep(REFRESH_INTERVAL)
st.rerun()