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

152 lines
5.8 KiB
Python

"""Standalone digital-IO monitor/logger for the CAN-IO board.
Deliberately separate from the CAN path: it talks to the board over the
USB '!'/'@' channel (see can-io/firmware/src/usb_bridge.h), which keeps
working when the CAN bus has no other powered node - the situation you're
in before the EPS gets its 12V enable, and exactly when the CAN status
frame goes missing because the shared TX queue backs up behind
100ms-timing-out transmits.
Logs each direction as its own signal:
IN1 = the car's 12V ignition/enable signal arriving at this board
OUT1 = the 12V signal this board repeats onward to the EPS
CSV columns: t, uptime_s, in1..in4, out1, out2, event
event is "change" for a real transition, "sample" for periodic polls.
"""
from __future__ import annotations
import csv
import threading
import time
from pathlib import Path
from typing import Optional
class DioLogger:
"""Appends IO states to a CSV, noting which rows were real transitions."""
def __init__(self, path: Path):
self._file = open(path, "w", newline="")
self._writer = csv.writer(self._file)
self._writer.writerow(
["t", "wall", "uptime_s", "in1", "in2", "in3", "in4", "out1", "out2", "event"]
)
self._lock = threading.Lock()
self._t0 = time.time()
self._prev: Optional[tuple[int, int]] = None
def record(self, inputs: int, outputs: int, uptime_s: int) -> bool:
"""Returns True if this was a change (not just a periodic sample)."""
with self._lock:
changed = self._prev is not None and (inputs, outputs) != self._prev
first = self._prev is None
self._prev = (inputs, outputs)
now = time.time()
self._writer.writerow(
[
round(now - self._t0, 4),
round(now, 4),
uptime_s,
*[(inputs >> i) & 1 for i in range(4)],
outputs & 1,
(outputs >> 1) & 1,
"change" if changed else ("boot" if first else "sample"),
]
)
self._file.flush() # keep the log usable while a session is still running
return changed
def close(self) -> None:
self._file.close()
class DioMonitor:
"""Polls the board's IO channel on its own thread, independent of any
CAN relay/replay activity, optionally logging every update.
`owns_reads` must be False whenever something else (the Gateway's relay
thread, the replay loop) is already calling read_frame() on the same
adapter: two threads reading one serial port interleave partial lines
and corrupt both streams. In that case this only writes '@G' requests
and samples the io_state the other reader populates. Set it True when
this monitor is the sole user of the port.
"""
def __init__(self, adapter, logger: Optional[DioLogger] = None,
poll_interval: float = 0.25, owns_reads: bool = False):
self.adapter = adapter
self.logger = logger
self.poll_interval = poll_interval
self.owns_reads = owns_reads
self.inputs = 0
self.outputs = 0
self.uptime_s = 0
self.last_update: Optional[float] = None
self.updates = 0
self.changes = 0
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
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._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.logger is not None:
self.logger.close()
def set_output(self, index: int, on: bool) -> None:
self.adapter.set_output(index, on)
def set_output_verified(self, index: int, on: bool, attempts: int = 4, timeout: float = 0.6) -> bool:
"""Command an output and confirm the board actually reports it, retrying.
A single '@S' write can be lost (USB CDC hiccup, or the line arriving
while the board is mid-parse), which shows up as an output that
occasionally just doesn't switch. Re-asserting until the reported
state matches makes that self-healing - the command is idempotent,
so a duplicate is harmless.
"""
want = 1 if on else 0
for _ in range(attempts):
self.adapter.set_output(index, on)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
self.adapter.request_io()
time.sleep(0.08)
state = self.adapter.io_state
if state is not None and ((state.outputs >> index) & 1) == want:
self.outputs = state.outputs
return True
return False
def _run(self) -> None:
while not self._stop.is_set():
self.adapter.request_io()
if self.owns_reads:
deadline = time.monotonic() + self.poll_interval
while time.monotonic() < deadline:
self.adapter.read_frame(timeout=0.05)
else:
time.sleep(self.poll_interval)
state = self.adapter.io_state
if state is None:
continue
self.inputs = state.inputs
self.outputs = state.outputs
self.uptime_s = state.uptime_s
self.last_update = state.recv_time
self.updates += 1
if self.logger is not None and self.logger.record(state.inputs, state.outputs, state.uptime_s):
self.changes += 1