BMW_E8x_EPS/can-io/README.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

6.7 KiB
Raw Permalink Blame History

CAN IO Board — XIAO ESP32-S3

A small CAN bus IO node: 4 optocoupler inputs, 2 relay outputs, built on a Seeed Studio XIAO ESP32-S3 with an SN65HVD230 transceiver and the ESP32's built-in TWAI controller (driver/twai.h).

  • Reports input/output state on the bus (heartbeat + immediately on change)
  • Outputs controllable over CAN (set / set-all / toggle)
  • Local set/reset rules: IN1→OUT1 on, IN2→OUT1 off, IN3→OUT2 on, IN4→OUT2 off
  • Direct passthrough: an input can continuously mirror onto an output (e.g. repeating the car's 12V wake signal straight through to another unit) - see PASSTHROUGH_MAP in config.h
  • Optional USB-CAN bridge: mirrors every frame on the bus over USB serial as SLCAN ASCII, and injects frames sent back - turns the board into a transparent adapter for its own bus on top of its IO duties. See USB_BRIDGE_ENABLE in config.h and firmware/src/usb_bridge.cpp.
  • Full frame formats: see PROTOCOL.md
can-io-board/
├── firmware/          PlatformIO project (Arduino framework + FreeRTOS)
│   ├── include/config.h   ← everything tweakable lives here
│   └── src/               ← one module per concern, documented headers
├── gui/               Python/Tkinter tool for an SLCAN adapter
├── PROTOCOL.md        CAN frame reference
└── README.md          this file

Hardware

GPIO XIAO pin Function Notes
1 D0 / A0 CAN TX → SN65HVD230 D
2 D1 / A1 CAN RX ← SN65HVD230 R
3 D2 / A2 IN1 optocoupler, active low
4 D3 / A3 IN2 optocoupler, active low
5 D4 / SDA IN3 optocoupler, active low
6 D5 / SCL IN4 optocoupler, active low
43 D6 OUT1 darlington → relay 1
44 D7 OUT2 darlington → relay 2
21 — LED on-board user LED, heartbeat

Electrical notes:

  • Inputs use internal pull-ups; the opto transistor pulls the pin to GND when the external contact/button closes. Debounce is done in software (40 ms). If your optos drive the pin high instead, set INPUTS_ACTIVE_LOW 0 in config.h.
  • Outputs are active high into the darlington (OUTPUTS_ACTIVE_HIGH 1).
  • SN65HVD230 runs at 3.3 V — direct connection, no level shifting. Remember bus termination (120 Ω at both bus ends).

Boot behaviour of GPIO43/44 (worth knowing!)

GPIO43/44 are UART0 TX/RX. The application never uses UART0 (the console is native USB CDC), but the ROM bootloader briefly drives GPIO43 as UART TX at every reset: boot messages plus an idle-high level until the firmware reclaims the pin (well under a second). Depending on your darlington input network, relay 1 may click briefly at power-up, and a pull-up on GPIO44 could do the same for relay 2.

Mitigations if it bothers you: a ~10 kΩ pull-down on each darlington input helps GPIO44; GPIO43 is actively driven, so if the short pulse is a real problem, move the outputs to free pins (e.g. GPIO7/8, D8/D9) — it's a two-line change in config.h.

Building & flashing

cd firmware
pio run                 # build
pio run -t upload       # flash over USB
pio device monitor      # logs via native USB CDC, 115200

Firmware architecture

Plain Arduino framework + FreeRTOS primitives. setup() only wires modules together; everything runs in tasks:

Task File Prio Purpose
input io_input.cpp 8 5 ms scan, integrator debounce (8 samples)
output io_output.cpp 8 sole owner of relay pins, fed by a queue
can_rx can_bus.cpp 10 receive + dispatch command frames
can_tx can_bus.cpp 9 transmit queued frames
can_alert can_bus.cpp 9 bus health, automatic bus-off recovery
status status.cpp 5 heartbeat + event status frames

FreeRTOS plumbing: a queue into the output task (single GPIO writer, no races), a queue into the CAN TX task, an event group driving the status reporter (change/request/boot bits; the wait timeout doubles as the heartbeat timer), and a mutex around the shared IO state. CAN tasks are pinned to core 0, IO tasks to core 1.

Behaviour details:

  • Input rules fire on the activation edge only; releasing a button does nothing, so outputs latch (set/reset stations).
  • Inputs already active at boot are reported but do not fire rules.
  • Relays are forced OFF first thing in setup().
  • Every accepted change (CAN or local) is confirmed by an immediate status frame — the bus is always the source of truth.
  • Bus-off (e.g. shorted CAN lines) recovers automatically.

All tunables — pins, polarities, debounce, IDs, bitrate, rules, priorities — are in firmware/include/config.h.

USB bridge mode

With USB_BRIDGE_ENABLE 1 (the default), the board's USB serial doubles as a plain SLCAN adapter for its own bus: every frame it receives is mirrored out as t/T + id + dlc + data (no trailing timestamp), and the same format sent back is injected onto the bus. This is what lets a PC-side tool treat the board as a transparent gateway node - see adapters/slcan_adapter.py and gateway/ at the project root.

While the bridge is on, all human-readable debug logging is compiled out (DEBUG_LOG() in config.h) so it can't corrupt the frame stream. Set USB_BRIDGE_ENABLE 0 and reflash to get plain-text logs back for local debugging with pio device monitor.

GUI (SLCAN)

Tkinter tool that talks to the board through any SLCAN adapter (e.g. your second ESP32 running slcan firmware).

cd gui
pip install -r requirements.txt
python can_io_gui.py

Pick the adapter's serial port, keep 500000 bit/s and base ID 0x100, Connect. You get live LEDs for IN1–4 / OUT1–2, On/Off/Toggle buttons per output, a status request button and a decoded frame log. "STALE" in the status bar means no status frame for >3.5 s (board off / bus problem).

Troubleshooting

  • No frames at all: check transceiver RS pin (pin 8) — tie to GND (or ≤10 kΩ) for high-speed mode; check termination and TX/RX not swapped.
  • TX failed / error-passive logs: usually a bitrate mismatch or a one-node bus (CAN needs a second node to ACK, the SLCAN adapter counts).
  • GUI can't open the port: close other serial monitors; on Linux add yourself to the dialout group.
  • Relay clicks at power-up: see "Boot behaviour of GPIO43/44" above.