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
134 lines
5.5 KiB
Python
134 lines
5.5 KiB
Python
"""Replay a captured CSV onto a live CAN bus (e.g. the EPS bus via the CAN-IO
|
|
board's USB bridge) - see whether the EPS will power up/behave from a
|
|
recording alone, with no car attached. A stepping stone before writing a
|
|
synthetic frame generator: get a known-good replay working and filtered down
|
|
to the minimum frame set first, then synthesize from there.
|
|
|
|
Usage:
|
|
python gateway/replay_to_bus.py captures/capture_Start-Stop.csv \\
|
|
--port /dev/cu.usbmodemXXXX --bitrate 500000 \\
|
|
[--speed 1.0] [--loop] [--block 0x1D6 0x380] [--rules myrules.json] \\
|
|
[--dio-log captures/dio_20260829.csv] [--dry-run]
|
|
|
|
--dry-run replays the timing/filtering logic and prints a summary without
|
|
opening a serial port - useful for testing with no adapter attached.
|
|
|
|
--rules loads a plain FilterRules JSON (as saved by FilterRules.save(), i.e.
|
|
{"default_allow": ..., "overrides": {...}}) - not the combined car_to_eps/
|
|
eps_to_car file gateway_app.py saves.
|
|
|
|
--dio-log replays the CAN-IO board's own digital outputs (e.g. the 12V
|
|
signal repeat) alongside the CAN traffic, from a timeline produced by
|
|
tools/extract_dio_timeline.py. Without this, a replay only sends CAN
|
|
frames - if the EPS also needs that physical signal to enable, replaying
|
|
CAN alone won't reproduce what it saw live.
|
|
"""
|
|
import argparse
|
|
import csv
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
from adapters.slcan_adapter import SlcanAdapter # noqa: E402
|
|
from decoder.replay_source import load_capture # noqa: E402
|
|
from gateway.rules import FilterRules # noqa: E402
|
|
|
|
CAN_ID_COMMAND = 0x101
|
|
CMD_SET_ALL = 0x02
|
|
|
|
|
|
def load_dio_events(path: Path) -> list[tuple[float, int]]:
|
|
"""Read a (t, inputs, outputs) timeline, keep only the times outputs changed."""
|
|
events = []
|
|
prev = None
|
|
with open(path, newline="") as f:
|
|
for row in csv.DictReader(f):
|
|
outputs = int(row["outputs"])
|
|
if outputs != prev:
|
|
events.append((float(row["t"]), outputs))
|
|
prev = outputs
|
|
return events
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("csv", help="Capture file to replay (timestamp_ms, arbitration_id, is_extended, dlc, data_hex)")
|
|
parser.add_argument("--port", help="Serial port of the target adapter (e.g. CAN-IO board bridge)")
|
|
parser.add_argument("--bitrate", type=int, default=500_000)
|
|
parser.add_argument("--speed", type=float, default=1.0, help="Playback speed multiplier")
|
|
parser.add_argument("--loop", action="store_true", help="Replay repeatedly until Ctrl+C")
|
|
parser.add_argument("--block", nargs="*", default=[], help="Arbitration IDs (hex) to withhold from the bus")
|
|
parser.add_argument("--rules", default=None, help="Load a FilterRules JSON file instead of/in addition to --block")
|
|
parser.add_argument("--dio-log", default=None, help="Replay CAN-IO board output changes from tools/extract_dio_timeline.py's output")
|
|
parser.add_argument("--dry-run", action="store_true", help="Print a summary instead of sending - no adapter needed")
|
|
args = parser.parse_args()
|
|
|
|
frames = load_capture(Path(args.csv))
|
|
if not frames:
|
|
print("No frames in capture", file=sys.stderr)
|
|
return 1
|
|
|
|
dio_events = load_dio_events(Path(args.dio_log)) if args.dio_log else []
|
|
|
|
rules = FilterRules()
|
|
if args.rules:
|
|
rules.load(Path(args.rules))
|
|
for tok in args.block:
|
|
rules.set_allow(int(tok, 16), False)
|
|
|
|
if args.dry_run:
|
|
adapter = None
|
|
else:
|
|
if not args.port:
|
|
print("--port is required unless --dry-run", file=sys.stderr)
|
|
return 1
|
|
adapter = SlcanAdapter(args.port, args.bitrate)
|
|
|
|
# Merge CAN frames and DIO output-change events into one time-ordered timeline.
|
|
timeline = [(t, "frame", (aid, data)) for t, aid, data in frames]
|
|
timeline += [(t, "dio", outputs) for t, outputs in dio_events]
|
|
timeline.sort(key=lambda e: e[0])
|
|
|
|
print(f"Replaying {len(frames)} frames + {len(dio_events)} DIO events ({frames[-1][0]:.1f}s) at {args.speed}x"
|
|
f"{' [dry run]' if args.dry_run else f' -> {args.port}'}. Ctrl+C to stop.\n")
|
|
|
|
sent = skipped = dio_sent = 0
|
|
try:
|
|
while True:
|
|
t0 = time.monotonic()
|
|
for t, kind, payload in timeline:
|
|
if kind == "frame":
|
|
aid, data = payload
|
|
allowed = rules.allows(aid)
|
|
if allowed:
|
|
if adapter is not None:
|
|
adapter.send_frame(aid, data)
|
|
sent += 1
|
|
else:
|
|
skipped += 1
|
|
else: # "dio": force OUT1/OUT2 to match the recorded state
|
|
if adapter is not None:
|
|
adapter.send_frame(CAN_ID_COMMAND, bytes([CMD_SET_ALL, 0x03, payload]))
|
|
dio_sent += 1
|
|
|
|
target = t0 + t / args.speed
|
|
delay = target - time.monotonic()
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
|
|
print(f"pass complete: {sent} sent, {skipped} skipped, {dio_sent} DIO changes ({len(frames)} frames total)")
|
|
if not args.loop:
|
|
break
|
|
sent = skipped = dio_sent = 0
|
|
except KeyboardInterrupt:
|
|
print("\nStopped.")
|
|
finally:
|
|
if adapter is not None:
|
|
adapter.close()
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|