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
146 lines
5.1 KiB
Python
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"))
|