# 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.