BMW_E8x_EPS/tools/analyze_bench_session.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

144 lines
5.3 KiB
Python

"""Analyse an EPS bench session: when did the unit come online, and did it stay?
Reads captures/eps_bench_*.csv (t, dir, arbitration_id, dlc, data_hex - both
directions) plus the matching dio_bench_*.csv, and reports:
- when the 12V enable was applied vs. when the EPS first answered
- which EPS IDs appeared, and in what order
- dropout detection: gaps where the EPS stopped transmitting, which is
what "it becomes intermittent" looks like in the data
- transmit health: whether our own frames kept to their intended period,
since a bench that stalls looks identical to an EPS that drops out
Usage:
python tools/analyze_bench_session.py captures/eps_bench_20260829_183208.csv
"""
import argparse
import csv
from collections import defaultdict
from pathlib import Path
EPS_IDS = {0x1FB, 0x4B0, 0x5B0}
def load(path: Path):
tx, rx = [], []
wall0 = None
with open(path, newline="") as f:
for row in csv.DictReader(f):
wall = float(row["wall"]) if row.get("wall") else None
if wall is not None and wall0 is None:
wall0 = wall
rec = (float(row["t"]), wall, int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"]))
(tx if row["dir"] == "tx" else rx).append(rec)
return tx, rx, wall0
def load_dio(path: Path, wall0):
"""Returns transitions on the bench log's time base.
The two logs start their relative clocks at different moments (the IO
monitor at Connect, the bench at Start TX), so a shared wall clock is
the only honest way to line them up. Older logs without a 'wall' column
can't be aligned - flagged rather than silently mis-reported.
"""
events, aligned = [], False
if not path.exists():
return events, aligned
prev = None
with open(path, newline="") as f:
for row in csv.DictReader(f):
outs = int(row["out1"]) | (int(row["out2"]) << 1)
ins = sum(int(row[f"in{i+1}"]) << i for i in range(4))
if row.get("wall") and wall0 is not None:
t = float(row["wall"]) - wall0
aligned = True
else:
t = float(row["t"])
if (ins, outs) != prev:
events.append((t, ins, outs))
prev = (ins, outs)
return events, aligned
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("bench_log")
ap.add_argument("--gap", type=float, default=1.0, help="Silence longer than this counts as a dropout")
a = ap.parse_args()
path = Path(a.bench_log)
tx, rx, wall0 = load(path)
dio, aligned = load_dio(path.parent / path.name.replace("eps_bench_", "dio_bench_"), wall0)
if not tx and not rx:
print("Empty log.")
return 1
duration = max((tx[-1][0] if tx else 0), (rx[-1][0] if rx else 0))
print(f"session {duration:.1f}s · {len(tx)} frames sent · {len(rx)} received\n")
if dio:
note = "" if aligned else " (NOT time-aligned - log predates the shared wall clock)"
print(f"Digital IO transitions (OUT1 = 12V enable to EPS){note}:")
for t, ins, outs in dio:
print(f" t={t:7.2f}s IN1={ins & 1} OUT1={outs & 1}")
print()
eps_rx = [(t, aid, d) for t, _, aid, d in rx if aid in EPS_IDS]
if not eps_rx:
print("The EPS never transmitted in this session.")
return 0
enable_t = next((t for t, _, outs in dio if outs & 1), None)
first = eps_rx[0][0]
print(f"EPS first answered at t={first:.2f}s")
if enable_t is not None and aligned:
print(f" 12V enable applied at t={enable_t:.2f}s -> {first - enable_t:.2f}s to come online")
elif enable_t is not None:
print(f" 12V enable at t={enable_t:.2f}s on its own clock - not comparable, see note above")
print()
print("EPS IDs, in order of first appearance:")
seen = {}
for t, aid, _ in eps_rx:
seen.setdefault(aid, t)
for aid, t in sorted(seen.items(), key=lambda kv: kv[1]):
n = sum(1 for _, a, _ in eps_rx if a == aid)
print(f" t={t:7.2f}s 0x{aid:03X} ({n} frames)")
print()
# Dropouts: any window where nothing came back from the EPS at all.
print(f"EPS dropouts (silence > {a.gap}s after it was already online):")
gaps, prev = [], first
for t, _, _ in eps_rx[1:]:
if t - prev > a.gap:
gaps.append((prev, t))
prev = t
if duration - prev > a.gap:
gaps.append((prev, duration))
if gaps:
for s, e in gaps:
print(f" t={s:7.2f}s -> {e:7.2f}s ({e - s:.2f}s silent)")
else:
print(" none - it stayed online for the whole session")
print()
# Our own transmit health over the same windows: if we stalled, that's
# our bug, not the EPS dropping out.
print("Our transmit rate per 5s window (spot bench stalls vs. EPS faults):")
buckets = defaultdict(int)
for t, _, _, _ in tx:
buckets[int(t // 5)] += 1
rx_buckets = defaultdict(int)
for t, aid, _ in eps_rx:
rx_buckets[int(t // 5)] += 1
for b in sorted(buckets):
bar = "#" * min(40, buckets[b] // 25)
print(f" t={b*5:4d}-{b*5+5:<4d}s tx={buckets[b]:6d} eps_rx={rx_buckets.get(b, 0):5d} {bar}")
return 0
if __name__ == "__main__":
raise SystemExit(main())