- C++ 95.1%
- Python 4.6%
- C 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| docs | ||
| lib | ||
| scripts | ||
| src | ||
| test | ||
| .clang-format | ||
| .editorconfig | ||
| .gitignore | ||
| CLAUDE.md | ||
| CONTRIBUTING.md | ||
| platformio.ini | ||
| README.md | ||
Campervan Control Systems
Modular control systems for a campervan, built from Seeed Studio XIAO ESP32C3 controllers each carrying a XIAO CAN Bus Expansion Board (MCP2515 controller, SN65HVD230 transceiver), communicating over a dedicated CAN bus at 250 kbps.
docs/can-protocol.md is the authority for all firmware. If code and that document disagree, the document is wrong and gets fixed in the same pull request as the code.
Layout
docs/can-protocol.md Protocol specification. Read this first.
lib/can-protocol/ Wire protocol: IDs, codecs, units, registries.
Header-only, no Arduino dependency, so it is testable
off-hardware. Keep it that way.
lib/node-runtime/ Shared controller runtime: bus handling, endpoint
abstraction, manual lockout, failsafe, peer liveness.
src/<node>/main.cpp One directory per controller. build_src_filter keeps
each node's main out of the others' firmware.
test/ Native tests, run on the host with no hardware.
Building
pio run # build every node
pio run -e node_lighting # build one node
pio run -e node_lighting -t upload
pio device monitor
pio test -e native # run the test suite on the host
Continuous integration
.forgejo/workflows/build.yml runs the tests and builds every node. Pull
requests build and test but publish nothing; every push to main publishes a
CalVer release (YYYY.MM.<nth build that month>) as both a Forgejo release and
a campervan-firmware package. CONTRIBUTING.md
covers how that works and why.
The native environment exists because lib/can-protocol has no Arduino
dependency. That constraint is the point, not a side effect: it is what allows
every codec and scaling helper to be tested without a board on the desk.
Controllers
The fleet is not uniform. Most controllers are XIAO ESP32C3 boards with the CAN expansion board; the lighting node is a LOLIN32.
| Node | ID | Environment | Board | CAN |
|---|---|---|---|---|
| Lighting, dimmable ceiling circuits | 0x10 |
node_lighting |
LOLIN32 (ESP32) | native TWAI + WCMCU-230 |
| Bathroom, PIR and extractor | 0x12 |
node_bathroom |
XIAO ESP32C3 | MCP2515 over SPI |
Every other node in the registry (lib/can-protocol/src/node_ids.h) is allocated
but not yet implemented, and defaults to the XIAO.
Why the lighting node is different
The ESP32-C3 cannot do what this node needs. It has 7 free GPIOs against the 8 the dimming circuits use, 14-bit LEDC against the 15 the original was tuned at, and no capacitive touch peripheral at all, which the wall controls depend on. The classic ESP32 has 20+ free GPIOs, 20-bit LEDC and 10 touch channels, and its own TWAI controller needs only a transceiver rather than an SPI CAN controller. Existing wiring and pin numbers carry over untouched.
CanBus has two implementations behind one interface, selected by
CTRL_CAN_MCP2515 or CTRL_CAN_TWAI in the environment's build flags. The
protocol library, the runtime above CanBus, and the node firmwares are all
unaware of which is in use.
One consequence: TWAI has a single acceptance code and mask, which cannot express "addressed to me, or these broadcast classes". The TWAI backend accepts everything and filters in software, so the lighting node simply sees more traffic than a XIAO node does. Both are correct — hardware filtering is an optimisation, never correctness.
Pin assignments
| Node | Pin | Function |
|---|---|---|
| Lighting | 16, 17 | PWM, seating (one LEDC channel, two runs) |
| Lighting | 5, 18, 23 | PWM, kitchen (one channel, three runs) |
| Lighting | 19 | PWM, bathroom |
| Lighting | 22 | PWM, awning |
| Lighting | 4 | PWM, cab |
| Lighting | 33 | Capacitive touch plate (T8) |
| Lighting | 25, 26 | TWAI transmit and receive to the WCMCU-230 |
| Bathroom | D0 | PIR input |
| Bathroom | D1 | PWM, extractor fan |
| Bathroom | D7, D8–D10 | MCP2515 chip select, SPI |
The bathroom node has D2–D6 spare. Any XIAO node is limited to seven usable
pins once SPI and chip select are accounted for.
Circuit behaviour
Carried over from the previous generation of this system
(rob/vancontrolsystems), where it was tuned against the actual fittings. Two
details are load-bearing:
- Seating, kitchen and bathroom run active-low drivers (
invert = truein theirPwmConfig). Zero duty on those channels is fully on. Getting this backwards makes a circuit blaze at full brightness whenever it is commanded off, including at every reset before LEDC takes the pin — which is whyLevelEndpoint::begindrives the off level withdigitalWritebefore attaching the pin. - PWM is 490 Hz. Resolution follows the SoC: 15-bit on the lighting node's
ESP32, matching the original, and 14-bit on a XIAO. A
static_assertagainstSOC_LEDC_TIMER_BIT_WIDE_NUMfails the build rather than letting an over-wide value fail at runtime and leave a circuit dark.
Touch sensing
The wall plate toggles seating, kitchen and cab together. TouchSensor is
ported from the previous generation, where it was developed against two real
problems in the van:
- Cold weather collapses the raw counts toward a quantisation floor, so a
fixed threshold stops working. Readings are oversampled eight times to recover
the lost resolution, and
touchSetCycleslengthens each measurement. - The inverter and DC-DC charger inject broadband noise. The sensor tracks a rolling mean and variance and thresholds on a z-score, so it adapts to a changing noise level rather than only to baseline drift. The baseline is frozen while touched or during a candidate spike, so a held finger cannot drag the resting baseline toward itself.
Two things to know when tuning. touchValueDecreases is true for Arduino-ESP32
core 2.x, where a touch lowers the raw reading; core 3.x reverses that, and
getting it wrong means the pad never triggers. And a touch commands its circuits
with Origin::Manual, which starts the ten second lockout — that is what stops
the bathroom PIR or Home Assistant undoing a deliberate press a moment later.
TouchSensor and TouchInput compile to nothing where SOC_TOUCH_SENSOR_NUM
is zero, so the runtime still builds for the XIAO nodes, and the header raises a
clear #error if one of them ever includes it.
Bus wiring
Most WCMCU-230 boards ship with a 120 ohm termination resistor fitted permanently rather than on a jumper. Correct for a two-node bench test, wrong for a full bus: only the two nodes at the physical ends should be terminated. Check the board before wiring a third node in.
Namespaces
Two, and the split is deliberate:
can::— the wire protocol. No Arduino dependency, so it can be tested on the host. Reaching formillis()in here breaks the native test environment.ctrl::— the control runtime. Arduino-bound, talks to hardware.
The namespace boundary is the reminder of which side of that line you are on.
Contributing
CONTRIBUTING.md has the conventions, the branch and pull request rules, and the process for allocating a node ID or an endpoint index so that allocations cannot collide.
Not yet implemented
Tracked in Vikunja under Campervan Control Systems. The significant gaps in what is here today:
CMD_HOLDandSTATE_MOMENTARY, the hold-to-run mechanism every momentary actuator depends on. Nothing in these two nodes uses it, but the air suspension valves cannot be built without it.- Interrupt-driven receive. The runtime polls, which keeps the interrupt pin out of the picture until the expansion board wiring is confirmed.
- Transmit queue with priority ordering; frames currently go straight out.
- WiFi, OTA,
BULK,DIAG, alarms, and the CCT and heater message families.