No description
  • Python 94.9%
  • JavaScript 2.8%
  • CSS 1%
  • HTML 0.7%
  • Shell 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Claude Code 0ab0d6b9f9
All checks were successful
Lint, type check and test / hassfest (push) Successful in 11s
Lint, type check and test / quality (push) Successful in 1m48s
Lint, type check and test / release (push) Successful in 3s
Add semantic class 0x2C percentage in whole percent (#87)
2026-09-25 06:16:51 +00:00
.forgejo/workflows Take the review: a cut that loses the reason is a cut too far 2026-09-20 07:20:44 +01:00
custom_components/campervan Add semantic class 0x2C percentage in whole percent (#87) 2026-09-25 06:16:51 +00:00
ha-data/config/custom_components Move explination of .gitkeep existence in to .gitkeep file so it's easier to find out why it's there 2026-09-06 18:10:49 +01:00
mqtt-data/config Remove unecessary persistence and auto save to prevent wear on SD card 2026-09-21 08:03:19 +00:00
tests Add semantic class 0x2C percentage in whole percent (#87) 2026-09-25 06:16:51 +00:00
tools Add semantic class 0x2C percentage in whole percent (#87) 2026-09-25 06:16:51 +00:00
updater Don't let an unreachable radio restart-loop the updater 2026-09-21 10:30:09 +01:00
.editorconfig Cut comments back to the ones that earn their place 2026-09-06 21:52:13 +01:00
.gitignore Add the updater service beside Home Assistant 2026-09-19 08:27:17 +01:00
CLAUDE.md Take the review: split the updater paragraph so it reads in one pass 2026-09-21 07:07:14 +01:00
compose.yaml Take the review: survive an endpoint with no host, and say its shape 2026-09-20 11:57:44 +01:00
pyproject.toml Still require a docstring on a module and a class 2026-09-20 21:01:47 +01:00
README.md Predict a decline before offering firmware a node already runs 2026-09-21 07:25:38 +01:00
requirements-dev.txt Take the review: pin the CI variable to the step that uses it 2026-09-19 21:29:08 +01:00
rpi-setup.md Say where the updater's CAN interface comes from 2026-09-20 09:05:52 +01:00

Campervan CAN bus

A Home Assistant custom integration for a campervan's CAN bus.

A Raspberry Pi running Home Assistant Container sits on the bus as node 0x01, talking to an MCP2515 module through SocketCAN. It discovers the van's controllers, presents them as devices and entities, and later commands them.

The controllers themselves, and the wire protocol they speak, live in CampervanControlSystems. docs/can-protocol.md there is the authority for everything on the wire; this repository is the second implementation of it, and when the two disagree the specification decides.

Status

The integration opens a named SocketCAN interface, reads it, and hands what arrives to a coordinator that anything else can subscribe to. It asks the van to describe itself with QRY_DESCRIPTORS whenever the bus comes up, holds what answers as an inventory of nodes and their endpoints, and turns that into one device per node and one entity per endpoint, as they are discovered rather than only at startup. An endpoint the firmware labels with SYS_ENDPOINT_LABEL takes that label as its name, and one without a whole label is named from what it is. A node that labels itself, at index 0xFF, names its device the same way, and one that doesn't is named from its node's constant name. A name given in Home Assistant wins over any of these.

Outputs appear at the platform they will always be at — a dimmer is a light — and can be asked to change: CMD_SWITCH for on, off and toggle, CMD_LEVEL for a level, both carrying ORIGIN_REMOTE and held inside the range the endpoint declared. A command is a request and there are no acknowledgement frames, so what was asked for shows straight away and gives way to the endpoint's own next broadcast, whatever it says; a request nothing answers inside a second and a half is dropped rather than left on screen. A level endpoint reads as the target its own frame names rather than where its output has got to, so a fade climbs to the asked-for level once instead of chasing it. An endpoint that never said it was writable is shown but never asked. A node refuses a command someone in the van has just beaten without saying so, so an endpoint whose state frame names an origin carries who last changed it, and when its ten second manual lockout ends while one stands. A toggle snapping back then says who holds the endpoint rather than nothing at all. Nothing hold-to-run appears at all: CMD_HOLD and STATE_MOMENTARY are not in the firmware, so a valve or a step motor is skipped rather than shown as something it is not.

The whole van can be asked to behave differently in one frame. SYS_MODE carries normal, night, lights out, away and travel, from a select on the bridge's own device, because a mode belongs to the bus rather than to any endpoint. Nothing acknowledges one and each node decides for itself what it means, so what is shown is the last mode broadcast — by Home Assistant or by any other node, with who asked for it beside it — rather than the mode the van is in. An outage clears it: anything broadcast through the outage went unheard, and nothing on the bus repeats a mode or asks what the current one is.

Shutdown is not in that list. It drives every output to its unattended state and takes lighting that would otherwise hold with it, so an accident costs you the lights in a van at night. It is an action instead, campervan.shutdown, which broadcasts nothing unless it is passed the word SHUTDOWN: a stray tap cannot produce a word, and an automation has to spell it out.

The Pi is a node on the bus rather than a listener on it. It announces itself as NODE_BRIDGE when the interface comes up and broadcasts a heartbeat at 1 Hz, so a controller tracks Home Assistant with the same peer liveness it uses for every other node, and can tell it being down from it being quiet. It owns no endpoints, so it announces none.

The bus coming and going is ordinary rather than exceptional, so the connection looks after itself. Two failures are told apart, because the answer to each differs: the interface going down means the socket is reopened until that works, while the controller going bus-off leaves the socket alone and reports the bus unavailable until it says it has restarted. Neither needs a Home Assistant restart.

What the Pi can hear of the bus gets a device of its own — the bridge, node 0x01 — carrying diagnostic entities: frames per second over a window, the nodes heard from and how many have since gone quiet, and two counts kept apart because they mean opposite things. An unknown message type is the unversioned bus working as designed; a decode failure is not. Beside them is a frame log, off at every start, which writes every frame the Pi hears to the log in candump form for as long as it is switched on.

Every node that announces itself also gets a firmware entity, on the same device as its endpoints. The two versions it shows come from opposite ends. What is installed is the bus's answer, from SYS_ANNOUNCE; what is available is the updater's, and the updater only lists images whose bytes are already on disk, so a version showing there can be had now. Both are releases — a node packs its CalVer into the version field — so an image is offered only where it is actually newer, on the packed value rather than on the text. A node with nothing cached for it reads as unknown. A version or an image name that cannot be ordered is offered rather than hidden, the same as anything else this bus does not recognise: an update nobody can see reads as a fault in the van. Installing posts to the updater, and a request it accepts without sending anything is reported as that rather than as an install that ran: until the updater's own conversation with the nodes is wired up, that is every request. Progress comes off the bus while a node writes, from SYS_WIFI_STATUS, and ends when the node comes back announcing a different image or goes quiet across the reboot — an image that fails to commit reverts to the one it left, so the entity honestly shows the old version still running and the new one still on offer.

A fetch the node says failed — SYS_WIFI_STATUS state 6, for a server it could not reach, a manifest with no entry for its type, a truncated image or a digest that did not match — reads as fetch_failed on the same entity, and stays there once the radio idles out, since the session ends within seconds of a failure and the entity would otherwise show nothing but a transfer that stopped. Pressing install again clears it, as does the node writing another image, coming back on a different build, or going quiet. It covers only the failures a node is left running to report, so it going untrue is not an update having taken: an image that boots and never commits is reverted by the bootloader, which nothing on the bus reports at all.

The bridge carries a check button beside those entities, because the update platform has none of its own: homeassistant.update_entity only refreshes what Home Assistant already holds, and never makes the updater go and look. One button covers the van, since a check polls the firmware repository for every node type at once. It answers when the check has finished downloading, which can take a while, and what it found shows up without waiting for the next poll. A check already running, an updater that is not running, a firmware repository the van's uplink could not reach, and an image offered but not fetched whole are each reported as themselves. The last two especially, because either one otherwise looks exactly like nothing new having been published.

Beside the button is an uplink sensor, because an install can cost the very connection it is being asked for over. A node pulls its firmware from an access point the Pi raises on wlan0, and the sensor says what carries the Pi's own traffic while that stands: ethernet, wireless or none. Whether it costs anything is update_drops_uplink beside it rather than the state, because only an uplink on the radio the access point wants has to come down — a wire, a second radio and nothing at all each cost nothing. When one does come down, Home Assistant is unreachable from the rest of the network until the update finishes, though the kiosk on the Pi itself is local and never loses it. It is shown and never enforced: a van in a field with no Ethernet is a perfectly good place to update anyway, and that is the person's call to make. An updater that is not running, or a host with no NetworkManager to ask, leaves it unknown rather than guessing, since a guess either way reads as a promise.

Work was read-only first — discovery, entities, state, availability, alarms and diagnostics, with no command path at all — so that the hardware, the decode path and the entity model were proved before any bug could switch something on in a van. Commands came second.

Requirements

  • Home Assistant Container, always — run from the compose.yaml here. Not OS and not Supervised, so there is no add-on and no Supervisor; the integration is the whole answer.
  • A CAN interface up at 250 kbps, CAN 2.0B with 29-bit extended identifiers.
  • No internet connection, cloud service or NTP while it is running. The van may have no network at all beyond the bus. The one dependency, python-can, is fetched when the integration is first set up, or baked into the image, and is never reached for again.

Bringing up can0

The interface is host configuration, and this repository does not own it: the integration only needs to know what the interface is called. On a Pi with an MCP2515 module, in /boot/firmware/config.txt:

dtparam=spi=on
dtoverlay=mcp2515-can0,oscillator=<your module's crystal, in Hz>,interrupt=<its INT GPIO>

The oscillator frequency has to match the crystal actually fitted to the module. A wrong value does not fail loudly — it produces an interface that comes up and then never receives a valid frame.

Then, after a reboot:

sudo ip link set can0 up type can bitrate 250000
sudo ip link set can0 txqueuelen 128   # the 10-frame default drops bursts
ip -details -statistics link show can0

Make it persistent with systemd-networkd or your distribution's equivalent, so the bus is there before Home Assistant starts.

Running it

compose.yaml is the deployment, on the van and on a workstation alike. It runs Home Assistant Container with this integration bind-mounted into the config directory, following the documented Raspberry Pi container install.

tools/stamp-build               # stamp the commit this checkout is on
docker compose up -d
docker compose logs -f          # the first start takes a minute

Then http://localhost:8123, complete onboarding once, and add the integration from Settings -> Devices & services.

A few things worth knowing:

  • The integration is mounted read-only — the repository is the source of truth. Restart the container to pick up a change; Home Assistant has no reload for the Python behind a custom integration.

  • tools/stamp-build writes the checkout's commit into the integration, which is what the Pi announces as its build identifier, the way a board announces the commit it was flashed from. Run it again after each git pull; a copy nothing stamped announces zero and says so in the log.

  • network_mode: host is not only for discovery. A CAN interface is a network interface, so it is also what lets the container see the van's bus at all.

  • The interface itself is the one you give the config flow, and nothing else sets it. The updater is on the same bus and is told which one over the control API, once at startup and again every minute, so a Pi with two MCP2515 overlays needs nothing configured twice and nothing kept in step by hand.

  • On a machine with no CAN hardware — a workstation rather than the van — the config flow will correctly refuse every interface name. A virtual bus is enough to get past it and exercise the flow, so bring one up by hand with sudo ip link add dev vcan0 type vcan and sudo ip link set up vcan0, then give the flow vcan0. A later tools/frame-bench run takes that bus away with it, so bring it back the same way.

  • The Mosquitto broker alongside it is for Home Assistant's own MQTT, and has nothing to do with the van's bus. It is published on 127.0.0.1:1883 and nowhere else, which is what makes anonymous access safe, so the MQTT integration wants 127.0.0.1 on port 1883 with no credentials.

  • The updater service is built from updater/ and owns the Pi's radio, with the firmware cache and the over-the-air conversation with the nodes still to come. During an update it raises a campervan-ota access point for the nodes to join, and puts the Pi's own network back afterwards — whether the update worked, failed, or nothing ever turned up. Over Ethernet nothing is lost; over WiFi the Pi drops its uplink to become the access point, so Home Assistant is unreachable from the network until it finishes. A restore deadline covers an update that never finishes, a restore on shutdown covers docker compose down, and a start deletes any hotspot left behind and brings back the uplink noted in updater-data/state, which is how a killed container gets its network back. It needs no privileged flag and no added capability, because NetworkManager does the privileged work over D-Bus in its own process. That is not a sandbox, mind: it is root in the host's network namespace with the system bus mounted, which is close enough to host root. Give it FIRMWARE_REGISTRY_TOKEN when the firmware registry wants one, and FIRMWARE_REPOSITORY to point it at an API other than the firmware repository's own — the API base, https://<host>/api/v1/repos/<owner>/<repo>, not the web URL. The token only travels over HTTPS, so an endpoint set to plain HTTP goes unauthenticated. It caches into updater-data/firmware. It holds no CAN interface of its own: until the integration names one it has no bus, reports connected: false, and answers every install no_link.

  • That cache is the only part of the van that wants an internet connection, and it is built to find nothing: a check that fails is ordinary, and an image found at home installs in a field weeks later. Every few hours it asks the firmware repository for its newest release and downloads whatever image it does not already hold, into updater-data/firmware/<environment>/<version>/, alongside an image.json recording the size and the SHA-256 this end computed. Nothing upstream publishes a hash. The metadata file is written last, so an image is only offered once its bytes are there.

  • The updater answers a small HTTP API on 127.0.0.1:8787, which is how the integration reads and drives it. Loopback is the whole of the access control: Home Assistant shares the host's network namespace, and the van's network and the hotspot never see it. It is a service API rather than a private one, so with Home Assistant stopped curl gets the same answers: GET /api/status, GET /api/firmware, GET /api/nodes, POST /api/check, POST /api/interface/<name> for the CAN interface to listen on, and POST /api/nodes/<node>/update, where <node> is an address such as 0x10. A check answers when it has finished downloading, which over the van's link can be a long wait, so give the call a generous timeout. Until the conversation with the nodes is built, a node's installed version reads as null and an update request is accepted with "started": false — accepted and done are not the same fact.

  • The nodes fetch from that cache over HTTP on port 8080, bound on every interface because a node reaches it over the update hotspot and a laptop over the van's ordinary LAN. GET /firmware/manifest.json lists the newest image held for each node type, with its release, size, SHA-256 and the path to fetch it from, which is /firmware/<version>/<environment>.bin rather than the cache tree: a node holds a path in 63 characters and reads at most 2048 bytes of manifest, and drops the entry or the whole manifest rather than the part it cannot take. An image whose path would not fit is left out and logged instead of offered. A node finds its own entry by node type, which is byte 0 of the SYS_ANNOUNCE it already sends, compares the release against the one it is running and, if it is not newer, leaves it unfetched (spec section 11.1); otherwise it checks the bytes against the hash before it switches boot slot. Only what the manifest offers can be fetched. There is no authentication, deliberately: the trust boundary is the network, which during an update is a hotspot with a per-session key.

  • The updater also mounts custom_components/campervan/protocol read-only, because the node registry that says 0x10 is lighting belongs to the integration and is not transcribed a second time.

  • ha-data/ is Home Assistant's own state and is ignored by git apart from the directory itself. Home Assistant writes it as root, so clearing it needs sudo. The same goes for mqtt-data/data, which the broker writes as its own user.

  • The container tracks stable, which drifts ahead of the release the test suite pins. Set HA_VERSION to pin it back when the two need to agree. MOSQUITTO_VERSION does the same for the broker, which otherwise follows Mosquitto 2.

Releases

Every push to main that passes CI publishes a release, versioned the way the firmware is: YYYY.MM.<nth build that month>, such as 2026.09.3, with a counter of its own. The release carries campervan-home-assistant-<version>.zip, which is also in the package registry as campervan-home-assistant.

The zip holds the campervan/ folder, already stamped with its version and its commit, so unzipping it inside a config's custom_components/ directory is the whole install. The version in the committed manifest.json stays below every release, so a checkout never looks newer than a published build.

Publishing needs a PACKAGE_TOKEN repository secret: a personal access token with write:package scope, since the automatic Actions token cannot write to the package registry. Repository secrets are not shared, so a PACKAGE_TOKEN set on the firmware repository does not reach this one.

Testing without a van

tools/frame_bench is a small web tool, independent of Home Assistant, that builds any frame in the message catalogue and publishes it to a SocketCAN interface. It is how the decode path, the entity model and availability get exercised without a van, a bench or any firmware. It needs nothing but the standard library.

tools/frame-bench             # asks for sudo

Then http://127.0.0.1:8099, and candump vcan0 in another terminal to watch what arrives.

tools/frame-bench creates the virtual bus, runs the bench on it, and deletes the bus again on the way out, however the bench ended. Nothing is left behind to be mistaken for a real bus later. It takes --interface if vcan0 is not what you want, and hands everything else — --host, --port — straight to the bench. It refuses any interface that is not a vcan, so the van's real bus, which is host configuration this repository does not own, stays out of it.

The bench itself listens on the loopback address, so putting frames on a real bus takes a deliberate python3 -m tools.frame_bench --interface can0 and never happens by accident.

The form is generated from one transcription of the specification, in tools/frame_bench/protocol.py, and the browser encodes nothing itself: it posts what you typed and renders what comes back. So the tool holds one implementation of the wire format rather than two, and when the specification moves that file is what changes.

Frames worth keeping are saved as JSON under tools/frame_bench/examples/ and committed. Values are stored by name rather than by number, so a diff reads as the protocol:

"fields": { "endpoint": 1, "status": "fault", "value": "fault" }

Sequences

Some of what the integration has to handle is a conversation rather than a frame. A node answers a descriptor query with an announce and then two frames per endpoint, staggered by node_id * 5 ms so the last node answers more than a second late, and availability is that shape backwards: a heartbeat every second and then a deliberate silence. A sequence is an ordered list of steps, each one an ordinary frame document with after milliseconds to wait first, and optionally repeat and every for a step that sends more than once.

A file under examples/ holding steps is a sequence; a file without one is a single frame, exactly as before. Both are listed in the same sidebar, and a sequence opens into the sequence card, where it can be played, added to and saved again.

The bench plays a sequence itself rather than the browser doing it, because a background tab has its timers throttled and the timing is the whole point. Every play gets an identifier, the rail shows what is playing and how far through it is, and any play can be stopped on its own or with everything else at once. Nothing survives the server: closing it stops every play.

Two deliberate limits, both because --interface can0 points this at the van:

  • A repeat is bounded. There is no way to ask for an endless one, and a step that sends more than once has to say how far apart. Ten minutes of heartbeat is "repeat": 600, "every": 1000, which also says in the file how long the scenario is meant to last.
  • The same sequence will not play twice at once. Starting one that is already playing is refused and names the play in the way, because two copies of a heartbeat scenario is a different scenario rather than more of the same, and clicking play twice almost always means "did that start?".

tests/test_frame_bench.py checks the encoder against the four worked examples in section 13 of the specification, which are the only frames the document spells out byte by byte, and re-encodes every saved example. Both are stopgaps: replace them with the shared test vectors from the firmware repository once those exist. tests/test_bench_sequences.py covers the player, and plans every saved sequence for the same reason. Nothing there sleeps: the player is given the wait it uses, so the delays are checked by what it asked to wait for.

Development

uv venv --python 3.14 .venv
uv pip install --python .venv/bin/python -r requirements-dev.txt

tools/check

tools/check runs what CI runs, in the same order: ruff check, a formatting check, mypy over the integration, the tests and the tools, then pytest. It stops at the first failure, so a green run means a green pipeline. Formatting is checked rather than applied; .venv/bin/ruff format . is what fixes it.

custom_components/campervan/protocol/ is this repository's transcription of the wire format, and imports nothing from Home Assistant. The frame bench keeps its own declarations because it needs words for a form and the library deliberately has none, but tests/test_bench_agrees_with_the_protocol.py pins the two together so only one of them may describe layout.

The shared codec vectors are generated in the firmware repository and committed here by tools/refresh-vectors. CI refreshes them and fails if they moved, which is what makes a codec change over there break the build over here. They cannot catch a misreading both implementations share, so the specification stays the authority and the worked examples are read from the document rather than from the vectors.

tests/vectors/SOURCE names the firmware branch they came from, and the script reads it when given no argument. A firmware feature branch publishes its vectors before it merges, and work here builds against one for as long as that takes. A build for main refuses anything but main, because a merged branch is kept rather than deleted: its vectors would freeze, and CI would go on matching that snapshot while the firmware's own main moved.

Most of the suite fakes the bus, holding a real pipe so the event loop arms a real reader. tests/test_transport_live.py is the exception: it opens a real vcan0 and has the frame bench put a frame on it, which is the only place the python-can call and a real descriptor are exercised. It skips itself where there is no vcan0, so it never runs in CI, and it wants a bus brought up by hand: tools/frame-bench deletes whatever bus it was pointed at when the bench stops, including one you brought up yourself.

Python 3.14 is not a preference: it is what Home Assistant 2026.9 requires. pytest-homeassistant-custom-component is pinned exactly, because its fixtures track Home Assistant's internals and it pins the Home Assistant release it was built against.

hassfest runs in CI as a container, and needs a runner that can run one. The layout checks that are ours to make are in tests/test_manifest.py.