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
147 lines
6.7 KiB
Markdown
147 lines
6.7 KiB
Markdown
# 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.
|