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
138 lines
4.6 KiB
Python
138 lines
4.6 KiB
Python
"""Bidirectional CAN gateway between the car bus and the EPS bus.
|
|
|
|
car (CANdapter) <==> gateway <==> EPS (CAN-IO board USB bridge)
|
|
|
|
Two pump threads, one per direction, each consulting a FilterRules instance
|
|
for whether to forward a given arbitration ID. Starts fully transparent
|
|
(every frame relayed both ways) - block IDs at runtime via the rules objects
|
|
(see gateway_app.py for a GUI, or drive FilterRules directly from a script).
|
|
|
|
Every frame seen is counted (passed/blocked) and, if a FrameStore is given
|
|
for that side, fed to it for live decoding/monitoring - reuses
|
|
decoder/ptcan_decoder.py so this looks the same as the replay dashboard.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import threading
|
|
import time
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from .rules import FilterRules
|
|
|
|
|
|
class GatewayLogger:
|
|
"""CSV log of every frame the gateway sees, whether relayed or not."""
|
|
|
|
def __init__(self, path: Path):
|
|
self._file = open(path, "w", newline="")
|
|
self._writer = csv.writer(self._file)
|
|
self._writer.writerow(["t", "direction", "arbitration_id", "is_extended", "dlc", "data_hex", "relayed"])
|
|
self._lock = threading.Lock()
|
|
self._t0 = time.time()
|
|
|
|
def write(self, direction: str, arbitration_id: int, is_extended: bool, data: bytes, relayed: bool) -> None:
|
|
with self._lock:
|
|
self._writer.writerow(
|
|
[
|
|
round(time.time() - self._t0, 4),
|
|
direction,
|
|
f"{arbitration_id:X}",
|
|
int(is_extended),
|
|
len(data),
|
|
data.hex().upper(),
|
|
int(relayed),
|
|
]
|
|
)
|
|
|
|
def close(self) -> None:
|
|
self._file.close()
|
|
|
|
|
|
class Stats:
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self.passed: Counter = Counter()
|
|
self.blocked: Counter = Counter()
|
|
|
|
def record(self, arbitration_id: int, passed: bool) -> None:
|
|
with self._lock:
|
|
(self.passed if passed else self.blocked)[arbitration_id] += 1
|
|
|
|
def snapshot(self):
|
|
with self._lock:
|
|
return dict(self.passed), dict(self.blocked)
|
|
|
|
def reset(self) -> None:
|
|
with self._lock:
|
|
self.passed.clear()
|
|
self.blocked.clear()
|
|
|
|
|
|
class Gateway:
|
|
"""Owns two already-open adapters and relays frames between them."""
|
|
|
|
def __init__(
|
|
self,
|
|
car_adapter,
|
|
eps_adapter,
|
|
car_to_eps_rules: Optional[FilterRules] = None,
|
|
eps_to_car_rules: Optional[FilterRules] = None,
|
|
car_store=None,
|
|
eps_store=None,
|
|
logger: Optional[GatewayLogger] = None,
|
|
):
|
|
self.car = car_adapter
|
|
self.eps = eps_adapter
|
|
self.car_to_eps_rules = car_to_eps_rules or FilterRules()
|
|
self.eps_to_car_rules = eps_to_car_rules or FilterRules()
|
|
self.car_store = car_store
|
|
self.eps_store = eps_store
|
|
self.logger = logger
|
|
self.stats = Stats()
|
|
|
|
self._stop = threading.Event()
|
|
self._threads: list[threading.Thread] = []
|
|
self._start_wall = time.time()
|
|
|
|
def start(self) -> None:
|
|
self._stop.clear()
|
|
self._threads = [
|
|
threading.Thread(target=self._pump, args=("car->eps",), daemon=True),
|
|
threading.Thread(target=self._pump, args=("eps->car",), daemon=True),
|
|
]
|
|
for t in self._threads:
|
|
t.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
for t in self._threads:
|
|
t.join(timeout=2)
|
|
if self.logger is not None:
|
|
self.logger.close()
|
|
|
|
def is_running(self) -> bool:
|
|
return any(t.is_alive() for t in self._threads)
|
|
|
|
def _pump(self, direction: str) -> None:
|
|
if direction == "car->eps":
|
|
src, dst, rules, src_store = self.car, self.eps, self.car_to_eps_rules, self.car_store
|
|
else:
|
|
src, dst, rules, src_store = self.eps, self.car, self.eps_to_car_rules, self.eps_store
|
|
|
|
while not self._stop.is_set():
|
|
frame = src.read_frame(timeout=0.5)
|
|
if frame is None:
|
|
continue
|
|
|
|
allowed = rules.allows(frame.arbitration_id)
|
|
if allowed:
|
|
dst.send_frame(frame.arbitration_id, frame.data, frame.is_extended)
|
|
|
|
self.stats.record(frame.arbitration_id, allowed)
|
|
if src_store is not None:
|
|
src_store.feed(frame.arbitration_id, frame.data, frame.recv_time - self._start_wall)
|
|
if self.logger is not None:
|
|
self.logger.write(direction, frame.arbitration_id, frame.is_extended, frame.data, allowed)
|