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
278 lines
12 KiB
Markdown
278 lines
12 KiB
Markdown
# 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.
|