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

12 KiB
Raw Blame History

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