- C++ 98.4%
- Python 1.1%
- Shell 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| docs | ||
| lib | ||
| scripts | ||
| src | ||
| test | ||
| tools | ||
| vectors | ||
| .clang-format | ||
| .editorconfig | ||
| .gitignore | ||
| CLAUDE.md | ||
| CONTRIBUTING.md | ||
| platformio.ini | ||
| README.md | ||
Campervan Control Systems
Modular control systems for a campervan, communicating over a dedicated CAN bus at 250 kbps. Most controllers are Seeed Studio XIAO boards carrying a XIAO CAN Bus Expansion Board (MCP2515 controller, SN65HVD230 transceiver); see Controllers for the ones that are not.
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.
docs/bus-diagnostics.md Reading a node's [bus] serial output when it has
gone quiet.
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.
src/host_sim/ The runtime as a Linux binary on SocketCAN, so a
consumer has a real node to talk to with no hardware.
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
pio run -e host_sim # build the host simulator
Every node builds against the min_spiffs.csv partition table, which gives each
of the two OTA app slots 1.875 MB instead of the stock default.csv 1.25 MB. A
board last flashed before that change is still on the old layout and needs one
cabled -t upload to move over: the partition table cannot be changed over the
air.
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. The
runtime's general paths are host-buildable for the same reason, so Node and
Endpoint are compiled and linked there too; only the endpoints that drive
hardware compile to nothing.
Running a node on the host
src/host_sim runs the real runtime against a SocketCAN interface, so Home
Assistant or any other consumer can be developed against a node that answers,
with nothing wired up.
scripts/host_sim.sh # or: scripts/host_sim.sh vcan1 -- --node 0x12
The script brings vcan0 up if it is not already, builds, runs, and on the way
out — including on Ctrl-C — removes the interface, but only if it was the one
that created it. Anything that was already there is left in place, since it is
probably carrying something else. The simulator itself takes --interface,
--node, --mute and --tx-delay.
It announces as NODE_LIGHTING (0x10) with that node's own endpoints: five
dimmers, the touch pad and its enable, at the same indices and with the same
fade timings and curves, so a STATE_LEVEL mid-fade carries the values the real
node would send. Index 7 goes beyond it — a plain switch with a failsafe of
off, so a consumer's STATE_SWITCH and failsafe paths have something to talk
to as well.
Borrowing the id is what makes a consumer's entities look like the ones it will
meet in the van, and the cost is not running this alongside the real lighting
node. 0x01 is refused outright: that is the Raspberry Pi.
Only the outputs are simulated. Descriptor emission, the manual lockout, origin
precedence, state reporting, announce, heartbeat and peer tracking all come from
ctrl::Node and ctrl::Endpoint, which is the whole point — a scripted
responder would be a third implementation of this bus, and unlike the codecs,
which are cross-checked field by field against the shared vectors, node
behaviour has nothing to catch it drifting.
pio test -e native compiles and links the SocketCAN backend, so CI keeps that
building. src/host_sim/main.cpp is not built by CI at all — run
pio run -e host_sim before relying on it. Neither the backend nor the
simulator has automated behavioural coverage, since both need a live socket and
CI has no vcan; the endpoints under sim_endpoints.h are covered by the native
tests.
Type into it while it runs:
| Command | Effect |
|---|---|
mute / unmute |
Stop and resume transmitting. The node carries on as normal and is simply not heard, which is what makes a consumer's optimistic-update revert testable. |
delay <ms> |
Hold every outgoing frame this long, in order. An answer that is late rather than absent. Muting discards what is still queued, so a silenced node stays silent. |
manual <index> |
Act as a person at that circuit: ORIGIN_MANUAL, so the ten second lockout starts and a consumer can be shown losing to it. |
press [ms] |
Press the wall plate for that long, 200 ms by default: EVT_BINARY pressed, three circuits toggled locally as the lighting node's own policy does it, then released carrying the hold duration. With endpoint 6 set to 0 the events still go out and the circuits do not move, which is the node's behaviour too. |
status |
What the endpoints and the link are doing. |
Every socket on a SocketCAN interface sees every frame, which a CAN controller
never does. CAN_RAW_RECV_OWN_MSGS would hand a node its own transmissions
back; it defaults to off and the backend sets it explicitly. The backend also
drops any frame carrying its own source address, covering the case that option
does not: a second process left running at the same node id.
Controllers
The fleet is not uniform. Most controllers are XIAO ESP32C3 boards with the CAN
expansion board; the lighting node is a LOLIN32, the kitchen node is a XIAO
ESP32S3, and NODE_BRIDGE is a Raspberry Pi.
| Node | ID | Environment | Board | CAN |
|---|---|---|---|---|
| Lighting, dimmable ceiling circuits | 0x10 |
node_lighting |
LOLIN32 (ESP32) | native TWAI + WCMCU-230 |
| Kitchen, under-cabinet strip and touch | 0x11 |
node_kitchen |
XIAO ESP32S3 | MCP2515 over SPI |
| Bathroom, PIR and extractor | 0x12 |
node_bathroom |
XIAO ESP32C3 | MCP2515 over SPI |
| Water heater, immersion element switching | 0x41 |
node_water_heater |
XIAO ESP32C3 | MCP2515 over SPI |
NODE_BRIDGE (0x01) is a Raspberry Pi sitting on the bus, running Home
Assistant and the van's display. It has no firmware here; its code lives in
its own repository. 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 three implementations behind one interface, selected by
CTRL_CAN_MCP2515, CTRL_CAN_TWAI or CTRL_CAN_SOCKETCAN 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 |
| Kitchen | D0 | Touch plate |
| Kitchen | D1 | PWM, under-cabinet strip |
| Kitchen | D7, D8–D10 | MCP2515 chip select, SPI |
| Bathroom | D0 | PIR input |
| Bathroom | D1 | PWM, extractor fan |
| Bathroom | D7, D8–D10 | MCP2515 chip select, SPI |
| Water heater | D1 | Immersion element relay |
| Water heater | 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 frequency and resolution are per-endpoint, via
PwmConfig. The default is 1 kHz, with resolution following the SoC: 15-bit on the lighting node's ESP32, matching the original, and 14-bit on a XIAO. The lighting node sets 490 Hz on every zone, and the bathroom extractor runs at 25 kHz, 8-bit.
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.
If the noise wins anyway, endpoint 6 is a stored on/off that takes the pad out
of the circuit. Only the local callbacks stop: the pad at endpoint 5 keeps
broadcasting EVT_BINARY, so a false trigger is still visible while the toggle
is off.
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.
A wiring fault is silent on the bus, because a node that cannot transmit cannot
report that it cannot transmit. Each node prints bus faults to its serial
monitor instead, prefixed [bus], and the lighting node adds its controller's
error counters while the bus is down.
docs/bus-diagnostics.md covers what each line and field means, and which signature points at which fault.
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. Talks to hardware, though its general paths build on a host so a node can be run off-hardware; the endpoints that drive pins are the Arduino-bound part.
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.
- Sending a
BULKtransfer. A node reassembles one, and takes session WiFi credentials from it; content type0x01is not implemented. DIAG, alarms, and the CCT and heater message families.