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
6.7 KiB
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_MAPinconfig.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_ENABLEinconfig.handfirmware/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 0inconfig.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
dialoutgroup. - Relay clicks at power-up: see "Boot behaviour of GPIO43/44" above.