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
12 KiB
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
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.
0x8Band0xA8with 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:
- Parked, engine off, wheel only. Confirms the 0x0C4 scale and separates steering from everything else. Turn exactly one full turn each way.
- Parked, press every MFL button in sequence. Cracks 0x1D6 in one log.
- Rolling in a straight line. Unlocks 0x0CE wheel speeds, 0x1A0 speed, 0x1A6, and tells you whether 0x1B6 is brake-related.
- Cold start to full warm-up. Settles every temperature byte at once — the coolant byte is the one that ends near 136 (88 °C).
- Idle, blip the throttle. Separates rpm from load and torque across 0x0A8/0x0A9/0x0AA.