BMW_E8x_EPS/eps-comms/findings.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

192 lines
10 KiB
Markdown

# EPS communication findings — running log
Newest entries at the top. Each entry: date, what was tested, what we saw,
what it means, what's still open.
For the consolidated result — how to run the EPS standalone — see
[EPS_PROTOCOL.md](EPS_PROTOCOL.md). This file is the working history.
---
## 2026-08-29 (evening) — minimum message set found: 0x130 + 0x1A0
**Result**: the EPS needs only **two** car messages, not the 69 the bus
carries: `0x130` CAS terminal status brings it up, `0x1A0` DSC road speed
sets the assist level. Plus the 12V enable on a discrete wire. Confirmed on
the bench by narrowing the transmit set.
**Two bugs found along the way, both mine, both looked like EPS faults:**
1. *Frozen counters.* A captured payload retransmitted verbatim leaves its
alive counter stuck, which every consumer treats as a stale sender. This
is why replay worked and static transmission didn't. Fixed by cycling the
captured payload **sequence** per ID rather than one frozen frame.
2. *Transmit rate starvation.* `read_frame()` used a blocking
`serial.read(64)` that could stall the transmit loop for up to 100 ms,
so the bench managed 264 frames/s against the ~1330 the full set needs -
every message arriving late, indistinguishable from a faulty sender.
Fixed by reading only buffered bytes and batching writes: 264 -> 1282
frames/s standalone. The two-message set needs just 60 frames/s, which
removes the problem entirely.
**Checksums remain unsolved.** Brute-forced sum/one's-complement/XOR over
every byte range, all constants, with and without counter contribution:
`0x1A0` b7 best fit 87.5% (fold(b2..b6) + 0x22), `0x130` b4 high nibble best
fit 64%. Not good enough to generate frames blind. Workaround in use:
replay genuine captured frames per terminal state, and patch only the speed
field of `0x1A0` (bytes 0-1, which the best-fitting checksum range excludes).
See EPS_PROTOCOL.md §6.
**EPS output messages decoded**: `0x1FB` is a pure alive counter (b0 low
nibble, 0-14, high nibble 0xF) - the best health signal available; `0x4B0`
is a status heartbeat with a likely checksum in b0; `0x5B0` has exactly two
payloads, `01 03 80…` (initialising) and `40 81 01 15…` (ready).
**Open**: assist has only been observed at 0 km/h, so the speed response
curve is unverified; behaviour on CAN loss at speed is untested; whether
`0x45` is needed or `0x41` suffices is unknown. Full list in
EPS_PROTOCOL.md §9.
---
## 2026-08-29 — clean isolated-bus test (post-fix), EPS's own IDs identified, first ruleset
**Test conditions**: same sequence as before, firmware TX-echo bug fixed
first. Engine actually had to be started this time to get the EPS working;
the dashboard EPS fault was intermittent even then. Nothing blocked yet -
fully transparent relay. Log: `captures/gateway_20260829_164540.csv` (84s,
60643 `car->eps` frames, only **37** `eps->car` frames - the fix worked).
**EPS's own traffic, isolated and confirmed for the first time**:
- `0x100` - our own CAN-IO board status (not the EPS - a test artifact, drop
before any real vehicle use).
- `0x1FB` (2 bytes) - genuinely the EPS module's own alive/counter frame.
Previously mislabeled "counter + checksum, unidentified module" in
`files/PTCAN_protocol.md`, which was written from a whole-bus capture -
now confirmed it's the EPS.
- `0x4B0` (8 bytes, `XX 01 FE FF FF FF FF FF`) - the EPS's own module-status
heartbeat. Byte 0 toggled between `0x00` and `0x30` - possibly a state or
fault-flag nibble worth watching once we can correlate it with the
cluster's fault indicator turning on/off.
**Intermittent fault - two live hypotheses, not yet distinguished**:
1. *Missing/incorrect input*: the EPS needs something we're not feeding it,
or feeding it in a state that only appears with the engine actually
running (e.g. a DME "engine actually running" flag vs. just "cranking
allowed", or a voltage/RPM value only valid once running).
2. *Relay latency/jitter*: this is a software (PC + 2x USB-serial) gateway,
which adds tens of ms of jitter per hop on top of whatever the real bus
had. 10ms-cycle messages like `0x0C4` (steering angle) arriving late
often enough could plausibly trip an EPS freshness/plausibility check
intermittently. **This is a real architectural limit of a PC-mediated
gateway** - if it turns out to be the cause, the fix is a dedicated
hardware relay/generator (no PC round-trip) once we know the required
message set, not more filtering.
Both are testable via replay (see below): replaying the known-working
`car->eps` sequence with **no live car** removes any car-side variability
and isolates the test to the gateway/EPS interaction. If the same
intermittent fault reproduces on replay, that points at (2) rather than (1).
**Digital IO was also recorded, indirectly**: the CAN-IO board reports its
own IN1-4/OUT1-2 state in its `0x100` status frame (on every change, plus a
1s heartbeat), which got logged like any other frame. Extracted with
`tools/extract_dio_timeline.py` - in this session, `IN1`/`OUT1` (the car's
12V signal and its repeat to the EPS) was high `3.539s -> 4.083s`, then
`21.794s -> 67.123s`, then off. Sparse (only 12 status samples in 84s) but
enough to know the on/off windows. `gateway/replay_to_bus.py --dio-log`
now replays this alongside the CAN traffic - **replaying CAN frames alone
would leave the EPS never seeing the 12V signal at all**, since a bench
replay has no real car to drive that input.
**Ruleset**: first best-guess filter saved to `gateway/rules.suggested.json`
(app's combined format) and `gateway/rules.suggested_car_to_eps.json` (plain
format for `replay_to_bus.py`/`FilterRules`). Blocks only what's almost
certainly irrelevant to EPS assist function - see table below. Verified by
dry-run replay: skips exactly the expected 3084 of 60643 frames.
| Blocked ID | Name | Why |
|---|---|---|
| 0x380 | VIN tail | Not needed for operation, and shouldn't be replayed/shared anyway |
| 0x1D6 | MFL steering wheel buttons | Unrelated function (audio/cruise control) |
| 0x1D0 | DME temps + fuel | Unrelated to steering assist |
| 0x1B4 | Instrument cluster (warning lamps) | Cluster's own broadcast; EPS is a lamp *source*, not consumer, of this one |
| 0x480, 0x492, 0x497, 0x4A9 | Other modules' generic status heartbeats | Not `0x4B0` - that one is the EPS's own, kept out of this list on purpose |
| 0x580, 0x592, 0x5A9, 0x5C0 | Diagnostic / ISO-TP session frames | Not part of normal operation |
Left allowed (either likely needed, or not confident enough to block yet):
steering angle/rate (`0x0C4`/`0x0C8`), road speed (`0x1A0`), wheel speeds
(`0x0CE`), DME engine speed/voltage/torque (`0xAA`/`0xA9`/`0xA8`), CAS
terminal (`0x130`), DSC status/counter/accumulator (`0x19E`/`0x0B6`/`0x1A6`),
`0x1B6` (flagged in the protocol notes as a possible steering-torque
signal - do not block without testing specifically), and everything still
unidentified.
**Next steps**:
1. Replay `captures/replay_car_to_eps_20260829.csv` (the exact car->eps
traffic from this working session) onto the EPS bus alone, car
disconnected, with `gateway/rules.suggested_car_to_eps.json` applied and
`--dio-log captures/dio_20260829.csv` so the 12V signal repeat is
reproduced too. Confirm the EPS still comes up the same way from replay
as it did live.
2. If the intermittent fault reproduces identically on replay, that's
evidence for the jitter hypothesis over a missing-message hypothesis.
3. Iterate the ruleset: block one more plausible-non-essential group at a
time (start with `0x1B4`'s replacement candidates, `0x0B6`, `0x1A6`),
re-replay, watch for the fault changing character.
4. Once a minimal set is confirmed stable, start the synthesis plan (see
README.md) - a generator that produces just those IDs from scratch.
---
## 2026-08-29 — first gateway sessions, discovered a firmware bug (not a bus-sharing issue)
**Test conditions**: car + EPS both wired through the gateway (CANdapter on
car PT-CAN, CAN-IO board on the EPS's own bus). Two sessions:
- `gateway_20260829_162119.csv` (~21s) - no `eps->car` traffic at all.
- `gateway_20260829_162235.csv` (~227s) - ignition on, engine off, then car
fully off. EPS "started working" with ignition on (but showed its usual
dashboard error - apparently normal without the engine running) and did
not start with the car fully off.
**What the data showed**: in the second session, 69 unique arbitration IDs
appeared in the `eps->car` direction; 64 of them were IDs the gateway had
*just* relayed `car->eps` moments earlier (recognisable car ECU messages:
`0xA8/0xA9/0xAA` DME, `0xC4/0xC8` SZL steering, `0xCE/0x19E/0x1A0` DSC,
`0x130` CAS). Only `0x100` (the board's own status frame) was genuinely
native to the EPS bus.
**Initial hypothesis (wrong)**: that the car and EPS buses were still
electrically the same bus. Ruled out - confirmed the EPS bus is physically
isolated, and the car/EPS ports used in the test were correct and distinct
(`/dev/cu.usbserial-DNBJV4F5` vs `/dev/cu.usbmodem101`).
**Actual cause (fixed)**: a firmware bug. The CAN-IO board mirrors its own
CAN transmissions back over the USB bridge so the PC can see frames the
board originates locally (status/heartbeat), since TWAI has no RX loopback.
That mirroring was too broad - it also fired for frames the *gateway* asked
the board to relay from the car bus, so every relayed ID echoed straight
back over USB looking exactly like new incoming EPS-bus traffic, which the
Python gateway then dutifully relayed back to the car bus (a real feedback
loop, not just a logging artifact). Fixed by tagging TX-queue entries with
whether they originated on the board itself (mirror) or came from the
bridge (don't mirror) - see `can-io/firmware/src/can_bus.{h,cpp}` and
`usb_bridge.cpp`, `can_send(msg, mirror_on_success)`.
**Conclusion**: both sessions captured before this fix are unreliable for
answering "what does the EPS bus actually carry" - re-test needed.
**Next test to run**: repeat the ignition-on / engine-off / car-off sequence
now that the firmware is fixed, and check whether `eps->car` traffic now
shows only genuinely EPS-native messages (plausibly very little beyond
`0x100`, until the EPS module itself responds to something).
**Open questions**:
- What does the EPS module transmit on its own bus, if anything, once it's
awake? (Could not tell from data so far - contaminated by the bug above.)
- Is a wake/terminal signal (e.g. the 12V line into the CAN-IO board's
input) required in addition to CAN traffic, or does CAN alone bring the
EPS up? Not yet tested independently.
- No messages have been blocked yet in `gateway_app.py` - full transparent
relay only so far.