No description
  • C++ 98.4%
  • Python 1.1%
  • Shell 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Robert Marshall d47248450f
All checks were successful
Build and test / build (push) Successful in 1m52s
Build and test / release (push) Successful in 2m35s
Merge pull request 'Store bathroom night brightness as a whole percentage' (#72) from bathroom-night-brightness into main
Reviewed-on: #72
2026-09-25 06:07:37 +00:00
.forgejo/workflows Check the build id on the images the release publishes 2026-09-20 12:16:34 +01:00
docs Store bathroom night brightness as a whole percentage 2026-09-25 06:58:53 +01:00
lib Store bathroom night brightness as a whole percentage 2026-09-25 06:58:53 +01:00
scripts Build the release images with the version stamped in 2026-09-20 12:09:15 +01:00
src Store bathroom night brightness as a whole percentage 2026-09-25 06:58:53 +01:00
test Store bathroom night brightness as a whole percentage 2026-09-25 06:58:53 +01:00
tools Pack the CalVer release into the firmware version field 2026-09-20 12:04:24 +01:00
vectors Pack the CalVer release into the firmware version field 2026-09-20 12:04:24 +01:00
.clang-format Formatting 2026-09-04 17:11:05 +01:00
.editorconfig The whole point of using tabs is to not enforce and indent size; it should be up to the renderer 2026-09-05 21:16:38 +01:00
.gitignore Check the build id on the images the release publishes 2026-09-20 12:16:34 +01:00
CLAUDE.md Document the kitchen node in README and CLAUDE.md 2026-09-22 07:57:47 +01:00
CONTRIBUTING.md Build the release images with the version stamped in 2026-09-20 12:09:15 +01:00
platformio.ini Add NODE_WATER_HEATER (0x41) firmware 2026-09-22 10:33:36 +01:00
README.md Add NODE_WATER_HEATER (0x41) firmware 2026-09-22 10:33:36 +01:00

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 = true in their PwmConfig). 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 why LevelEndpoint::begin drives the off level with digitalWrite before 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 touchSetCycles lengthens 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 for millis() 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_HOLD and STATE_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 BULK transfer. A node reassembles one, and takes session WiFi credentials from it; content type 0x01 is not implemented.
  • DIAG, alarms, and the CCT and heater message families.