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
144 lines
5.3 KiB
Python
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())
|