BMW_E8x_EPS/gateway/replay_runner.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

154 lines
5.7 KiB
Python

"""Hardware-aware replay engine, shared by gateway_app.py's Replay tab.
Unlike decoder/replay_source.py's ReplayPlayer (which only feeds a
FrameStore for visualization), this actually sends allowed frames to a real
adapter, and can also replay the CAN-IO board's own digital output changes
(see tools/extract_dio_timeline.py) - both driven by the same wall-clock
advance() call so it fits Streamlit's rerun-loop pattern with no background
thread needed.
"""
from __future__ import annotations
import csv
from pathlib import Path
from typing import Optional
CAN_ID_COMMAND = 0x101
CAN_ID_STATUS = 0x100
CMD_SET_ALL = 0x02
def load_capture(path: Path) -> list[tuple[float, int, bytes]]:
"""Same format/behaviour as decoder.replay_source.load_capture."""
rows = []
with open(path, newline="") as f:
for row in csv.DictReader(f):
rows.append((int(row["timestamp_ms"]), int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"])))
rows.sort(key=lambda r: r[0])
if not rows:
return []
t0 = rows[0][0]
return [((ms - t0) / 1000, aid, data) for ms, aid, data in rows]
def load_dio_events(path: Path) -> list[tuple[float, int]]:
"""Read a DIO timeline, keeping only the times the outputs changed.
Accepts both layouts we produce:
- gateway/dio_monitor.py's DioLogger: t, uptime_s, in1..in4, out1, out2, event
- the older tools/extract_dio_timeline.py: t, inputs, outputs
"""
events = []
prev = None
with open(path, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
if "out1" in row:
outputs = (int(row["out1"]) & 1) | ((int(row["out2"]) & 1) << 1)
else:
outputs = int(row["outputs"])
if outputs != prev:
events.append((float(row["t"]), outputs))
prev = outputs
return events
def gateway_log_to_capture(gateway_log: Path, direction: str = "car->eps", include_blocked: bool = False) -> list[tuple[float, int, bytes]]:
"""Same idea as tools/gateway_log_to_capture.py, but in-process."""
rows = []
with open(gateway_log, newline="") as f:
for row in csv.DictReader(f):
if row["direction"] != direction:
continue
if not include_blocked and row["relayed"] != "1":
continue
rows.append((float(row["t"]), int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"])))
rows.sort(key=lambda r: r[0])
if not rows:
return []
t0 = rows[0][0]
return [(t - t0, aid, data) for t, aid, data in rows]
def extract_dio_events(gateway_log: Path) -> list[tuple[float, int]]:
"""Same idea as tools/extract_dio_timeline.py, but in-process."""
samples = []
with open(gateway_log, newline="") as f:
for row in csv.DictReader(f):
if int(row["arbitration_id"], 16) != CAN_ID_STATUS:
continue
data = bytes.fromhex(row["data_hex"])
if len(data) < 2:
continue
samples.append((float(row["t"]), data[1]))
events = []
prev = None
for t, outputs in samples:
if outputs != prev:
events.append((t, outputs))
prev = outputs
return events
class HardwareReplayPlayer:
"""Steps through a merged (frames + DIO events) timeline, sending to a
real adapter as it goes, driven by repeated advance() calls (Streamlit
rerun loop) rather than a background thread."""
def __init__(self, frames: list[tuple[float, int, bytes]], dio_events: Optional[list[tuple[float, int]]] = None):
self.frames = frames
timeline = [(t, "frame", (aid, data)) for t, aid, data in frames]
timeline += [(t, "dio", outputs) for t, outputs in (dio_events or [])]
timeline.sort(key=lambda e: e[0])
self.timeline = timeline
self.duration = frames[-1][0] if frames else 0.0
self.index = 0
self.clock = 0.0
self.sent = 0
self.skipped = 0
self.dio_sent = 0
def reset(self) -> None:
self.index = 0
self.clock = 0.0
self.sent = self.skipped = self.dio_sent = 0
def at_end(self) -> bool:
return self.index >= len(self.timeline)
def advance(self, dt_wall: float, speed: float, store, rules, adapter=None) -> None:
self.clock += dt_wall * speed
while self.index < len(self.timeline) and self.timeline[self.index][0] <= self.clock:
t, kind, payload = self.timeline[self.index]
if kind == "frame":
aid, data = payload
allowed = rules.allows(aid)
if allowed:
if adapter is not None:
adapter.send_frame(aid, data)
self.sent += 1
store.feed(aid, data, t)
else:
self.skipped += 1
else: # "dio"
if adapter is not None:
# Direct USB IO channel, not a CAN command frame: works even
# when the EPS bus has no other powered node to ACK traffic.
for idx in range(2):
adapter.set_output(idx, bool(payload & (1 << idx)))
self.dio_sent += 1
self.index += 1
def seek(self, t: float, store) -> None:
store.reset()
self.reset()
self.clock = t
while self.index < len(self.timeline) and self.timeline[self.index][0] <= self.clock:
frame_t, kind, payload = self.timeline[self.index]
if kind == "frame":
aid, data = payload
store.feed(aid, data, frame_t)
self.sent += 1
else:
self.dio_sent += 1
self.index += 1