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
177 lines
6.6 KiB
Python
177 lines
6.6 KiB
Python
"""Generic SLCAN (Lawicel) driver — for the CAN-IO board's USB bridge mode.
|
|
|
|
Unlike the CANdapter (see candapter.py), this targets a real, unmodified
|
|
SLCAN implementation: 't'/'T' frame prefixes, no extra un-counted bytes
|
|
after the data field. Written for firmware/src/usb_bridge.cpp on the
|
|
CAN-IO board, but works with any standard SLCAN device.
|
|
|
|
On top of plain SLCAN, the CAN-IO board adds a digital-IO channel on the
|
|
same serial link that does NOT depend on the CAN bus being alive: '!' lines
|
|
report IN/OUT state, '@' lines set outputs. See io_state / set_output /
|
|
request_io below, and usb_bridge.h for the firmware side.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import time
|
|
from typing import Optional
|
|
|
|
import serial
|
|
|
|
from .candapter import BITRATE_CODES, CanFrame
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class IoState:
|
|
inputs: int # bit0 = IN1 ... bit3 = IN4
|
|
outputs: int # bit0 = OUT1, bit1 = OUT2
|
|
uptime_s: int
|
|
recv_time: float
|
|
|
|
def input_on(self, index: int) -> bool:
|
|
return bool(self.inputs & (1 << index))
|
|
|
|
def output_on(self, index: int) -> bool:
|
|
return bool(self.outputs & (1 << index))
|
|
|
|
|
|
class SlcanAdapter:
|
|
def __init__(self, port: str, bitrate: int, control_baud: int = 115200):
|
|
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.io_state: Optional[IoState] = None
|
|
self._send_command("C") # close channel in case it was left open
|
|
self._send_command(f"S{BITRATE_CODES[bitrate]}")
|
|
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) -> "SlcanAdapter":
|
|
return self
|
|
|
|
def __exit__(self, *exc) -> None:
|
|
self.close()
|
|
|
|
def send_frame(self, arbitration_id: int, data: bytes, is_extended: bool = False) -> None:
|
|
self._ser.write(self._frame_line(arbitration_id, data, is_extended).encode("ascii"))
|
|
|
|
def send_frames(self, frames) -> None:
|
|
"""Write several frames in one go - one syscall per batch instead of
|
|
per frame, which is the difference between holding a 10ms cycle and
|
|
not when a few dozen IDs come due together."""
|
|
if not frames:
|
|
return
|
|
blob = "".join(self._frame_line(aid, data, ext) for aid, data, ext in frames)
|
|
self._ser.write(blob.encode("ascii"))
|
|
|
|
@staticmethod
|
|
def _frame_line(arbitration_id: int, data: bytes, is_extended: bool = False) -> str:
|
|
if is_extended:
|
|
return f"T{arbitration_id:08X}{len(data):X}{data.hex().upper()}\r"
|
|
return f"t{arbitration_id:03X}{len(data):X}{data.hex().upper()}\r"
|
|
|
|
# ---- CAN-independent digital IO (CAN-IO board only) -------------------
|
|
|
|
def set_output(self, index: int, on: bool) -> None:
|
|
"""Drive OUT<index+1>. Takes effect even with a dead/one-node CAN bus."""
|
|
self._ser.write(f"@S{index}{1 if on else 0}\r".encode("ascii"))
|
|
|
|
def toggle_output(self, index: int) -> None:
|
|
self._ser.write(f"@T{index}\r".encode("ascii"))
|
|
|
|
def request_io(self) -> None:
|
|
"""Ask for an immediate IO report; the reply updates self.io_state
|
|
the next time read_frame() runs."""
|
|
self._ser.write(b"@G\r")
|
|
|
|
def read_frame(self, timeout: Optional[float] = None) -> Optional[CanFrame]:
|
|
"""Read one frame, or None if nothing complete arrived within timeout.
|
|
|
|
Also consumes '!' IO report lines as they go by, updating io_state -
|
|
so callers that poll this in a loop get IO tracking for free.
|
|
"""
|
|
deadline = None if timeout is None else time.monotonic() + timeout
|
|
while True:
|
|
if deadline is not None and time.monotonic() >= deadline:
|
|
return None
|
|
|
|
nl = self._buf.find(b"\r")
|
|
if nl == -1:
|
|
# Only take what's already buffered: a plain read(64) blocks
|
|
# until 64 bytes arrive or the port's own timeout expires,
|
|
# which would stall a caller that asked for a few ms and
|
|
# wreck the transmit cadence of anything sharing this thread.
|
|
waiting = self._ser.in_waiting
|
|
if waiting:
|
|
self._buf.extend(self._ser.read(waiting))
|
|
elif deadline is None:
|
|
self._buf.extend(self._ser.read(1))
|
|
else:
|
|
time.sleep(0.0005)
|
|
continue
|
|
|
|
line = bytes(self._buf[:nl])
|
|
del self._buf[: nl + 1]
|
|
|
|
# ACK (0x06) / BELL (0x07) replies carry no terminator of their
|
|
# own, so they end up glued to the front of whatever line comes
|
|
# next - strip them before looking at the prefix.
|
|
line = line.lstrip(b"\x06\x07")
|
|
|
|
if not line:
|
|
continue
|
|
if line[0:1] == b"!":
|
|
self._parse_io(line)
|
|
continue
|
|
if chr(line[0]) not in ("t", "T"):
|
|
continue # ignore stray ACK/BELL bytes mixed into the stream
|
|
|
|
frame = self._parse_frame(line)
|
|
if frame is not None:
|
|
return frame
|
|
|
|
def _parse_io(self, line: bytes) -> None:
|
|
text = line.decode("ascii", errors="replace")
|
|
try:
|
|
self.io_state = IoState(
|
|
inputs=int(text[1:3], 16),
|
|
outputs=int(text[3:5], 16),
|
|
uptime_s=int(text[5:13], 16),
|
|
recv_time=time.time(),
|
|
)
|
|
except (ValueError, IndexError):
|
|
pass
|
|
|
|
@staticmethod
|
|
def _parse_frame(line: bytes) -> Optional[CanFrame]:
|
|
text = line.decode("ascii", errors="replace")
|
|
is_extended = text[0] == "T"
|
|
try:
|
|
if is_extended:
|
|
id_hex, rest = text[1:9], text[9:]
|
|
else:
|
|
id_hex, rest = text[1:4], text[4:]
|
|
|
|
dlc = int(rest[0], 16)
|
|
data = bytes.fromhex(rest[1 : 1 + dlc * 2])
|
|
return CanFrame(
|
|
arbitration_id=int(id_hex, 16),
|
|
is_extended=is_extended,
|
|
data=data,
|
|
timestamp_ms=None,
|
|
recv_time=time.time(),
|
|
)
|
|
except (ValueError, IndexError):
|
|
return None
|