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

147 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](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
```bash
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).
```bash
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.