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

283 lines
11 KiB
Python

"""Drive the EPS with a minimal, hand-controlled message set.
Where eps_bench.py replays whole captured sets to find out what's needed,
this assumes the answer is already known - CAS terminal status (0x130) to
bring the unit up, DSC road speed (0x1A0) to set how much assist it gives -
and gives you direct control of those two signals.
Encoding strategy, and why it differs per message:
0x130 terminal: the capture contains real frames for each terminal state
(off / R / 15 / running / cranking), so a state change just switches
which captured sequence is cycling. Counters and checksums stay exactly
as the car produced them - nothing to solve.
0x1A0 road speed: the reference capture is stationary throughout, so
there is no ground truth for a moving-speed frame. What we do know is
that the best-fitting checksum over that data covers bytes 2..6 (87.5%
of frames, tools/solve_checksum.py) - which excludes the speed field in
bytes 0..1. So patching speed into a captured frame should leave its
checksum valid. That is an inference, not a verified fact: watch the
EPS response monitor when you move the slider, and treat a drop-out as
the encoding being rejected.
"""
from __future__ import annotations
import csv
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
CAN_ID_TERMINAL = 0x130
CAN_ID_ROAD_SPEED = 0x1A0
TERMINAL_STATES = {
"off": 0x00,
"terminal R": 0x40,
"ignition (KL15)": 0x41,
"engine running": 0x45,
"cranking": 0x55,
}
# Periods the car uses for these two messages (median from the captures).
PERIOD_TERMINAL = 0.1
PERIOD_ROAD_SPEED = 0.02
SPEED_SCALE = 0.1 # km/h per bit, per files/PTCAN_protocol.md
STANDSTILL_FLAG = 0x80 # b1 bit7, set while the car reports stationary
def load_state_sequences(path: Path, arbitration_id: int, state_byte: int = 0) -> dict[int, list[bytes]]:
"""Group a capture's frames for one ID by the value of one byte.
Used to pull genuine per-terminal-state 0x130 frames out of a recording,
so switching state means switching which real sequence we cycle rather
than synthesising a payload whose checksum we can't verify.
"""
groups: dict[int, list[bytes]] = {}
with open(path, newline="") as f:
for row in csv.DictReader(f):
if int(row["arbitration_id"], 16) != arbitration_id:
continue
data = bytes.fromhex(row["data_hex"])
if len(data) <= state_byte:
continue
groups.setdefault(data[state_byte], []).append(data)
return groups
def load_frames(path: Path, arbitration_id: int, limit: int = 400) -> list[bytes]:
out = []
with open(path, newline="") as f:
for row in csv.DictReader(f):
if int(row["arbitration_id"], 16) == arbitration_id:
out.append(bytes.fromhex(row["data_hex"]))
if len(out) >= limit:
break
return out
def encode_road_speed(template: bytes, kmh: float) -> bytes:
"""Patch a road-speed value into a captured 0x1A0 frame.
Only bytes 0-1 are touched; see the module docstring for why that should
leave the frame's checksum intact.
"""
raw = max(0, min(0x0FFF, int(round(kmh / SPEED_SCALE))))
out = bytearray(template)
out[0] = raw & 0xFF
flags = out[1] & 0xF0
if kmh > 0:
flags &= ~STANDSTILL_FLAG & 0xFF
else:
flags |= STANDSTILL_FLAG
out[1] = flags | ((raw >> 8) & 0x0F)
return bytes(out)
def decode_road_speed(data: bytes) -> float:
return (data[0] | ((data[1] & 0x0F) << 8)) * SPEED_SCALE
def decode_eps_message(arbitration_id: int, data: bytes) -> dict:
"""Best-effort decode of the three messages the EPS transmits.
Derived from byte-frequency analysis of bench captures (see
eps-comms/findings.md). Fields marked "?" are inferred from structure
rather than confirmed against a known-good reference, so treat them as
leads rather than facts.
"""
out: dict[str, str] = {}
if not data:
return out
if arbitration_id == 0x1FB and len(data) >= 2:
# b0 runs F0..FE: high nibble is a constant marker, low nibble the
# 0..14 alive counter (skipping 15) used everywhere on this bus.
out["alive counter"] = str(data[0] & 0x0F)
out["marker"] = f"0x{data[0] >> 4:X}"
if data[0] >> 4 != 0xF:
out["note"] = "unexpected marker - normally 0xF"
elif arbitration_id == 0x4B0 and len(data) >= 4:
out["counter?"] = str(data[1] & 0x0F)
flags = data[1] >> 4
out["flags?"] = f"0x{flags:X}"
out["checksum?"] = f"0x{data[0]:02X}"
if data[3] != 0xFF:
out["b3"] = f"0x{data[3]:02X} (normally FF)"
elif arbitration_id == 0x5B0 and len(data) >= 4:
# Two payloads seen: 01 03 80 FF... right at power-up, then
# 40 81 01 15 FF... once it settles - looks like an init/ready pair.
out["state?"] = "starting" if data[0] == 0x01 else "ready" if data[0] == 0x40 else f"0x{data[0]:02X}"
out["raw"] = data[:4].hex(" ").upper()
return out
@dataclass
class EpsRx:
count: int = 0
last_seen: float = 0.0
last_data: bytes = b""
changed_mask: int = 0
history: list = field(default_factory=list)
class EpsController:
"""Transmits just the terminal + road-speed messages, on their own thread."""
def __init__(self, adapter, capture: Path, log_path: Optional[Path] = None):
self.adapter = adapter
self.terminal_sequences = load_state_sequences(capture, CAN_ID_TERMINAL)
self.speed_templates = load_frames(capture, CAN_ID_ROAD_SPEED)
self.terminal_state = 0x00
self.speed_kmh = 0.0
self.send_speed = True
self.rx: dict[int, EpsRx] = {}
self.tx_count = 0
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self._t0 = 0.0
self._term_i = 0
self._speed_i = 0
self._log_file = None
self._log_writer = None
if log_path is not None:
self._log_file = open(log_path, "w", newline="")
self._log_writer = csv.writer(self._log_file)
self._log_writer.writerow(["t", "wall", "dir", "arbitration_id", "dlc", "data_hex", "note"])
def available_states(self) -> dict[str, int]:
"""Terminal states we actually hold captured frames for."""
return {name: v for name, v in TERMINAL_STATES.items() if v in self.terminal_sequences}
def set_terminal(self, value: int) -> None:
with self._lock:
self.terminal_state = value
self._term_i = 0
self._log_note(f"terminal -> 0x{value:02X}")
def set_speed(self, kmh: float) -> None:
with self._lock:
self.speed_kmh = kmh
self._log_note(f"speed -> {kmh:.1f} km/h")
def _log_note(self, note: str) -> None:
if self._log_writer is not None:
self._log_writer.writerow([round(time.monotonic() - self._t0, 4), round(time.time(), 4),
"note", "", "", "", note])
def is_running(self) -> bool:
return bool(self._thread and self._thread.is_alive())
def start(self) -> None:
if self.is_running():
return
self._stop.clear()
self._t0 = time.monotonic()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=2)
if self._log_file is not None:
self._log_file.close()
self._log_file = None
self._log_writer = None
def snapshot(self):
with self._lock:
return dict(self.rx), self.tx_count
def _run(self) -> None:
next_term = next_speed = time.monotonic()
while not self._stop.is_set():
now = time.monotonic()
batch = []
if now >= next_term:
next_term = now + PERIOD_TERMINAL
with self._lock:
seq = self.terminal_sequences.get(self.terminal_state)
if seq:
data = seq[self._term_i % len(seq)]
self._term_i += 1
batch.append((CAN_ID_TERMINAL, data, False))
if now >= next_speed:
next_speed = now + PERIOD_ROAD_SPEED
with self._lock:
if self.send_speed and self.speed_templates:
tmpl = self.speed_templates[self._speed_i % len(self.speed_templates)]
self._speed_i += 1
batch.append((CAN_ID_ROAD_SPEED, encode_road_speed(tmpl, self.speed_kmh), False))
if batch:
self.adapter.send_frames(batch)
with self._lock:
self.tx_count += len(batch)
if self._log_writer is not None:
wall = time.time()
for aid, data, _ in batch:
self._log_writer.writerow([round(now - self._t0, 4), round(wall, 4), "tx",
f"{aid:X}", len(data), data.hex().upper(), ""])
deadline = min(next_term, next_speed)
while time.monotonic() < deadline:
frame = self.adapter.read_frame(timeout=0.001)
if frame is None:
break
self._record_rx(frame)
slack = deadline - time.monotonic()
if slack > 0:
time.sleep(min(slack, 0.005))
def _record_rx(self, frame) -> None:
t = time.monotonic() - self._t0
with self._lock:
info = self.rx.get(frame.arbitration_id)
if info is None:
info = EpsRx()
self.rx[frame.arbitration_id] = info
if info.last_data and len(info.last_data) == len(frame.data):
for i, (a, b) in enumerate(zip(info.last_data, frame.data)):
if a != b:
info.changed_mask |= 1 << i
info.count += 1
info.last_seen = t
info.last_data = frame.data
info.history.append((t, frame.data))
if len(info.history) > 200:
del info.history[:100]
if self._log_writer is not None:
self._log_writer.writerow([round(t, 4), round(time.time(), 4), "rx",
f"{frame.arbitration_id:X}", len(frame.data),
frame.data.hex().upper(), ""])