BMW_E8x_EPS/files/PTCAN_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

278 lines
12 KiB
Markdown
Raw Permalink 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 PT-CAN — decoder notes
Derived from `capture_Start-Stop.csv` (60.0 s, 26 782 frames, 72 arbitration IDs, all 11-bit).
Bus is **PT-CAN, 500 kbit/s** — the ID set (DME at 0x0A8–0x0AA, SZL at 0x0C4, DSC at 0x0CE/0x1A0,
CAS at 0x130) is the standard E8x/E9x powertrain bus that the EPS unit hangs off.
Confidence is marked per row: **[A]** proven inside this log, **[B]** strongly indicated,
**[C]** informed guess, worth checking.
---
## 1. What the log contains
| t (s) | Event | Evidence |
|---|---|---|
| 0 → 14.9 | Engine idling ~705 rpm | 0x0AA rpm field |
| 13.4 | Ignition drops to KL15 (engine commanded off) | 0x130 b0: `0x45` → `0x41` |
| 14.9 | Engine stops | 0x0AA rpm → 0 |
| 17.7 → 19.0 | Key to accessory, then off | 0x130 b0: `0x40` → `0x00` |
| **22.5 → 47.5** | **Bus asleep — zero frames on the wire** | frame histogram |
| 47.5 | Bus wakes | traffic resumes |
| 51.2 | Key in / terminal R | 0x130 b0: `0x80` → `0x40` → `0x41` |
| 55.4 → 56.3 | **Cranking** | 0x130 b0 = `0x55`, voltage sags |
| 56.3 → 60 | Running, idle settles ~705 rpm | 0x0AA |
So this is a full stop → sleep → wake → start cycle, not the auto start-stop (MSA) function.
---
One row in the capture is corrupt: line 9261 carries `timestamp_ms = 4` for ID 0x0C4, tens of
seconds out of place. One bad row in 26 782 is nothing, but sort by timestamp before computing
rates or your periods come out negative.
## 2. Two structural rules that apply to most messages
**Alive counter.** A 4-bit counter that runs `0 → 14` and **skips 15 (0xF)**. Position varies:
| ID | counter |
|---|---|
| 0x0A8, 0x0A9, 0x0AA | byte 1, low nibble |
| 0x0B6 | byte 1, full byte |
| 0x130 | byte 4, low nibble |
| 0x1A0 | byte 6, high nibble |
| 0x1B4 | byte 3, low nibble (byte 3 = `0xF0 \| cnt`) |
| 0x194, 0x1E1, 0x2F1 | byte 1, low nibble |
| 0x1FB, 0x2F3 | byte 0, low nibble |
| 0x308 | byte 0, high nibble |
**Checksum.** One's-complement sum (add bytes, fold the carry back in) of every *other* byte,
plus a constant that is unique per ID. Verified 100 % on:
```
0x1B4 b7 = ocsum(b0..b6) + 0xB6
0x0B6 b0 = ocsum(b1..b4) + 0xB7
0x194 b0 = ocsum(b1..b3) + 0x00
0x1E1 b0 = ocsum(b1..b5) + 0xE3
0x200 b7 = ocsum(b0..b6) + 0xBF
```
```python
def ocsum(bs):
c = 0
for b in bs:
c += b
if c > 0xFF:
c = (c & 0xFF) + 1
return c & 0xFF
```
For 0x0A8/0x0A9/0x0AA/0x1A0/0x19E the same formula matches 60–75 % of frames, so the algorithm is
right but something else is folded in (probably a nibble that only moves when the payload does).
0x130, 0x1D0 and 0x1A6 don't fit it at all — different scheme.
**A CRC-8 search over all 256 polynomials and all seeds found nothing**, so it isn't a CRC.
---
## 3. Decoded messages
### 0x0C4 — steering angle, SZL → DSC/EPS · 7 bytes · 10 ms **[A]**
The one to build the visualiser around, and the message the EPS actually cares about.
| bits | signal | format |
|---|---|---|
| b0–b1 | **steering wheel angle** | int16 LE, 0.04395 °/bit |
| b2 | `0xFC` constant | |
| b3–b4 | **steering wheel angular rate** | int16 LE, same unit per second |
| b5 | `0xFF` constant | |
| b6 | `0xF1` constant | |
Proof: differentiating the angle channel reproduces the rate channel with **ratio 1.010,
correlation 0.996** — so they share a scale and the pairing is certain. Range in this log is
−3813 … +3891 → about ±170°.
The 0.04395 °/bit factor (= 360/8192) is the usual BMW steering constant but it is **not proven by
this log** — the *shape* is proven, the *unit* isn't. To pin it: park, log 0x0C4, turn the wheel
exactly one full turn, and check the raw delta. 8192 → 0.04395 is right. 3600 → use 0.1.
### 0x0C8 — steering angle, slow copy · 6 bytes · 200 ms **[A]**
Same b0–b1 angle and b3–b4 rate as 0x0C4. Correlation against 0x0C4 is **1.0000**, mean absolute
difference 1.9 raw counts. b5 high nibble is the counter. Useful as a sanity check on your decoder.
### 0x0AA — DME engine speed · 8 bytes · 10 ms **[A/B]**
| bits | signal |
|---|---|
| b0 | checksum |
| b1 low nibble | alive counter |
| b4–b5 | **engine speed**, uint16 LE |
| b6 | flags: `0xF4` engine off, `0x84` cranking, `0x80` running |
| b7 | tracks load — 253 right after start, decaying to ~172 at idle, 0 when off **[C]** |
Scaling: the raw value is **always a multiple of 4**, so the real field is 14 bits (bits 2–15) with
a 0.625 rpm LSB — i.e. **rpm = raw / 6.4**. That gives idle 691–746 rpm, cranking 200–229 rpm,
peak 1181 rpm, all textbook. Some BMW references use `raw / 4` instead; that would put idle at
1125 rpm and cranking at 350 rpm, which fits the numbers much less well. Check against your
tachometer once and you'll know.
### 0x0A9 — DME, includes system voltage · 8 bytes · 10 ms **[B]**
12-bit field assembled as `(b3 >> 4) | (b4 << 4)`. It behaves exactly like electrical system
voltage and nothing else:
```
215 everything off, bus just woken
207 held flat through the whole cranking window ← starter sag
215 → 227 → 237 → 251 → 255 engine catches, alternator picks up
254–255 steady while running
```
Range across the log is 206–290. The unit needs a multimeter to pin down; `raw × 0.0555` puts
running at 14.1 V and rest at 11.9 V, which is close but the rest value reads low. b5 moves fast
and independently — a separate signal.
### 0x0A8 — DME torque · 8 bytes · 10 ms **[C]**
b2 read as int8 gives −5…+14, sitting at 0 while running and −4/−5 while stopped, which looks like
a small signed torque or torque-loss figure. b3 high nibble is a second small field. b5–b6 pinned
at `0x0C 0xCF` / `0x0D 0xCF` looks like a calibration constant. b7 ∈ {0x00, 0x02, 0x20, 0x22} —
two flag bits that track engine state.
### 0x130 — CAS terminal status · 5 bytes · 100 ms **[A]**
byte 0 is the whole story, and every value below actually occurs in this log:
| b0 | meaning |
|---|---|
| `0x00` | everything off |
| `0x40` | terminal R (accessory) |
| `0x41` | terminal 15 (ignition on, engine off) |
| `0x45` | engine running |
| `0x55` | **cranking** (starter engaged) |
| `0x80` | wake-up / key detected |
b1 follows: `0x41` awake, `0x01` off, `0x0D` transitional. b4 low nibble is the counter, high
nibble moves with it but isn't a plain sum.
### 0x1D0 — DME temperatures + fuel · 8 bytes · 100 ms **[A/B]**
| byte | reading |
|---|---|
| b0 | `0x45`/`0x46` → 21/22 °C with the standard −48 offset **[B]** |
| b1 | `0x46`/`0x47` → 22/23 °C **[B]** |
| b4–b5 | **fuel consumption accumulator**, uint16 LE **[A]** |
| b6 | `0x0D` constant |
| b7 | checksum |
The b4–b5 accumulator is the cleanest result in the log: it climbs monotonically while the engine
runs, **freezes the instant rpm hits zero**, and **resets to 0** after the DME power-cycles. Slope
at idle is **987 counts/s**. If that's 1 µL per count it works out to 3.55 L/h, which is too much
for a warm idle, so a count is probably a fraction of a µL — calibrate it against a tank fill.
Since the engine is warm here (idle ~705 rpm), b0/b1 at 21/22 °C are more likely intake air and
ambient than coolant. Log a cold start and watch: coolant will climb to ~136 raw (88 °C).
### 0x1A0 — DSC road speed · 8 bytes · 20 ms **[B]**
`speed = (b0 | ((b1 & 0x0F) << 8)) × 0.1 km/h`. Reads 0 for the whole log (stationary), with
b1 bit 7 set as a validity/standstill flag. b6 high nibble = counter, b7 = checksum.
### 0x1B4 — instrument cluster · 8 bytes · 100 ms **[A/B]**
b0 + low nibble of b1 is a second speed field (0 here, b1 = `0xC0` flags). b3 = `0xF0 | counter`,
b7 = the fully-solved checksum. **b4/b5 are warning-lamp bits** and they step exactly with the
start sequence:
```
04 30 running 12 30 cranking begins
00 30 engine off 56 75 lamps lit during/after crank
02 30 ignition on 56 76 → 46 76 → 04 30 lamps clearing
```
### 0x0CE — DSC wheel speeds · 8 bytes · 20 ms **[B]**
Four 16-bit fields, all zero for the entire log. Consistent with a stationary car; you'll need a
rolling capture to get the scale.
### 0x19E — DSC status · 8 bytes · 20 ms **[B]**
b1 = `0xE0` engine running / `0xEA` engine off; b5 = `0x00` running, `0x20` ignition-on,
`0x63` off. b2 high nibble is a **slow** counter — it steps once every ~11 frames (≈4.5 Hz), not
once per frame, so don't treat it as the usual alive counter. b7 moves by +0x10 whenever b2 does,
which is what a sum-based checksum would do.
### 0x380 — VIN · 7 bytes · ~2 s **[A]**
Seven printable ASCII bytes: the **last 7 characters of the VIN**. Your log contains `VK89782`.
Worth knowing before you post captures publicly.
### 0x1A6 — accumulator · 8 bytes · 100 ms **[C]**
`(b6 >> 4) | ((b7 & 0x0F) << 4)` is a free-running 8-bit counter that advances **+25 per frame
≈ 256 Hz**, and b0 creeps 0x0C → 0x10 over the minute, resetting after the sleep. It keeps running
with the car stationary, so it's a time base rather than distance — though 0x1A6 is normally
described as the distance-pulse message, so a rolling log would settle it.
### 0x1D6 — MFL steering wheel buttons · 2 bytes **[B]**
`FFFF` for the whole log = nothing pressed. Press each button and the bits will fall out in
one pass.
### 0x592 / 0x5A9 / 0x5C0 / 0x580 — diagnostics **[B]**
ISO-TP framing with a BMW address byte in front:
```
0x5C0: 82 10 07 21 6D 34 2B CB addr 0x82, first frame, len 7, KWP service 0x21
82 21 E2 CF 00 00 00 00 addr 0x82, consecutive frame #1
0x592: 80 10 11 10 ED 12 AC 5F addr 0x80, first frame, len 17, service 0x10
80 21 ... / 80 22 ... consecutive frames #1, #2
```
Byte 0 = address, byte 1 high nibble = ISO-TP PDU type (1 = first, 2 = consecutive), low nibble =
length or sequence. Only appear around key events — some module doing a handshake.
### 0x480 / 0x492 / 0x497 / 0x4A9 / 0x4B0 — module status **[C]**
All 8 bytes, ~470 ms, all shaped `XX 42 .. .. FF FF FF FF` with byte 0 unique per ID
(0x12 / 0x17 / 0x29 / 0x30 / 0x00). Looks like a per-module alive/status broadcast.
---
## 4. Counter and checksum only
These carry no payload that moves in this log — they're heartbeats, or their real content only
appears when the car is driving:
`0x194` (4B), `0x1E1` (6B), `0x1FB` (2B), `0x2F3` (3B), `0x2F1` (3B), `0x0B6` (5B, payload pinned
at `80 00 08`), `0x200` (8B, fully constant), `0x2B2` (8B — b4 toggles 0x00/0x10/0x20 fast and
irregularly, looks like a validity strobe).
## 5. Not yet identified
`0x135 0x195 0x1B5 0x1B6 0x202 0x21A 0x23A 0x242 0x252 0x2A6 0x2C0 0x2CF 0x2D2 0x2F6 0x2F8 0x2FA`
`0x2FC 0x308 0x310 0x31D 0x330 0x332 0x335 0x337 0x34F 0x374 0x381 0x383 0x388 0x395 0x3AC 0x3B0`
`0x3B3 0x3B4 0x3B9 0x3BE 0x5D6 0x5E0 0x5E3 0x5F2 0x5F8`
Two worth chasing:
- **0x1B6** (7 B, 50 ms) — b4 wanders 0…34 continuously with the car parked, b5 = `0xA8`/`0xAC`.
A live analogue channel on a stationary car; brake pressure or a steering torque signal are
both plausible. If it's steering torque it's the single most interesting message for EPS work.
- **0x335** (8 B, ~1 s) — several slowly drifting bytes. `0x8B` and `0xA8` with the −48 offset
would be 91 °C and 120 °C, which would suit coolant and oil on a warm engine.
## 6. Fastest ways to fill the gaps
Each of these isolates one variable, which is what makes a diff-based approach work:
1. **Parked, engine off, wheel only.** Confirms the 0x0C4 scale and separates steering from
everything else. Turn exactly one full turn each way.
2. **Parked, press every MFL button in sequence.** Cracks 0x1D6 in one log.
3. **Rolling in a straight line.** Unlocks 0x0CE wheel speeds, 0x1A0 speed, 0x1A6, and tells you
whether 0x1B6 is brake-related.
4. **Cold start to full warm-up.** Settles every temperature byte at once — the coolant byte is
the one that ends near 136 (88 °C).
5. **Idle, blip the throttle.** Separates rpm from load and torque across 0x0A8/0x0A9/0x0AA.