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
152 lines
5.8 KiB
Python
152 lines
5.8 KiB
Python
"""Standalone digital-IO monitor/logger for the CAN-IO board.
|
|
|
|
Deliberately separate from the CAN path: it talks to the board over the
|
|
USB '!'/'@' channel (see can-io/firmware/src/usb_bridge.h), which keeps
|
|
working when the CAN bus has no other powered node - the situation you're
|
|
in before the EPS gets its 12V enable, and exactly when the CAN status
|
|
frame goes missing because the shared TX queue backs up behind
|
|
100ms-timing-out transmits.
|
|
|
|
Logs each direction as its own signal:
|
|
IN1 = the car's 12V ignition/enable signal arriving at this board
|
|
OUT1 = the 12V signal this board repeats onward to the EPS
|
|
|
|
CSV columns: t, uptime_s, in1..in4, out1, out2, event
|
|
event is "change" for a real transition, "sample" for periodic polls.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
class DioLogger:
|
|
"""Appends IO states to a CSV, noting which rows were real transitions."""
|
|
|
|
def __init__(self, path: Path):
|
|
self._file = open(path, "w", newline="")
|
|
self._writer = csv.writer(self._file)
|
|
self._writer.writerow(
|
|
["t", "wall", "uptime_s", "in1", "in2", "in3", "in4", "out1", "out2", "event"]
|
|
)
|
|
self._lock = threading.Lock()
|
|
self._t0 = time.time()
|
|
self._prev: Optional[tuple[int, int]] = None
|
|
|
|
def record(self, inputs: int, outputs: int, uptime_s: int) -> bool:
|
|
"""Returns True if this was a change (not just a periodic sample)."""
|
|
with self._lock:
|
|
changed = self._prev is not None and (inputs, outputs) != self._prev
|
|
first = self._prev is None
|
|
self._prev = (inputs, outputs)
|
|
now = time.time()
|
|
self._writer.writerow(
|
|
[
|
|
round(now - self._t0, 4),
|
|
round(now, 4),
|
|
uptime_s,
|
|
*[(inputs >> i) & 1 for i in range(4)],
|
|
outputs & 1,
|
|
(outputs >> 1) & 1,
|
|
"change" if changed else ("boot" if first else "sample"),
|
|
]
|
|
)
|
|
self._file.flush() # keep the log usable while a session is still running
|
|
return changed
|
|
|
|
def close(self) -> None:
|
|
self._file.close()
|
|
|
|
|
|
class DioMonitor:
|
|
"""Polls the board's IO channel on its own thread, independent of any
|
|
CAN relay/replay activity, optionally logging every update.
|
|
|
|
`owns_reads` must be False whenever something else (the Gateway's relay
|
|
thread, the replay loop) is already calling read_frame() on the same
|
|
adapter: two threads reading one serial port interleave partial lines
|
|
and corrupt both streams. In that case this only writes '@G' requests
|
|
and samples the io_state the other reader populates. Set it True when
|
|
this monitor is the sole user of the port.
|
|
"""
|
|
|
|
def __init__(self, adapter, logger: Optional[DioLogger] = None,
|
|
poll_interval: float = 0.25, owns_reads: bool = False):
|
|
self.adapter = adapter
|
|
self.logger = logger
|
|
self.poll_interval = poll_interval
|
|
self.owns_reads = owns_reads
|
|
self.inputs = 0
|
|
self.outputs = 0
|
|
self.uptime_s = 0
|
|
self.last_update: Optional[float] = None
|
|
self.updates = 0
|
|
self.changes = 0
|
|
self._stop = threading.Event()
|
|
self._thread: Optional[threading.Thread] = None
|
|
|
|
def is_running(self) -> bool:
|
|
return bool(self._thread and self._thread.is_alive())
|
|
|
|
def start(self) -> None:
|
|
if self.is_running():
|
|
return
|
|
self._stop.clear()
|
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
if self._thread:
|
|
self._thread.join(timeout=2)
|
|
if self.logger is not None:
|
|
self.logger.close()
|
|
|
|
def set_output(self, index: int, on: bool) -> None:
|
|
self.adapter.set_output(index, on)
|
|
|
|
def set_output_verified(self, index: int, on: bool, attempts: int = 4, timeout: float = 0.6) -> bool:
|
|
"""Command an output and confirm the board actually reports it, retrying.
|
|
|
|
A single '@S' write can be lost (USB CDC hiccup, or the line arriving
|
|
while the board is mid-parse), which shows up as an output that
|
|
occasionally just doesn't switch. Re-asserting until the reported
|
|
state matches makes that self-healing - the command is idempotent,
|
|
so a duplicate is harmless.
|
|
"""
|
|
want = 1 if on else 0
|
|
for _ in range(attempts):
|
|
self.adapter.set_output(index, on)
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
self.adapter.request_io()
|
|
time.sleep(0.08)
|
|
state = self.adapter.io_state
|
|
if state is not None and ((state.outputs >> index) & 1) == want:
|
|
self.outputs = state.outputs
|
|
return True
|
|
return False
|
|
|
|
def _run(self) -> None:
|
|
while not self._stop.is_set():
|
|
self.adapter.request_io()
|
|
if self.owns_reads:
|
|
deadline = time.monotonic() + self.poll_interval
|
|
while time.monotonic() < deadline:
|
|
self.adapter.read_frame(timeout=0.05)
|
|
else:
|
|
time.sleep(self.poll_interval)
|
|
|
|
state = self.adapter.io_state
|
|
if state is None:
|
|
continue
|
|
self.inputs = state.inputs
|
|
self.outputs = state.outputs
|
|
self.uptime_s = state.uptime_s
|
|
self.last_update = state.recv_time
|
|
self.updates += 1
|
|
if self.logger is not None and self.logger.record(state.inputs, state.outputs, state.uptime_s):
|
|
self.changes += 1
|