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
12 KiB
BMW E8x EPS — standalone operating protocol
How to power up, control and monitor the E8x/E9x electric power steering (EPS) unit outside the donor car — e.g. in a custom EV conversion.
Everything here was derived by observation from a working car and verified against the real unit on a bench. Where something is inferred rather than proven, it says so explicitly: [A] proven on hardware, [B] strongly indicated, [C] inference worth testing. Trusting a [C] without checking it is how you end up debugging the wrong layer.
Source material: eps-comms/findings.md (chronological log),
files/PTCAN_protocol.md (whole-bus reverse engineering), and the capture
and bench logs in captures/.
1. What the EPS actually needs
The short version, and the surprise of this project: two CAN messages and one 12V wire. Not the 69-message firehose the car puts on the bus.
| Requirement | Detail | Confidence |
|---|---|---|
| 12V enable signal | Discrete wire, not CAN. Unit stays dead without it | [A] |
0x130 CAS terminal status |
100 ms cycle. Brings the unit up | [A] |
0x1A0 DSC road speed |
20 ms cycle. Sets assist level | [A] |
| CAN bus @ 500 kbit/s | 11-bit identifiers, classic CAN 2.0 | [A] |
| A second node on the bus | See §2.3 — CAN needs someone to ACK | [A] |
Everything else the car transmits (engine data, wheel speeds, diagnostics, VIN, cluster, comfort modules) turned out to be unnecessary for steering assist. That was established by loading progressively smaller transmit sets and watching whether the unit still came up and assisted.
2. Electrical and bus setup
2.1 Power and enable
The EPS needs its main power feed and a separate 12V enable/wake signal. With power but no enable, the unit is completely inert: no CAN transmission, no assist. [A]
This creates a chicken-and-egg worth designing around: until the EPS is enabled it is not on the bus, so a lone controller has nobody to ACK its frames and every transmit fails (see §2.3). Your controller must therefore be able to assert the enable line without depending on successful CAN traffic.
2.2 Bus parameters
Bitrate 500 kbit/s
Identifiers 11-bit (standard)
Frame format classic CAN 2.0A
Termination 120 Ω at each end of the segment (~60 Ω measured across H/L, powered down)
2.3 The ACK requirement
CAN requires at least one other node to acknowledge a frame. On a bench
with only your controller and an unpowered EPS, every transmission fails
arbitration and the controller reports timeouts — with the ESP32 TWAI
peripheral this surfaces as ESP_ERR_TIMEOUT on every send. [A]
This is normal, not a fault. It resolves the moment the EPS is enabled and joins the bus. Design implication: don't gate your enable logic behind "CAN is working", and don't treat early TX failures as a hardware problem.
3. Messages you must transmit
3.1 0x130 — CAS terminal status · 5 bytes · 100 ms [A]
The message that brings the unit up. Byte 0 carries the terminal (ignition) state; the rest is supporting state plus a counter and checksum.
| Byte | Contents | Confidence |
|---|---|---|
| 0 | Terminal state — see table below | [A] |
| 1 | 0x00 when terminal is off, 0x40 in every other state |
[A] |
| 2 | Supporting flags. 9 distinct values observed, 0x21 and 0xD0 dominate |
[B] |
| 3 | Supporting flags. 9 distinct values, 0x8F/0x0F dominate |
[B] |
| 4 | Low nibble: alive counter 0–14, skips 15. High nibble: checksum | [A] / [B] |
Terminal states, all observed in the donor car:
b0 |
Meaning | Use |
|---|---|---|
0x00 |
Everything off | Resting state |
0x40 |
Terminal R (accessory) | First step of a wake-up |
0x41 |
Terminal 15 (ignition on, engine off) | Unit powers up here |
0x45 |
Engine running | Normal operating state |
0x55 |
Cranking (starter engaged) | Transient; assist inhibited |
3.2 0x1A0 — DSC road speed · 8 bytes · 20 ms [A]
Controls how much assist the unit provides — more speed, less assist, the standard speed-sensitive steering behaviour.
| Bits | Signal | Format | Confidence |
|---|---|---|---|
| b0 + low nibble of b1 | Road speed | 12-bit unsigned, 0.1 km/h per bit | [A] |
| b1 bit 7 | Standstill/validity flag — set while stationary | flag | [B] |
| b2–b5 | DSC state (all constant in the reference capture) | — | [C] |
| b6 high nibble | Alive counter | 0–14 | [B] |
| b7 | Checksum | See §6 | [B] |
Decoding, and the encoding used by gateway/eps_control.py:
speed_kmh = (b0 | ((b1 & 0x0F) << 8)) * 0.1
raw = int(round(speed_kmh / 0.1)) # 0..4095
b0 = raw & 0xFF
b1 = (b1 & 0xF0) | ((raw >> 8) & 0x0F) # keep the flag nibble
# clear bit 7 of b1 when moving, set it when stationary
4. Messages the EPS transmits
Useful for health monitoring and fault detection. None of it needs to be consumed for the unit to function.
4.1 0x1FB — alive counter · 2 bytes · ~4 Hz [A]
The cleanest health indicator available.
| Byte | Contents |
|---|---|
| 0 | High nibble constant 0xF; low nibble = alive counter, 0–14, skipping 15 |
| 1 | 0xFF constant |
If this counter stops advancing, the EPS has stopped. It is the single best signal to watch in a control loop.
4.2 0x4B0 — status heartbeat · 8 bytes · ~1 Hz [B]
| Byte | Contents | Confidence |
|---|---|---|
| 0 | Checksum — high entropy, 5 values observed | [C] |
| 1 | Low nibble counter-like; high nibble flags (0x0, 0x1, 0x4 seen) |
[C] |
| 2 | 0xFE constant |
[A] |
| 3 | Normally 0xFF; briefly 0x01 during start-up |
[B] |
| 4–7 | 0xFF constant (unused) |
[A] |
4.3 0x5B0 — power-up state · 8 bytes · event-driven [B]
Only two payloads ever observed, which makes this a clean state marker:
| Payload | Meaning |
|---|---|
01 03 80 FF FF FF FF FF |
Initialising, immediately after enable |
40 81 01 15 FF FF FF FF |
Ready / settled |
5. Bring-up and shutdown
5.1 Startup [A]
Order matters. The unit prefers to wake into a bus that is already populated, rather than into silence:
1. Begin transmitting 0x130 with b0 = 0x00 (off) and 0x1A0 at 0 km/h
→ the bus is alive before the EPS joins it
2. Wait ~1 s
3. Assert the 12V enable
4. Wait ~0.5 s → 0x5B0 "initialising" appears
5. 0x130 b0 → 0x40 (terminal R), hold ~1.5 s
6. 0x130 b0 → 0x41 (terminal 15), hold ~1.5 s
7. 0x130 b0 → 0x45 (engine running), hold ~2 s
8. Confirm 0x1FB is advancing and 0x5B0 reports "ready"
Expect the unit to answer within a few seconds of the enable. Keep transmitting continuously from step 1 — a gap is treated as sender failure.
5.2 Shutdown [B]
Walk the states back down before removing power, rather than cutting the enable outright:
1. 0x130 b0 → 0x41 (terminal 15), hold ~1 s
2. 0x130 b0 → 0x40 (terminal R), hold ~1 s
3. 0x130 b0 → 0x00 (off), hold ~1 s
4. Remove the 12V enable
5. Stop transmitting
6. Counters and checksums — the honest status
Both required messages carry an alive counter and a checksum. The counter scheme is solved; the checksums are not. This is the main open problem for a fully synthetic implementation.
6.1 Alive counters [A]
Universal on this bus: a 4-bit counter running 0 → 14, skipping 15, incrementing once per transmitted frame.
| Message | Counter location |
|---|---|
0x130 |
byte 4, low nibble |
0x1A0 |
byte 6, high nibble |
A frozen counter is read as a stale or faulty sender and the message is ignored — this was the single biggest cause of "the unit won't come up" during development. Retransmitting a captured payload verbatim does not work; the counter must advance.
6.2 Checksums — unsolved
Brute-forced across sum, one's-complement sum and XOR, every byte range, all 256 constants, with and without counter contribution:
| Message | Checksum byte | Best fit found | Match rate |
|---|---|---|---|
0x1A0 |
b7 | fold(sum(b2..b6)) + 0x22 |
87.5 % |
0x130 |
b4 high nibble | sum of nibbles(b0..b3) + counter + 0x6 |
64 % |
Neither is good enough to generate frames blind. A CRC-8 search over all 256
polynomials and seeds also found nothing (files/PTCAN_protocol.md).
One useful consequence of the 0x1A0 result: the best-fitting range is
bytes 2–6, which excludes the speed field in bytes 0–1. If that is
correct, changing road speed does not invalidate the checksum. eps_control.py
relies on this, and it holds in testing — but it is [C], so verify on
your own unit by watching 0x1FB when you move the speed slider.
6.3 The workaround that actually works [A]
Rather than synthesising checksums, replay genuine captured frames:
- For
0x130, record the car sending each terminal state and keep those frame sequences. Changing state means switching which recorded sequence you are cycling. Counters and checksums are then exactly what the car produced. - For
0x1A0, patch the speed field into a captured frame and leave bytes 2–7 untouched (per §6.2).
This is what gateway/eps_control.py does, and it drives the unit reliably.
The frame inventory available in captures/replay_car_to_eps_20260829.csv:
| Terminal state | Captured frames |
|---|---|
off (0x00) |
230 |
terminal R (0x40) |
21 |
ignition (0x41) |
121 |
engine running (0x45) |
456 |
cranking (0x55) |
8 |
For a production build, solving the checksums properly is worth the effort — the replay approach ties you to a recording, and cannot express a state the donor car never produced.
7. Timing requirements
Underestimated during development, and the cause of most of the intermittent faults seen along the way.
| Message | Period | Rate |
|---|---|---|
0x130 |
100 ms | 10 frames/s |
0x1A0 |
20 ms | 50 frames/s |
| Minimum total | 60 frames/s |
Late frames look identical to a faulty sender. A PC-hosted controller transmitting the full 69-message set needs ~1330 frames/s and struggled to sustain it, producing exactly the intermittent dropouts that looked like an EPS fault. The two-message set needs 60 frames/s — trivial for any microcontroller, and far more reliable.
Design guidance for a custom EV: generate these messages on a microcontroller with a hardware CAN controller and a timer-driven loop. Do not put a PC, USB-serial link or non-realtime OS in the path.
8. Minimum viable controller
What a standalone implementation has to do:
- Drive a 12V enable output, independently of CAN state.
- Transmit
0x130every 100 ms with the desired terminal state, an advancing counter and a valid checksum. - Transmit
0x1A0every 20 ms with the current road speed, an advancing counter and a valid checksum. - Monitor
0x1FB— if its counter stops advancing, the EPS has dropped out; re-run the startup sequence. - Follow the startup and shutdown sequences in §5.
Optional but recommended: watch 0x5B0 for the ready transition, and log
0x4B0 for post-mortem fault analysis.
9. Verification checklist
Before trusting this on a moving vehicle:
- Confirm assist actually varies with the
0x1A0speed value, across the range you intend to use. Only 0 km/h is proven from the donor car. - Confirm the
0x1A0checksum inference (§6.2) by sweeping speed and watching0x1FBfor dropouts. - Establish what the EPS does when CAN stops entirely at speed — does assist fade, or cut abruptly?
- Establish behaviour on counter/checksum errors: ignored frame, or latched fault needing a power cycle?
- Confirm whether
0x45(engine running) is required for full assist, or whether0x41(ignition) is sufficient. - Solve the checksums (§6.2) if you need states the donor car never produced.
- Check for a torque-sensor or steering-angle input requirement under load — the bench testing was done stationary.
10. Tooling in this repository
| Tool | Purpose |
|---|---|
gateway/gateway_app.py |
Four-tab UI: live relay, replay, bench, EPS control |
gateway/eps_control.py |
Minimal two-message controller — the reference implementation |
gateway/eps_bench.py |
Transmit arbitrary message sets to find what's needed |
tools/analyze_bench_session.py |
Enable-vs-online timing, dropouts, TX rate health |
tools/analyze_startup_order.py |
What preceded the unit coming online |
tools/solve_checksum.py |
Brute-force checksum solver |