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