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
154 lines
5.7 KiB
Python
154 lines
5.7 KiB
Python
"""Hardware-aware replay engine, shared by gateway_app.py's Replay tab.
|
|
|
|
Unlike decoder/replay_source.py's ReplayPlayer (which only feeds a
|
|
FrameStore for visualization), this actually sends allowed frames to a real
|
|
adapter, and can also replay the CAN-IO board's own digital output changes
|
|
(see tools/extract_dio_timeline.py) - both driven by the same wall-clock
|
|
advance() call so it fits Streamlit's rerun-loop pattern with no background
|
|
thread needed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
CAN_ID_COMMAND = 0x101
|
|
CAN_ID_STATUS = 0x100
|
|
CMD_SET_ALL = 0x02
|
|
|
|
|
|
def load_capture(path: Path) -> list[tuple[float, int, bytes]]:
|
|
"""Same format/behaviour as decoder.replay_source.load_capture."""
|
|
rows = []
|
|
with open(path, newline="") as f:
|
|
for row in csv.DictReader(f):
|
|
rows.append((int(row["timestamp_ms"]), int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"])))
|
|
rows.sort(key=lambda r: r[0])
|
|
if not rows:
|
|
return []
|
|
t0 = rows[0][0]
|
|
return [((ms - t0) / 1000, aid, data) for ms, aid, data in rows]
|
|
|
|
|
|
def load_dio_events(path: Path) -> list[tuple[float, int]]:
|
|
"""Read a DIO timeline, keeping only the times the outputs changed.
|
|
|
|
Accepts both layouts we produce:
|
|
- gateway/dio_monitor.py's DioLogger: t, uptime_s, in1..in4, out1, out2, event
|
|
- the older tools/extract_dio_timeline.py: t, inputs, outputs
|
|
"""
|
|
events = []
|
|
prev = None
|
|
with open(path, newline="") as f:
|
|
reader = csv.DictReader(f)
|
|
for row in reader:
|
|
if "out1" in row:
|
|
outputs = (int(row["out1"]) & 1) | ((int(row["out2"]) & 1) << 1)
|
|
else:
|
|
outputs = int(row["outputs"])
|
|
if outputs != prev:
|
|
events.append((float(row["t"]), outputs))
|
|
prev = outputs
|
|
return events
|
|
|
|
|
|
def gateway_log_to_capture(gateway_log: Path, direction: str = "car->eps", include_blocked: bool = False) -> list[tuple[float, int, bytes]]:
|
|
"""Same idea as tools/gateway_log_to_capture.py, but in-process."""
|
|
rows = []
|
|
with open(gateway_log, newline="") as f:
|
|
for row in csv.DictReader(f):
|
|
if row["direction"] != direction:
|
|
continue
|
|
if not include_blocked and row["relayed"] != "1":
|
|
continue
|
|
rows.append((float(row["t"]), int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"])))
|
|
rows.sort(key=lambda r: r[0])
|
|
if not rows:
|
|
return []
|
|
t0 = rows[0][0]
|
|
return [(t - t0, aid, data) for t, aid, data in rows]
|
|
|
|
|
|
def extract_dio_events(gateway_log: Path) -> list[tuple[float, int]]:
|
|
"""Same idea as tools/extract_dio_timeline.py, but in-process."""
|
|
samples = []
|
|
with open(gateway_log, newline="") as f:
|
|
for row in csv.DictReader(f):
|
|
if int(row["arbitration_id"], 16) != CAN_ID_STATUS:
|
|
continue
|
|
data = bytes.fromhex(row["data_hex"])
|
|
if len(data) < 2:
|
|
continue
|
|
samples.append((float(row["t"]), data[1]))
|
|
events = []
|
|
prev = None
|
|
for t, outputs in samples:
|
|
if outputs != prev:
|
|
events.append((t, outputs))
|
|
prev = outputs
|
|
return events
|
|
|
|
|
|
class HardwareReplayPlayer:
|
|
"""Steps through a merged (frames + DIO events) timeline, sending to a
|
|
real adapter as it goes, driven by repeated advance() calls (Streamlit
|
|
rerun loop) rather than a background thread."""
|
|
|
|
def __init__(self, frames: list[tuple[float, int, bytes]], dio_events: Optional[list[tuple[float, int]]] = None):
|
|
self.frames = frames
|
|
timeline = [(t, "frame", (aid, data)) for t, aid, data in frames]
|
|
timeline += [(t, "dio", outputs) for t, outputs in (dio_events or [])]
|
|
timeline.sort(key=lambda e: e[0])
|
|
self.timeline = timeline
|
|
self.duration = frames[-1][0] if frames else 0.0
|
|
self.index = 0
|
|
self.clock = 0.0
|
|
self.sent = 0
|
|
self.skipped = 0
|
|
self.dio_sent = 0
|
|
|
|
def reset(self) -> None:
|
|
self.index = 0
|
|
self.clock = 0.0
|
|
self.sent = self.skipped = self.dio_sent = 0
|
|
|
|
def at_end(self) -> bool:
|
|
return self.index >= len(self.timeline)
|
|
|
|
def advance(self, dt_wall: float, speed: float, store, rules, adapter=None) -> None:
|
|
self.clock += dt_wall * speed
|
|
while self.index < len(self.timeline) and self.timeline[self.index][0] <= self.clock:
|
|
t, kind, payload = self.timeline[self.index]
|
|
if kind == "frame":
|
|
aid, data = payload
|
|
allowed = rules.allows(aid)
|
|
if allowed:
|
|
if adapter is not None:
|
|
adapter.send_frame(aid, data)
|
|
self.sent += 1
|
|
store.feed(aid, data, t)
|
|
else:
|
|
self.skipped += 1
|
|
else: # "dio"
|
|
if adapter is not None:
|
|
# Direct USB IO channel, not a CAN command frame: works even
|
|
# when the EPS bus has no other powered node to ACK traffic.
|
|
for idx in range(2):
|
|
adapter.set_output(idx, bool(payload & (1 << idx)))
|
|
self.dio_sent += 1
|
|
self.index += 1
|
|
|
|
def seek(self, t: float, store) -> None:
|
|
store.reset()
|
|
self.reset()
|
|
self.clock = t
|
|
while self.index < len(self.timeline) and self.timeline[self.index][0] <= self.clock:
|
|
frame_t, kind, payload = self.timeline[self.index]
|
|
if kind == "frame":
|
|
aid, data = payload
|
|
store.feed(aid, data, frame_t)
|
|
self.sent += 1
|
|
else:
|
|
self.dio_sent += 1
|
|
self.index += 1
|