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
89 lines
2.9 KiB
Python
89 lines
2.9 KiB
Python
"""Background-thread wrapper around Candapter for use from the Streamlit UI."""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
from adapters.candapter import Candapter # noqa: E402
|
|
|
|
|
|
class CsvRecorder:
|
|
"""Appends frames to a CSV in the same format log_can.py uses."""
|
|
|
|
def __init__(self, path: Path):
|
|
self._file = open(path, "w", newline="")
|
|
self._writer = csv.writer(self._file)
|
|
self._writer.writerow(["timestamp_ms", "arbitration_id", "is_extended", "dlc", "data_hex"])
|
|
|
|
def write(self, frame) -> None:
|
|
self._writer.writerow(
|
|
[
|
|
frame.timestamp_ms if frame.timestamp_ms is not None else "",
|
|
f"{frame.arbitration_id:X}",
|
|
int(frame.is_extended),
|
|
len(frame.data),
|
|
frame.data.hex().upper(),
|
|
]
|
|
)
|
|
|
|
def close(self) -> None:
|
|
self._file.close()
|
|
|
|
|
|
class LiveCapture:
|
|
"""Runs Candapter.read_frame() on a background thread, feeding a FrameStore."""
|
|
|
|
def __init__(self) -> None:
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._stop = threading.Event()
|
|
self.error: Optional[str] = None
|
|
self.connected = False
|
|
self.frame_count = 0
|
|
|
|
def is_running(self) -> bool:
|
|
return bool(self._thread and self._thread.is_alive())
|
|
|
|
def start(self, port: str, bitrate: int, store, record_path: Optional[Path] = None) -> None:
|
|
if self.is_running():
|
|
return
|
|
self._stop.clear()
|
|
self.error = None
|
|
self.frame_count = 0
|
|
self._thread = threading.Thread(target=self._run, args=(port, bitrate, store, record_path), daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
if self._thread:
|
|
self._thread.join(timeout=2)
|
|
self.connected = False
|
|
|
|
def _run(self, port: str, bitrate: int, store, record_path: Optional[Path]) -> None:
|
|
try:
|
|
adapter = Candapter(port, bitrate, timestamps=True)
|
|
except (OSError, ValueError) as exc:
|
|
self.error = str(exc)
|
|
return
|
|
|
|
recorder = CsvRecorder(record_path) if record_path else None
|
|
self.connected = True
|
|
start_wall = time.time()
|
|
try:
|
|
with adapter:
|
|
while not self._stop.is_set():
|
|
frame = adapter.read_frame(timeout=0.5)
|
|
if frame is None:
|
|
continue
|
|
store.feed(frame.arbitration_id, frame.data, frame.recv_time - start_wall)
|
|
self.frame_count += 1
|
|
if recorder is not None:
|
|
recorder.write(frame)
|
|
finally:
|
|
self.connected = False
|
|
if recorder is not None:
|
|
recorder.close()
|