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
338 lines
12 KiB
Markdown
338 lines
12 KiB
Markdown
# 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`:
|
||
|
||
```python
|
||
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:
|
||
|
||
1. Drive a 12V enable output, independently of CAN state.
|
||
2. Transmit `0x130` every 100 ms with the desired terminal state, an
|
||
advancing counter and a valid checksum.
|
||
3. Transmit `0x1A0` every 20 ms with the current road speed, an advancing
|
||
counter and a valid checksum.
|
||
4. Monitor `0x1FB` — if its counter stops advancing, the EPS has dropped
|
||
out; re-run the startup sequence.
|
||
5. 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 `0x1A0` speed value, across
|
||
the range you intend to use. Only 0 km/h is proven from the donor car.
|
||
- [ ] Confirm the `0x1A0` checksum inference (§6.2) by sweeping speed and
|
||
watching `0x1FB` for 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 whether `0x41` (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 |
|