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

134 lines
5.5 KiB
Python

"""Replay a captured CSV onto a live CAN bus (e.g. the EPS bus via the CAN-IO
board's USB bridge) - see whether the EPS will power up/behave from a
recording alone, with no car attached. A stepping stone before writing a
synthetic frame generator: get a known-good replay working and filtered down
to the minimum frame set first, then synthesize from there.
Usage:
python gateway/replay_to_bus.py captures/capture_Start-Stop.csv \\
--port /dev/cu.usbmodemXXXX --bitrate 500000 \\
[--speed 1.0] [--loop] [--block 0x1D6 0x380] [--rules myrules.json] \\
[--dio-log captures/dio_20260829.csv] [--dry-run]
--dry-run replays the timing/filtering logic and prints a summary without
opening a serial port - useful for testing with no adapter attached.
--rules loads a plain FilterRules JSON (as saved by FilterRules.save(), i.e.
{"default_allow": ..., "overrides": {...}}) - not the combined car_to_eps/
eps_to_car file gateway_app.py saves.
--dio-log replays the CAN-IO board's own digital outputs (e.g. the 12V
signal repeat) alongside the CAN traffic, from a timeline produced by
tools/extract_dio_timeline.py. Without this, a replay only sends CAN
frames - if the EPS also needs that physical signal to enable, replaying
CAN alone won't reproduce what it saw live.
"""
import argparse
import csv
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from adapters.slcan_adapter import SlcanAdapter # noqa: E402
from decoder.replay_source import load_capture # noqa: E402
from gateway.rules import FilterRules # noqa: E402
CAN_ID_COMMAND = 0x101
CMD_SET_ALL = 0x02
def load_dio_events(path: Path) -> list[tuple[float, int]]:
"""Read a (t, inputs, outputs) timeline, keep only the times outputs changed."""
events = []
prev = None
with open(path, newline="") as f:
for row in csv.DictReader(f):
outputs = int(row["outputs"])
if outputs != prev:
events.append((float(row["t"]), outputs))
prev = outputs
return events
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("csv", help="Capture file to replay (timestamp_ms, arbitration_id, is_extended, dlc, data_hex)")
parser.add_argument("--port", help="Serial port of the target adapter (e.g. CAN-IO board bridge)")
parser.add_argument("--bitrate", type=int, default=500_000)
parser.add_argument("--speed", type=float, default=1.0, help="Playback speed multiplier")
parser.add_argument("--loop", action="store_true", help="Replay repeatedly until Ctrl+C")
parser.add_argument("--block", nargs="*", default=[], help="Arbitration IDs (hex) to withhold from the bus")
parser.add_argument("--rules", default=None, help="Load a FilterRules JSON file instead of/in addition to --block")
parser.add_argument("--dio-log", default=None, help="Replay CAN-IO board output changes from tools/extract_dio_timeline.py's output")
parser.add_argument("--dry-run", action="store_true", help="Print a summary instead of sending - no adapter needed")
args = parser.parse_args()
frames = load_capture(Path(args.csv))
if not frames:
print("No frames in capture", file=sys.stderr)
return 1
dio_events = load_dio_events(Path(args.dio_log)) if args.dio_log else []
rules = FilterRules()
if args.rules:
rules.load(Path(args.rules))
for tok in args.block:
rules.set_allow(int(tok, 16), False)
if args.dry_run:
adapter = None
else:
if not args.port:
print("--port is required unless --dry-run", file=sys.stderr)
return 1
adapter = SlcanAdapter(args.port, args.bitrate)
# Merge CAN frames and DIO output-change events into one time-ordered timeline.
timeline = [(t, "frame", (aid, data)) for t, aid, data in frames]
timeline += [(t, "dio", outputs) for t, outputs in dio_events]
timeline.sort(key=lambda e: e[0])
print(f"Replaying {len(frames)} frames + {len(dio_events)} DIO events ({frames[-1][0]:.1f}s) at {args.speed}x"
f"{' [dry run]' if args.dry_run else f' -> {args.port}'}. Ctrl+C to stop.\n")
sent = skipped = dio_sent = 0
try:
while True:
t0 = time.monotonic()
for t, kind, payload in timeline:
if kind == "frame":
aid, data = payload
allowed = rules.allows(aid)
if allowed:
if adapter is not None:
adapter.send_frame(aid, data)
sent += 1
else:
skipped += 1
else: # "dio": force OUT1/OUT2 to match the recorded state
if adapter is not None:
adapter.send_frame(CAN_ID_COMMAND, bytes([CMD_SET_ALL, 0x03, payload]))
dio_sent += 1
target = t0 + t / args.speed
delay = target - time.monotonic()
if delay > 0:
time.sleep(delay)
print(f"pass complete: {sent} sent, {skipped} skipped, {dio_sent} DIO changes ({len(frames)} frames total)")
if not args.loop:
break
sent = skipped = dio_sent = 0
except KeyboardInterrupt:
print("\nStopped.")
finally:
if adapter is not None:
adapter.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())