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
10 KiB
EPS communication findings — running log
Newest entries at the top. Each entry: date, what was tested, what we saw, what it means, what's still open.
For the consolidated result — how to run the EPS standalone — see EPS_PROTOCOL.md. This file is the working history.
2026-08-29 (evening) — minimum message set found: 0x130 + 0x1A0
Result: the EPS needs only two car messages, not the 69 the bus
carries: 0x130 CAS terminal status brings it up, 0x1A0 DSC road speed
sets the assist level. Plus the 12V enable on a discrete wire. Confirmed on
the bench by narrowing the transmit set.
Two bugs found along the way, both mine, both looked like EPS faults:
- Frozen counters. A captured payload retransmitted verbatim leaves its alive counter stuck, which every consumer treats as a stale sender. This is why replay worked and static transmission didn't. Fixed by cycling the captured payload sequence per ID rather than one frozen frame.
- Transmit rate starvation.
read_frame()used a blockingserial.read(64)that could stall the transmit loop for up to 100 ms, so the bench managed 264 frames/s against the ~1330 the full set needs - every message arriving late, indistinguishable from a faulty sender. Fixed by reading only buffered bytes and batching writes: 264 -> 1282 frames/s standalone. The two-message set needs just 60 frames/s, which removes the problem entirely.
Checksums remain unsolved. Brute-forced sum/one's-complement/XOR over
every byte range, all constants, with and without counter contribution:
0x1A0 b7 best fit 87.5% (fold(b2..b6) + 0x22), 0x130 b4 high nibble best
fit 64%. Not good enough to generate frames blind. Workaround in use:
replay genuine captured frames per terminal state, and patch only the speed
field of 0x1A0 (bytes 0-1, which the best-fitting checksum range excludes).
See EPS_PROTOCOL.md §6.
EPS output messages decoded: 0x1FB is a pure alive counter (b0 low
nibble, 0-14, high nibble 0xF) - the best health signal available; 0x4B0
is a status heartbeat with a likely checksum in b0; 0x5B0 has exactly two
payloads, 01 03 80… (initialising) and 40 81 01 15… (ready).
Open: assist has only been observed at 0 km/h, so the speed response
curve is unverified; behaviour on CAN loss at speed is untested; whether
0x45 is needed or 0x41 suffices is unknown. Full list in
EPS_PROTOCOL.md §9.
2026-08-29 — clean isolated-bus test (post-fix), EPS's own IDs identified, first ruleset
Test conditions: same sequence as before, firmware TX-echo bug fixed
first. Engine actually had to be started this time to get the EPS working;
the dashboard EPS fault was intermittent even then. Nothing blocked yet -
fully transparent relay. Log: captures/gateway_20260829_164540.csv (84s,
60643 car->eps frames, only 37 eps->car frames - the fix worked).
EPS's own traffic, isolated and confirmed for the first time:
0x100- our own CAN-IO board status (not the EPS - a test artifact, drop before any real vehicle use).0x1FB(2 bytes) - genuinely the EPS module's own alive/counter frame. Previously mislabeled "counter + checksum, unidentified module" infiles/PTCAN_protocol.md, which was written from a whole-bus capture - now confirmed it's the EPS.0x4B0(8 bytes,XX 01 FE FF FF FF FF FF) - the EPS's own module-status heartbeat. Byte 0 toggled between0x00and0x30- possibly a state or fault-flag nibble worth watching once we can correlate it with the cluster's fault indicator turning on/off.
Intermittent fault - two live hypotheses, not yet distinguished:
- Missing/incorrect input: the EPS needs something we're not feeding it, or feeding it in a state that only appears with the engine actually running (e.g. a DME "engine actually running" flag vs. just "cranking allowed", or a voltage/RPM value only valid once running).
- Relay latency/jitter: this is a software (PC + 2x USB-serial) gateway,
which adds tens of ms of jitter per hop on top of whatever the real bus
had. 10ms-cycle messages like
0x0C4(steering angle) arriving late often enough could plausibly trip an EPS freshness/plausibility check intermittently. This is a real architectural limit of a PC-mediated gateway - if it turns out to be the cause, the fix is a dedicated hardware relay/generator (no PC round-trip) once we know the required message set, not more filtering.
Both are testable via replay (see below): replaying the known-working
car->eps sequence with no live car removes any car-side variability
and isolates the test to the gateway/EPS interaction. If the same
intermittent fault reproduces on replay, that points at (2) rather than (1).
Digital IO was also recorded, indirectly: the CAN-IO board reports its
own IN1-4/OUT1-2 state in its 0x100 status frame (on every change, plus a
1s heartbeat), which got logged like any other frame. Extracted with
tools/extract_dio_timeline.py - in this session, IN1/OUT1 (the car's
12V signal and its repeat to the EPS) was high 3.539s -> 4.083s, then
21.794s -> 67.123s, then off. Sparse (only 12 status samples in 84s) but
enough to know the on/off windows. gateway/replay_to_bus.py --dio-log
now replays this alongside the CAN traffic - replaying CAN frames alone
would leave the EPS never seeing the 12V signal at all, since a bench
replay has no real car to drive that input.
Ruleset: first best-guess filter saved to gateway/rules.suggested.json
(app's combined format) and gateway/rules.suggested_car_to_eps.json (plain
format for replay_to_bus.py/FilterRules). Blocks only what's almost
certainly irrelevant to EPS assist function - see table below. Verified by
dry-run replay: skips exactly the expected 3084 of 60643 frames.
| Blocked ID | Name | Why |
|---|---|---|
| 0x380 | VIN tail | Not needed for operation, and shouldn't be replayed/shared anyway |
| 0x1D6 | MFL steering wheel buttons | Unrelated function (audio/cruise control) |
| 0x1D0 | DME temps + fuel | Unrelated to steering assist |
| 0x1B4 | Instrument cluster (warning lamps) | Cluster's own broadcast; EPS is a lamp source, not consumer, of this one |
| 0x480, 0x492, 0x497, 0x4A9 | Other modules' generic status heartbeats | Not 0x4B0 - that one is the EPS's own, kept out of this list on purpose |
| 0x580, 0x592, 0x5A9, 0x5C0 | Diagnostic / ISO-TP session frames | Not part of normal operation |
Left allowed (either likely needed, or not confident enough to block yet):
steering angle/rate (0x0C4/0x0C8), road speed (0x1A0), wheel speeds
(0x0CE), DME engine speed/voltage/torque (0xAA/0xA9/0xA8), CAS
terminal (0x130), DSC status/counter/accumulator (0x19E/0x0B6/0x1A6),
0x1B6 (flagged in the protocol notes as a possible steering-torque
signal - do not block without testing specifically), and everything still
unidentified.
Next steps:
- Replay
captures/replay_car_to_eps_20260829.csv(the exact car->eps traffic from this working session) onto the EPS bus alone, car disconnected, withgateway/rules.suggested_car_to_eps.jsonapplied and--dio-log captures/dio_20260829.csvso the 12V signal repeat is reproduced too. Confirm the EPS still comes up the same way from replay as it did live. - If the intermittent fault reproduces identically on replay, that's evidence for the jitter hypothesis over a missing-message hypothesis.
- Iterate the ruleset: block one more plausible-non-essential group at a
time (start with
0x1B4's replacement candidates,0x0B6,0x1A6), re-replay, watch for the fault changing character. - Once a minimal set is confirmed stable, start the synthesis plan (see README.md) - a generator that produces just those IDs from scratch.
2026-08-29 — first gateway sessions, discovered a firmware bug (not a bus-sharing issue)
Test conditions: car + EPS both wired through the gateway (CANdapter on car PT-CAN, CAN-IO board on the EPS's own bus). Two sessions:
gateway_20260829_162119.csv(~21s) - noeps->cartraffic at all.gateway_20260829_162235.csv(~227s) - ignition on, engine off, then car fully off. EPS "started working" with ignition on (but showed its usual dashboard error - apparently normal without the engine running) and did not start with the car fully off.
What the data showed: in the second session, 69 unique arbitration IDs
appeared in the eps->car direction; 64 of them were IDs the gateway had
just relayed car->eps moments earlier (recognisable car ECU messages:
0xA8/0xA9/0xAA DME, 0xC4/0xC8 SZL steering, 0xCE/0x19E/0x1A0 DSC,
0x130 CAS). Only 0x100 (the board's own status frame) was genuinely
native to the EPS bus.
Initial hypothesis (wrong): that the car and EPS buses were still
electrically the same bus. Ruled out - confirmed the EPS bus is physically
isolated, and the car/EPS ports used in the test were correct and distinct
(/dev/cu.usbserial-DNBJV4F5 vs /dev/cu.usbmodem101).
Actual cause (fixed): a firmware bug. The CAN-IO board mirrors its own
CAN transmissions back over the USB bridge so the PC can see frames the
board originates locally (status/heartbeat), since TWAI has no RX loopback.
That mirroring was too broad - it also fired for frames the gateway asked
the board to relay from the car bus, so every relayed ID echoed straight
back over USB looking exactly like new incoming EPS-bus traffic, which the
Python gateway then dutifully relayed back to the car bus (a real feedback
loop, not just a logging artifact). Fixed by tagging TX-queue entries with
whether they originated on the board itself (mirror) or came from the
bridge (don't mirror) - see can-io/firmware/src/can_bus.{h,cpp} and
usb_bridge.cpp, can_send(msg, mirror_on_success).
Conclusion: both sessions captured before this fix are unreliable for answering "what does the EPS bus actually carry" - re-test needed.
Next test to run: repeat the ignition-on / engine-off / car-off sequence
now that the firmware is fixed, and check whether eps->car traffic now
shows only genuinely EPS-native messages (plausibly very little beyond
0x100, until the EPS module itself responds to something).
Open questions:
- What does the EPS module transmit on its own bus, if anything, once it's awake? (Could not tell from data so far - contaminated by the bug above.)
- Is a wake/terminal signal (e.g. the 12V line into the CAN-IO board's input) required in addition to CAN traffic, or does CAN alone bring the EPS up? Not yet tested independently.
- No messages have been blocked yet in
gateway_app.py- full transparent relay only so far.