BMW_E8x_EPS/eps-comms/EPS_PROTOCOL.md
Luca c32c4645b5 Reverse-engineer BMW E8x EPS for standalone operation
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
2026-08-29 19:34:43 +02:00

338 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |