- Python 94.9%
- JavaScript 2.8%
- CSS 1%
- HTML 0.7%
- Shell 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| custom_components/campervan | ||
| ha-data/config/custom_components | ||
| mqtt-data/config | ||
| tests | ||
| tools | ||
| updater | ||
| .editorconfig | ||
| .gitignore | ||
| CLAUDE.md | ||
| compose.yaml | ||
| pyproject.toml | ||
| README.md | ||
| requirements-dev.txt | ||
| rpi-setup.md | ||
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.yamlhere. 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-buildwrites 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 eachgit pull; a copy nothing stamped announces zero and says so in the log. -
network_mode: hostis 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 vcanandsudo ip link set up vcan0, then give the flowvcan0. A latertools/frame-benchrun 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:1883and nowhere else, which is what makes anonymous access safe, so the MQTT integration wants127.0.0.1on port1883with no credentials. -
The
updaterservice is built fromupdater/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 acampervan-otaaccess 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 coversdocker compose down, and a start deletes any hotspot left behind and brings back the uplink noted inupdater-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 itFIRMWARE_REGISTRY_TOKENwhen the firmware registry wants one, andFIRMWARE_REPOSITORYto 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 intoupdater-data/firmware. It holds no CAN interface of its own: until the integration names one it has no bus, reportsconnected: false, and answers every installno_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 animage.jsonrecording 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 stoppedcurlgets 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, andPOST /api/nodes/<node>/update, where<node>is an address such as0x10. 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 asnulland 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.jsonlists 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>.binrather 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 theSYS_ANNOUNCEit 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/protocolread-only, because the node registry that says0x10is 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 needssudo. The same goes formqtt-data/data, which the broker writes as its own user. -
The container tracks
stable, which drifts ahead of the release the test suite pins. SetHA_VERSIONto pin it back when the two need to agree.MOSQUITTO_VERSIONdoes 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.