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

138 lines
4.6 KiB
Python

"""Bidirectional CAN gateway between the car bus and the EPS bus.
car (CANdapter) <==> gateway <==> EPS (CAN-IO board USB bridge)
Two pump threads, one per direction, each consulting a FilterRules instance
for whether to forward a given arbitration ID. Starts fully transparent
(every frame relayed both ways) - block IDs at runtime via the rules objects
(see gateway_app.py for a GUI, or drive FilterRules directly from a script).
Every frame seen is counted (passed/blocked) and, if a FrameStore is given
for that side, fed to it for live decoding/monitoring - reuses
decoder/ptcan_decoder.py so this looks the same as the replay dashboard.
"""
from __future__ import annotations
import csv
import threading
import time
from collections import Counter
from pathlib import Path
from typing import Optional
from .rules import FilterRules
class GatewayLogger:
"""CSV log of every frame the gateway sees, whether relayed or not."""
def __init__(self, path: Path):
self._file = open(path, "w", newline="")
self._writer = csv.writer(self._file)
self._writer.writerow(["t", "direction", "arbitration_id", "is_extended", "dlc", "data_hex", "relayed"])
self._lock = threading.Lock()
self._t0 = time.time()
def write(self, direction: str, arbitration_id: int, is_extended: bool, data: bytes, relayed: bool) -> None:
with self._lock:
self._writer.writerow(
[
round(time.time() - self._t0, 4),
direction,
f"{arbitration_id:X}",
int(is_extended),
len(data),
data.hex().upper(),
int(relayed),
]
)
def close(self) -> None:
self._file.close()
class Stats:
def __init__(self):
self._lock = threading.Lock()
self.passed: Counter = Counter()
self.blocked: Counter = Counter()
def record(self, arbitration_id: int, passed: bool) -> None:
with self._lock:
(self.passed if passed else self.blocked)[arbitration_id] += 1
def snapshot(self):
with self._lock:
return dict(self.passed), dict(self.blocked)
def reset(self) -> None:
with self._lock:
self.passed.clear()
self.blocked.clear()
class Gateway:
"""Owns two already-open adapters and relays frames between them."""
def __init__(
self,
car_adapter,
eps_adapter,
car_to_eps_rules: Optional[FilterRules] = None,
eps_to_car_rules: Optional[FilterRules] = None,
car_store=None,
eps_store=None,
logger: Optional[GatewayLogger] = None,
):
self.car = car_adapter
self.eps = eps_adapter
self.car_to_eps_rules = car_to_eps_rules or FilterRules()
self.eps_to_car_rules = eps_to_car_rules or FilterRules()
self.car_store = car_store
self.eps_store = eps_store
self.logger = logger
self.stats = Stats()
self._stop = threading.Event()
self._threads: list[threading.Thread] = []
self._start_wall = time.time()
def start(self) -> None:
self._stop.clear()
self._threads = [
threading.Thread(target=self._pump, args=("car->eps",), daemon=True),
threading.Thread(target=self._pump, args=("eps->car",), daemon=True),
]
for t in self._threads:
t.start()
def stop(self) -> None:
self._stop.set()
for t in self._threads:
t.join(timeout=2)
if self.logger is not None:
self.logger.close()
def is_running(self) -> bool:
return any(t.is_alive() for t in self._threads)
def _pump(self, direction: str) -> None:
if direction == "car->eps":
src, dst, rules, src_store = self.car, self.eps, self.car_to_eps_rules, self.car_store
else:
src, dst, rules, src_store = self.eps, self.car, self.eps_to_car_rules, self.eps_store
while not self._stop.is_set():
frame = src.read_frame(timeout=0.5)
if frame is None:
continue
allowed = rules.allows(frame.arbitration_id)
if allowed:
dst.send_frame(frame.arbitration_id, frame.data, frame.is_extended)
self.stats.record(frame.arbitration_id, allowed)
if src_store is not None:
src_store.feed(frame.arbitration_id, frame.data, frame.recv_time - self._start_wall)
if self.logger is not None:
self.logger.write(direction, frame.arbitration_id, frame.is_extended, frame.data, allowed)