BMW_E8x_EPS/adapters/candapter.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

146 lines
5.1 KiB
Python

"""Minimal driver for the CANdapter (Ewert Energy Systems / candapter.com).
The CANdapter speaks a Lawicel-like ASCII protocol over its FTDI virtual
serial port, but diverges from real SLCAN in ways that break python-can's
stock slcan backend:
- Standard 11-bit frames are reported as ``Tiiildd..`` and extended 29-bit
frames as ``Xiiiiiiiildd..`` (real SLCAN uses lowercase ``t``/``T``
respectively) so the kernel slcan driver and python-can can't parse the
extended-ID frames at all.
- The optional millisecond timestamp (enabled with ``A1``) is appended
after the data bytes but is NOT counted in the DLC field, so it has to
be recovered from whatever is left over on the line.
"""
from __future__ import annotations
import dataclasses
import time
from typing import Optional
import serial
# Lawicel-style bitrate codes accepted by the 'S' command.
BITRATE_CODES = {
10_000: 0,
20_000: 1,
50_000: 2,
100_000: 3,
125_000: 4,
250_000: 5,
500_000: 6,
800_000: 7,
1_000_000: 8,
}
ACK = 0x06
BELL = 0x07
@dataclasses.dataclass
class CanFrame:
arbitration_id: int
is_extended: bool
data: bytes
timestamp_ms: Optional[int]
recv_time: float
class Candapter:
def __init__(self, port: str, bitrate: int, control_baud: int = 115200, timestamps: bool = False):
if bitrate not in BITRATE_CODES:
raise ValueError(f"Unsupported bitrate {bitrate}; choose one of {sorted(BITRATE_CODES)}")
self._ser = serial.Serial(port, control_baud, timeout=0.1)
self._buf = bytearray()
self._send_command("C") # close channel in case it was left open
self._send_command(f"S{BITRATE_CODES[bitrate]}")
self._send_command("A1" if timestamps else "A0")
self._send_command("O")
def _send_command(self, cmd: str) -> None:
self._ser.reset_input_buffer()
self._ser.write((cmd + "\r").encode("ascii"))
time.sleep(0.05)
self._ser.read(self._ser.in_waiting or 1) # drain ACK/BELL/text reply
def close(self) -> None:
try:
self._send_command("C")
finally:
self._ser.close()
def __enter__(self) -> "Candapter":
return self
def __exit__(self, *exc) -> None:
self.close()
def read_frame(self, timeout: Optional[float] = None) -> Optional[CanFrame]:
"""Read one frame, or None if nothing complete arrived within timeout."""
deadline = None if timeout is None else time.monotonic() + timeout
while True:
# Check every iteration, not just while waiting for bytes - a flood of
# malformed lines (e.g. wrong bitrate) would otherwise spin forever.
if deadline is not None and time.monotonic() >= deadline:
return None
nl = self._buf.find(b"\r")
if nl == -1:
chunk = self._ser.read(64)
if chunk:
self._buf.extend(chunk)
continue
line = bytes(self._buf[:nl])
del self._buf[: nl + 1]
if not line or chr(line[0]) not in ("t", "T", "x", "X"):
continue # ignore stray ACK/BELL bytes mixed into the stream
frame = self._parse_frame(line)
if frame is not None:
return frame
@staticmethod
def _parse_frame(line: bytes) -> Optional[CanFrame]:
text = line.decode("ascii", errors="replace")
prefix = text[0]
try:
# Observed on this adapter: lowercase 't' = standard 11-bit frame (real
# SLCAN convention). Extended frames haven't been observed yet, so both
# the documented 'X'/'x' and the real-SLCAN 'T' are accepted as 29-bit.
if prefix == "t":
id_hex, rest = text[1:4], text[4:]
is_extended = False
else: # "T", "x", or "X"
id_hex, rest = text[1:9], text[9:]
is_extended = True
dlc = int(rest[0], 16)
data_hex = rest[1 : 1 + dlc * 2]
data = bytes.fromhex(data_hex)
remainder = rest[1 + dlc * 2 :] # leftover = ms timestamp, if 'A1' was set
timestamp_ms = int(remainder, 16) if len(remainder) == 4 else None
return CanFrame(
arbitration_id=int(id_hex, 16),
is_extended=is_extended,
data=data,
timestamp_ms=timestamp_ms,
recv_time=time.time(),
)
except (ValueError, IndexError):
return None
def send_frame(self, arbitration_id: int, data: bytes, is_extended: bool = False) -> None:
"""Transmit a frame onto the bus.
Standard frames use the observed 't' RX convention. Extended frames use
'T' (the real-SLCAN convention) - this has NOT been verified against
real hardware, since no 29-bit traffic has been seen on this bus yet.
"""
if is_extended:
line = f"T{arbitration_id:08X}{len(data):X}{data.hex().upper()}"
else:
line = f"t{arbitration_id:03X}{len(data):X}{data.hex().upper()}"
self._ser.write((line + "\r").encode("ascii"))