No description
  • C++ 95.1%
  • Python 4.6%
  • C 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Robert Marshall 23f3893632
All checks were successful
Build and test / build (push) Successful in 35s
Build and test / release (push) Successful in 4s
Merge PR #6: State that the protocol knows nothing about its consumers
2026-09-05 21:00:41 +00:00
.forgejo/workflows Don't attach debug symbols to release to save on space 2026-09-05 09:47:44 +01:00
docs State that the protocol knows nothing about its consumers 2026-09-05 21:59:22 +01:00
lib Reduce duplicated annoument code 2026-09-04 17:37:09 +01:00
scripts Release automatically from main with CalVer versioning 2026-09-04 18:18:11 +01:00
src Flesh out the lighting node based on the existing controller in the van 2026-09-04 17:33:24 +01:00
test Flesh out the lighting node based on the existing controller in the van 2026-09-04 17:33: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 Release automatically from main with CalVer versioning 2026-09-04 18:18:11 +01:00
CLAUDE.md State that the protocol knows nothing about its consumers 2026-09-05 21:59:22 +01:00
CONTRIBUTING.md Record that a second implementation of the wire format exists 2026-09-05 20:11:07 +01:00
platformio.ini Flesh out the lighting node based on the existing controller in the van 2026-09-04 17:33:24 +01:00
README.md Add CONTRIBUTING.md with conventions and allocation process 2026-09-05 13:18:59 +01:00

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, D8D10 MCP2515 chip select, SPI

The bathroom node has D2D6 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 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_assert against SOC_LEDC_TIMER_BIT_WIDE_NUM fails 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 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.

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 for millis() 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_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.
  • WiFi, OTA, BULK, DIAG, alarms, and the CCT and heater message families.