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