Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 33d92e133c fix(audit): track the heating-can image pins, not the deleted Ansible vars
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#476 merged first, which removed edge_open3e_pkg, edge_e3oncan_repo and
edge_e3oncan_version in favour of the image. The audit entry added here still
told the agent to read those, so it would have gone looking for variables
that no longer exist and reported nothing -- the same silent non-auditing the
entry was written to prevent.

Repointed at where the pins actually live now: OPEN3E_REF and E3ONCAN_REF as
ARGs in ci/images/heating-can/Containerfile. The awkward specifics carry over
unchanged, since they are properties of the upstreams rather than of how we
package them: open3e publishes no tags so the comparison must be against
commits, and E3onCAN's pin is an untagged commit later than its newest tag.

Added a note the previous version could not have made: heating_can_image is
:latest, so the node follows whatever CI last built. The Containerfile ARGs
are what actually fix the versions, and bumping one is what requires a
rebuild -- otherwise a reader could reasonably conclude the tag is the pin.

The pin commit itself is now a no-op against master and survives only as the
audit registration; its conflict on heating-can/defaults resolved in favour of
the image.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:17:42 +02:00
claude-botandClaude Opus 5 150a83ee38 fix(edge): make the capability-group invariant structural, and unstale a note
Both from review on #475.

The rule "a host in a capability group must also be in [edge_host]" was
enforced by a comment, in a file that already uses :children a few lines
below for ntp_cluster. The three capability groups are now children of
edge_host, so the relation is structural: a host added only to [heating_can]
still resolves group_vars/edge_host, where the local MQTT contract lives, and
cannot silently come up without edge_mqtt_host and friends.

That matters more than it looks. The MQTT contract only moved to group_vars
in this PR because splitting the plays exposed that the roles could not
render without each other's defaults -- so an edge host outside [edge_host]
would hit exactly the failure that change was made to fix.

Verified all four groups still resolve to edge-1, and the redundant
ansible_user on the capability entries is dropped: it is inherited from the
[edge_host] declaration rather than repeated three times.

Also unstaled a Day-2 note that still read "the pip task tracks the git
default branch (pin edge_open3e_pkg to a tag if that ever bites)". Both
services are pinned to commits now, deliberately, and pinning to a tag is not
available for open3e -- it publishes none. Replaced with the actual workflow:
the weekly update-audit reports drift, bump the pin and re-run.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:16:37 +02:00
claude-botandClaude Opus 5 dd6553ab07 fix(heating-can): pin open3e, and register both CAN services with the audit
open3e was installed from an unpinned git URL, so the heating bridge
silently tracked upstream main: whatever HEAD happened to be on the day a
converge ran was what ended up driving the heat pump. Now pinned to
6553c104, which was simultaneously upstream HEAD and the copy already
installed on edge-1, so the pin froze the running state and changed nothing.

A commit rather than a tag because open3e publishes none -- its tags API
returns an empty array. The package self-reports 0.7.5 at that commit, which
is useful context but is not a tag.

Honest about what this does NOT fix: pip still re-clones and rebuilds a VCS
requirement even when the SHA is pinned, because it cannot compare an
installed VCS dist against a requested URL without fetching it first.
Verified directly on the host:

  pip install --dry-run "git+...@6553c104"
    -> Cloning ... Resolved ... to commit 6553c104
       Installing build dependencies: started

So the task still reports changed and still restarts open3e on every
converge. Pinning buys deliberate versioning, not idempotence.

Pinning without an update path only converts churn into silent rot, so both
services are now registered with the weekly update-audit -- which had been
auditing neither, despite E3onCAN having been pinned since July. The audit
prompt warns that anything not listed is silently not audited, and these two
were exactly that.

The new scope entry records the awkward specifics so the agent does not
report normal states as problems: open3e has no tags at all, so it must
compare commits against the default branch; E3onCAN does tag, but sparsely,
and its pin sits between the newest tag and HEAD.

Also corrected while there: edge_e3oncan_version was commented "# V0.5.2",
which does not exist upstream. The repo's only tag is v0.5.1 (6f43b3fb), and
461dcbdf is a later untagged commit from 2026-06-21, "Fixed: MQTT client ID
collision caused stale retained values; added publish error logging".

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:16:37 +02:00
claude-botandClaude Opus 5 0b845e7c76 refactor(edge): split the plays by capability, not just the roles
Splitting edge-host into single-concern roles left the coupling one layer
up: install-edge-host.yml was still one play applying all eleven roles to
one group. A host in [edge_host] got the Z-Wave gateway and the CAN services
whether or not it had the hardware -- the monolith again, in the play.

Inventory groups now state which hardware a host physically has, and each
playbook targets the group for its own concern:

  [edge_host]       install-edge-host.yml       base: flash-wear, log
                                                shipper, MQTT bridge, VLAN,
                                                docker
  [heating_can]     install-heating-can.yml     Innomaker USB2CAN
  [zwave_gateway]   install-zwave-gateway.yml   Nabu Casa ZWA-2
  [power_meter]     install-power-meter.yml     FTDI meter head

No flags and no `when:` on the roles. Moving a stick to another box is a
one-line inventory change, which is what it should be. A host with nothing
attached is simply absent from the capability groups -- which is what lets a
second thin client join as an NTP peer without being handed peripherals it
does not have.

The split exposed a circular dependency the single play had been hiding: the
local MQTT contract was scattered across two roles' defaults, in both
directions. mqtt-bridge's config needs edge_open3e_mqtt_user, because the
bridge authenticates to the cluster broker AS that user; heating-can and
power-meter-sml need edge_mqtt_host, because that is where they publish.
Neither role could render without the other's defaults, and it only ever
worked because one play loaded every role at once.

Those four values are the interface between the broker and its clients, not
the property of either side, so they move to group_vars/edge_host -- where
the matching secrets (edge_open3e_mqtt_password, edge_sml2mqtt_mqtt_password)
already lived. Removed from the role defaults so there is one source of
truth. Each role keeps what is genuinely its own: mqtt-bridge the cluster
endpoint and CA path, heating-can the datapoints and poll interval.

Also dropped: the legacy shared-compose teardown pre_tasks. That migration
completed on the 2026-08-08 apply (verified: /opt/edge-host/docker-compose.yml
is gone), and carrying migration cruft in a base play would be the same
mixing this commit is undoing.

Verified by --check --diff against the live host, all four plays:

  install-edge-host      ok=33 changed=0 failed=0
  install-zwave-gateway  ok=8  changed=0 failed=0
  install-power-meter    ok=4  changed=0 failed=0
  install-heating-can    ok=18 changed=4 failed=0

Three of four are fully idempotent. heating-can's four are the known open3e
pip churn plus the E3onCAN venv, which reports changed only under check mode
(it was ok in the real apply) -- and their two restart handlers.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:16:17 +02:00
k.renner 685905b1df Merge pull request 'build(heating-can): image for the CAN readers, replacing pip-into-venv' (#476) from heating-can-image into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#476
2026-08-10 19:04:49 +00:00
claude-botandClaude Opus 5 f9848d2bf5 fix(heating-can): correct the overlay path, secret mode and writable CWD
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review on #476 found a real regression plus three things worth fixing before
this reaches edge-1.

Blocking: edge_open3e_devices.vx3.dpList still pointed at the old host path
/opt/edge-host/open3e/vx3plus.py. The compose file mounts that overlay at
/config/vx3plus.py and nothing mounts anything under /opt/edge-host inside
the container, so open3e would have looked for it at a path that does not
exist -- silently falling back to the stock vx3 datapoint list and dropping
the battery-module DIDs (vx3.1577-1582 and friends) the overlay exists to
add. Now /config/vx3plus.py.

Worth noting the build-time self-test could not have caught this: it proves
the binaries execute, not that devices.json resolves against the mounted
files at runtime.

Writable working directory. WORKDIR was /opt, which the COPY layers create
root-owned, while the process runs as open3e. udsoncan writes its log file
into the CWD -- the native install gave open3e's directory `owner: open3e`
for exactly that reason, and I dropped that when containerising. Now a
dedicated /var/lib/open3e, owned by the service user.

Secret mode. MQTT_PASSWORD was inline in docker-compose.yml, which is
rendered 0644. The env file this replaced was 0600, and the sibling roles on
this host keep secrets in a 0600 file beside a world-readable compose file.
Restored: credentials move to heating-can.env at 0600, referenced via
env_file.

edge_e3oncan_mqtt_format had become dead -- both services were fed
MQTT_FORMAT from edge_open3e_mqtt_format. The two default to the same string
so behaviour was unchanged, but overriding it did nothing. Wired through as
E3ONCAN_MQTT_FORMAT, which supervisord passes to e3oncan only.

Also from review: dropped the registered-but-unread heating_can_native_stopped,
and the migration now removes the emptied {{ edge_base_dir }}/open3e and
/e3oncan directories rather than leaving them behind.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:58:09 +02:00
security-bot 2e62216bc7 chore(image-automation): bump prompve/prometheus-pve-exporter:3.10.0
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-10 06:35:03 +00:00
claude-botandClaude Opus 5 fac46aa7ac fix(heating-can): self-test at build time instead of a CI step
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The smoke-test step I added errored the whole pipeline before any step ran.
It used the freshly built image as its own step image, but the Gitea registry
is private: the build steps authenticate kaniko for PUSH, nothing gives the
runner credentials to PULL. So the step could never have worked.

Moved into the Containerfile as a build-time RUN, which is better anyway --
it needs no registry credentials, and a broken image is never published
rather than published and then flagged.

It asserts what the previous revision got wrong: both services actually
execute, and supervisord's config carries both programs. That is exactly the
failure that shipped green -- supervisord pointed at
/opt/e3oncan/src/E3onCANcollect.py, which does not exist (the file is at the
repo root), so the image would have run open3e while e3oncan crash-looped
and the E380 meter went silently missing.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:13:59 +02:00
claude-botandClaude Opus 5 6fb760edae fix(heating-can): correct the e3oncan path, and smoke-test the image in CI
The image shipped a path that does not exist. supervisord ran
`python /opt/e3oncan/src/E3onCANcollect.py`, but that file is at the repo
ROOT -- there is no src/ directory. The `src` came from the old Ansible task,
which cloned into `{{ edge_base_dir }}/e3oncan/src`; that was the clone
DESTINATION, not part of the upstream layout, and I carried it into the
Containerfile without checking. Confirmed against the upstream tree at the
pinned commit: E3onCANcollect.py sits at the root alongside requirements.txt,
which is why the build succeeded while the runtime path was wrong.

So the image built green and would have started open3e and failed e3oncan on
every restart -- the E380 meter silently absent, with supervisord retrying
forever.

Nothing caught that, because nothing ran the image. Now CI does: a smoke-test
step uses the freshly built image as its own step image and asserts both
services execute and supervisord's config carries both programs.

It cannot exercise the CAN bus or the broker, neither of which exists on a
runner, so it verifies exactly what is verifiable without hardware. That is
still the difference between "built" and "works" -- and it belongs on a
throwaway runner, not on the production edge host, which is where I wrongly
went looking for this the first time.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:12:03 +02:00
claude-botandClaude Opus 5 96fcb426aa build(heating-can): one container, both readers supervised inside it
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Reverts my own reinterpretation. The instruction was to put both in one
container because they belong together; I argued against it on crash-isolation
grounds, then read the correction "two services, one image" as agreement with
my version rather than as a restatement of the original. It was not.

A container runs one PID 1. With two processes that PID 1 is a supervisor,
which is how a container handles multiple services. supervisord now runs both
readers: it restarts either on its own if it dies, reaps children and forwards
shutdown -- none of which a bare process as PID 1 can do for a sibling, which
was the actual answer to the concern I raised.

Also gone: the dispatch entrypoint and the argv in the compose file. Both were
the same mistake in different places -- the invocation living outside the
artifact. open3e's and E3onCAN's command lines are an implementation detail of
this image now; supervisord builds them from environment variables it expands
itself. Nothing outside the image knows either CLI, so an upstream flag rename
cannot break the compose file or an Ansible template.

The compose file supplies configuration and nothing else: one service, one
container, environment only. The credential env file is dropped -- the
credentials are environment already.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:06:43 +02:00
claude-botandClaude Opus 5 56a9859e50 refactor(heating-can): run the image, install nothing on the node
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing is installed on the host any more. The image is built in CI with
both sources pinned, published to the registry, and edge-1 pulls and runs
it. That is the whole point of building it -- installation happens at build
time, once, not on every converge.

Removed from the node: both venvs, the E3onCAN checkout, and the two systemd
units. Kept and still rendered: devices.json, the vx3 datapoint overlay and
the broker credentials, now bind-mounted into the containers rather than
read from a venv working directory.

Two services from one image, as before: open3e drives the Vitocal/VX3 over
UDS, e3oncan listens passively for the E380 meter. Separate containers so
either can crash and restart without a supervisor as PID 1.

The migration stops AND masks the old units before the containers start.
Masked rather than disabled because two open3e instances issuing UDS
requests on one CAN bus is the failure mode to avoid, and a stray
`systemctl start` or leftover dependency should not be able to bring the
native one back alongside the container.

No missing-device tolerance here, unlike the zwave and meter roles: can0 is
this host's purpose. If it is absent the heating telemetry is down and the
run should say so rather than pass quietly.

heating_can_image replaces edge_open3e_pkg / edge_e3oncan_repo /
edge_e3oncan_version. The source pins move into the Containerfile, so what
is deployed here is a tag -- and `docker compose up -d` on an unchanged tag
is a genuine no-op, which is what removes the restart-per-converge that
pinning the pip URL could not.

Verification is limited and worth stating: plays/install-heating-can.yml
lives in #475 and is not on this branch, so only the master-side play
syntax-checks here. A --check run cannot exercise this at all -- command
tasks are skipped under check mode, so neither the pull nor the compose up
would run. It cannot be meaningfully verified until the image has been built
and #475 is merged.

Depends on #475 (the capability-split playbooks, and the MQTT contract
moving to group_vars). This branch is cut from master, so heating-can's
defaults will conflict there; #475 should merge first and this be rebased
onto it.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:59:45 +02:00
claude-botandClaude Opus 5 4df1018e05 build(heating-can): make both services first-class commands in the image
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The image had no ENTRYPOINT and no CMD, with a comment claiming that was
deliberate because the commands are rendered by Ansible. That was
rationalising a defect.

An image with nothing runnable is not a self-contained artifact. The
knowledge of how to START each service leaked into Ansible, and the two were
not even symmetric: open3e installs a console script via pip, while E3onCAN
is an unpackaged script in a checkout, so the compose file would have had to
carry `python /opt/e3oncan/src/E3onCANcollect.py` -- an implementation detail
of this image's internal layout, in a file that has no business knowing it.
Move the checkout and every caller breaks.

E3onCAN now gets a wrapper on PATH, so both services are invoked the same
way and `docker run <image> e3oncan --help` works.

CMD prints what the image provides and exits 64 (EX_USAGE) rather than
silently doing nothing, so `docker run <image>` is self-describing. Which
service to run, and its arguments, stays the caller's choice -- the compose
file renders those, because the datapoint list and broker settings live
there.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:54:18 +02:00
claude-botandClaude Opus 5 0e4d516722 build(heating-can): image for the CAN readers, replacing pip-into-venv
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Builds one image containing open3e (Vitocal heat pump, Vitocharge VX3) and
E3onCAN (E380 grid energy meter), both pinned in the Containerfile.

Why: both were pip-installed into venvs on edge-1, open3e straight from an
unpinned git URL. pip cannot compare an installed VCS dist against a
requested one without fetching it, so `pip install` re-cloned and rebuilt on
every Ansible converge -- reporting changed, firing the restart handler, and
putting a gap in heating telemetry each time. Pinning the SHA does not fix
that; verified directly on the host that pip still clones and rebuilds a
pinned VCS requirement. A tagged image does: `docker compose up -d` on an
unchanged tag is a genuine no-op.

It also gives the weekly update-audit a normal artifact instead of two
awkward special cases. open3e publishes no tags at all, and E3onCAN is
pinned to an untagged commit sitting between its newest tag and HEAD -- both
of which the audit currently has to be told about in prose.

One image, two services. They read the same physical installation over the
same bus and are versioned together, so they are one artifact; but they will
run as two containers from it, so either can crash and restart on its own
without a supervisor as PID 1.

Fixes a long-standing blind spot in passing: e3oncan.service set
PYTHONUNBUFFERED=1 and open3e.service did not, so open3e's stdout was
block-buffered and effectively never reached the journal or Loki. The image
sets it for both.

Build follows the existing kaniko pattern exactly -- path-scoped `when:` so
only this image rebuilds, date tag plus latest, pushed to the Gitea registry
alongside the ci-* images.

Deliberately artifact-only. The heating-can role still deploys venvs and
systemd units; moving it to a compose project is a separate change, once
this image exists and has been pulled and run once. Rewriting the role that
drives the heat pump against an image that has never been built would be the
wrong order.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:52:53 +02:00
k.renner 603db4ed95 Merge pull request 'fix(edge-host): make the bootstrap self-contained and the runbook followable' (#474) from edge-bootstrap-selfcontained into master
ci/woodpecker/cron/update-all Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#474
2026-08-08 10:39:47 +00:00
claude-botandClaude Opus 5 6cfa63f6f3 fix(edge): keep the docker log-driver config out of the shared role
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review on #474 caught that the previous commit put the daemon.json task in
docker-install, which is shared by ten plays: install-netbird-server,
install-netbird-proxy, install-n8n, install-authentik-ha, install-owncloud,
install-gatus, install-ical-proxy, install-grist and
install-music-assistant.

None of those hosts has an /etc/docker/daemon.json today -- the template and
the default were both new here -- so the next routine run of any of those
plays would have created one, reported changed, fired the Restart docker
handler, and bounced every container on that host. Unannounced restarts
across authentik, n8n, owncloud, grist, netbird, gatus, music-assistant and
ical-proxy, from a change about edge-host bootstrapping.

The --check verification could not have caught it: it only ever exercised
install-edge-host.yml, and the damage was in the other nine plays.

The comment I wrote there was the tell. It said "the services that use this
host run with network_mode: host" -- true of edge-1, false of most consumers
of that role. A justification that only held for the one host I was looking
at, written into a shared file.

Moved to edge-flash-wear, which is edge-host-only and already owns this
rationale: routing container stdout to the journal is flash-wear handling,
not generic docker setup. The networking aside is dropped -- in a flash-wear
role the only thing worth saying about daemon.json is why logging goes to
the journal.

docker-install is now byte-identical to master (git diff origin/master --
roles/docker-install/ is empty).

Two details the move needed: /etc/docker is created explicitly, because
edge-flash-wear runs before docker-install on a first bootstrap and the
directory will not exist yet; and the Restart docker handler fires at
end-of-play, by which time docker is installed.

Verified: ok=67 changed=25. The changed count is unchanged from before the
move, and no docker task appears in it -- daemon.json renders byte-identical
to what the old role produced, so it reports ok and the handler never fires
on edge-1.

Also from review: noted on the play that heating-can, zwave-gateway and
power-meter-sml each declare edge_base_dir in their own defaults so they
stay usable standalone, that play vars win, and that the two must be kept in
step.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:27:13 +02:00
claude-botandClaude Opus 5 9cc20251fd fix(edge): retire the legacy compose project before any role starts a container
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review on #474 found two blocking defects, both in exactly the place the
--check verification is blind: command and shell tasks are skipped, not
simulated, under check mode. So "verified with --check --diff" covered
everything except the two things that would actually have broken.

1. Ordering: a real run would have aborted.

   The legacy-project teardown lived in power-meter-sml, which the play runs
   last. zwave-gateway runs before it and does `docker compose up -d` for a
   container named zwave-js -- while the old shared project still owns that
   name. Docker enforces container names globally, not per project, so the
   command fails with "Conflict: container name already in use". That is not
   the missing-USB-device string the task tolerates, so the task fails and
   the play aborts before ever reaching the cleanup.

   Moved to pre_tasks, which is its correct home anyway: it is a migration
   step for both services, and putting it inside either one meant whichever
   ran first hit the conflict.

   The play now also declares edge_base_dir and edge_legacy_compose in its
   own vars rather than borrowing them from a role's defaults, since
   pre_tasks must not depend on role default resolution. And it removes the
   now-orphaned zwavejs.env left at the old shared project root.

2. Dangling handler.

   The libeatmydata task still notified "Restart edge compose services", a
   handler that was deleted with the old role. Masked today only because
   `creates:` makes the task report ok -- but the role's own comment says to
   delete that .so when the Alpine base bumps, and the next time it does,
   Ansible would fatally error with "handler not found". Now notifies
   "Restart zwave-js".

   Swept every notify in the new roles against every defined handler; that
   was the only unresolved one.

Also from review: kubernetes/bootstrap.sh referenced the deleted role.
Repointed to ansible/docs/edge-host.md.

Re-verified: ok=66 changed=25. The teardown moved from the power-meter-sml
group into pre_tasks and gained the orphaned-env removal, so the count goes
5 + 5 + 14 -> 5 pre-existing + 6 migration + 14 header repointing. Nothing
else moved.

The limit is worth stating plainly: this re-run still cannot prove the
teardown works, for the same reason it missed the bug. The first real apply
is the test.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:47:39 +02:00
claude-botandClaude Opus 5 2d9c3ba213 refactor(edge): split the edge-host role into one role per concern
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
edge-host was a single role doing eight unrelated jobs: flash-wear
mitigation, log shipping, VLAN networking, an MQTT bridge, a CAN interface,
the heating CAN services, a Z-Wave gateway and a power-meter reader.

That is not just untidy. It is what blocked a second thin client from
joining as an NTP peer: the host-generic work (journald to RAM, Alloy, the
VLAN sub-interface) could not be applied without also handing the box a
Z-Wave radio and a CAN bus it does not have. The role bound its services to
specific USB device paths, so a second host would have tried to start
containers for hardware that is not there.

Now:

  edge-flash-wear   journald -> RAM, noatime/lazytime, TRIM, eMMC health
  edge-log-shipper  Alloy: journal -> Loki
  edge-vlan         tagged VLAN sub-interface
  mqtt-bridge       loopback mosquitto -> cluster broker over TLS
  socketcan         can0 only
  heating-can       open3e (Vitocal/VX3) + e3oncan (E380 meter)
  zwave-gateway     zwave-js-ui, own compose project
  power-meter-sml   sml2mqtt, own compose project

The E380 reader lives with open3e rather than in its own role: it is part
of the same heating installation -- it feeds the heat pump's own energy
management -- not a separate system that happens to share a bus. can0 does
stay separate, because an interface is not the same concern as the services
consuming it.

Each role installs its own packages, so it stands alone. An edge host with
no peripherals takes the first five and none of the rest.

docker-daemon.json moves to docker-install with its own docker_log_driver
variable and Restart docker handler, since configuring the docker daemon was
never an edge-host concern. Handlers split ten ways; defaults split per
role; all sixteen templates and files moved with git mv.

Two deliberate behaviour changes on the node, both accepted:

1. Compose split. zwave-js and sml2mqtt shared a compose file, so touching
   either recreated both. They are now separate projects in their own
   directories. container_name is fixed, so the old project must release the
   names first -- a guarded one-time task downs it and removes the file.
   First apply costs a brief down/up on both. Data paths are unchanged; the
   zwave store is not migrated.

2. Header repointing. Templates and scripts carried "Managed by
   roles/edge-host" headers, which render onto the host -- they would have
   sent an operator to a role that no longer exists. Updating them changes
   file content, so it restarts the services that consume those files.

Verified by --check --diff against the live host. 24 changed items, and
every one is accounted for:

    5  pre-existing, unchanged from the baseline before this work:
       common timesyncd; open3e venv; E3onCAN venv; and the open3e /
       e3oncan restart handlers. The venv tasks install from a git URL,
       which pip cannot verify, so they report changed on every run --
       a separate defect, not introduced here.
    5  the compose split: zwave env + compose and sml compose to their
       new paths, plus the two legacy-project retirement tasks.
   14  the header repointing: journald conf, eMMC health script, alloy
       config, mosquitto bridge config, 80-can0.network, open3e overlay /
       env / unit, e3oncan unit, zwave backup script + env, sml2mqtt
       config -- and the resulting restarts of journald, alloy, mosquitto,
       open3e and e3oncan.

Nothing changed outside those three groups.

One consequence check mode cannot show: 80-can0.network.j2 changed, so the
"Reload networkd" handler fires on a real run and reconfigures can0,
briefly dropping the bus under open3e and e3oncan. It is absent from the
check-mode output only because shell handlers are skipped there. The VLAN
templates did NOT change, so the riskier reconfigure of the parent uplink
on this headless host is not triggered.

Also fixed in passing: the keepalived-ntp guard told operators to recover
with `--limit <host> --tags vlan40`, and referred to the edge-host role.
Both corrected. The role README becomes ansible/docs/edge-host.md -- it
documents the host, not any single role now.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:33:57 +02:00
claude-botandClaude Opus 5 07faf0ae69 fix(edge-host-iso): carry the bootstrap fixes into the ISO side too
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review on #474 caught that the previous commit fixed the procedure in
ansible/roles/edge-host/README.md and left the same two defects in the two
places an operator actually reads them first.

That happened because my grep for the stale patterns ran with the working
directory set to ansible/, so `images/edge-host-iso/README.md` was a path
that did not exist. grep returned nothing and I read that as clean. A false
negative from a bad path, not a clean sweep.

Fixed now, verified from the repository root:

  airootfs/usr/local/bin/edge-install
    Printed the superseded two-command sequence at the console immediately
    after imaging -- the very first instructions anyone sees, telling them
    to run common.yml with -l edge_host. Now prints the single unrestricted
    run, plus the two steps that precede it (confirm the host resolves by
    name, add it to [edge_host]).

  README.md
    Described the image as being for `edge-1.srv.renner.uno`, a name that
    does not resolve, and repeated the same two-command bootstrap. Both
    replaced. This file is the link target of step 2 in the ansible-side
    README, so following the procedure end to end landed straight back on
    the defect #474 claims to close.

Also corrected while there: the ISO README called the target "the bare-metal
N100". edge-1 is a Wyse 3040 (Atom x5-Z8350), and the installer text further
down already says it "covers the Wyse 3040". Rewritten to describe the image
as not tied to one box or model, since the installer autodetects the target
disk -- which is what makes it usable for the second thin client.

Also from review: a comment on why `become: true` is set when the connection
is already root. Not taken -- ignore_unreachable, which common.yml sets. The
review's own reasoning applies: Ansible already skips an unreachable host and
continues with the others, so it adds nothing here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:21:27 +02:00
claude-botandClaude Opus 5 e288ebe39d fix(edge-host): make the bootstrap self-contained and the runbook followable
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The documented base-install procedure had three defects, two of which are
standing house rules and one of which simply does not work.

1. It required a DHCP reservation.

   "Add the DHCP reservation/DNS entry for <host> on the UDM" was step 3 --
   a reservation as a documented precondition. Reservations are off the
   table here. The UDM registers DHCP leases in DNS on its own, so the
   record appears without one, and the host is identified by name
   everywhere: inventory, plays, README. Its address is never recorded.

2. It required --limit, because the plays were split wrong.

   Step 4 was:

     ansible-playbook plays/common.yml -l edge_host
     ansible-playbook plays/install-edge-host.yml

   The --limit was load-bearing, not sloppiness: common.yml is
   `hosts: all:!...`, so running it unrestricted while bootstrapping a
   single new box would reconfigure the entire fleet. The split left no
   other option. But --limit narrows the target list rather than staging
   anything, and leaves every other host outside convergence with no report
   that they were skipped.

   Fixed structurally rather than by deleting the flag: install-edge-host.yml
   now carries `certificates` and `common` itself -- the parts of common.yml
   that apply to an edge host -- so the bootstrap is one unrestricted run
   over the edge_host group. No second command, no limit.

   Not carried over: qemu-guest-agent (these are bare metal, not VMs) and
   monitoring-agent (Debian-oriented; edge hosts ship logs via edge-alloy).
   common.yml's loki/prometheus vars go with monitoring-agent and are not
   needed -- verified that the common role does not reference them.

3. It named a host that does not exist.

   The README said `edge-1.srv.renner.uno` in both the base-install steps
   and the Z-Wave cutover checklist. The host is `edge-1.iot.renner.uno`;
   the .srv name does not resolve, so anyone following the runbook hits a
   dead name at step 3.

Verified idempotent against the live host before committing:
`ansible-playbook plays/install-edge-host.yml --check --diff` over the whole
edge_host group -> ok=53 changed=5 failed=0, with all five changes inside
the edge-host role (open3e venv, E3onCAN checkout/venv), which was already
in this play. certificates and common contributed no changes at all.

Also checked the common role branches only on Debian/RedHat os_family, so
its OS-specific tasks skip cleanly on Arch.

Prompted by preparing a second Wyse 3040 as an NTP peer -- this is the
procedure that would have been followed to build it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:07:15 +02:00
k.renner 5b03d6b7af Merge pull request 'refactor(prometheus): address the PVE nodes by name in scrape targets' (#473) from prometheus-pve-hostnames into master
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#473
2026-08-08 05:11:29 +00:00
security-bot 343e4845e2 chore(image-automation): bump gotenberg/gotenberg:8.35.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-07 19:34:25 +00:00
security-bot 5d56c35017 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.8.1
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-07 18:36:02 +00:00
73 changed files with 1307 additions and 806 deletions
+29
View File
@@ -217,3 +217,32 @@ steps:
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
# unpinned VCS requirement re-cloned and restarted the heating bridge on every
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
# builds the same way and lands in the same registry so the audit sees it.
- name: build-heating-can
image: gcr.io/kaniko-project/executor:v1.23.2-debug
directory: ci/images/heating-can
depends_on: []
when:
- event: manual
- event: push
path: ["ci/images/heating-can/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
--destination gitea.renner.uno/gitea_admin/heating-can:latest
@@ -33,13 +33,43 @@ Build and boot the dedicated installer image — see
(partitions the internal disk — NVMe if present, else the eMMC; the
installer autodetects — installs the ansible-ready base system with SSH
keys, DHCP, docker — asks for confirmation before wiping).
3. Add the DHCP reservation/DNS entry for `edge-1.srv.renner.uno` on the
UDM, confirm `ssh root@edge-1.srv.renner.uno` works.
4. From the workstation:
3. Confirm the box resolves and is reachable by name — `ssh root@<host>` must
work before anything else. The UDM registers DHCP leases in DNS, so the
record appears on its own; the host is identified by name everywhere
(inventory, plays, this file) and its address is never recorded. Do not
pin it to a reservation.
4. Add the host to `[edge_host]` in `inventory/pve_hosts.yml`, by name.
That group is the base: every edge thin client belongs to it, and it
assumes no peripherals.
5. From the workstation:
```
ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
```
One unrestricted run over the whole group. The play is self-contained --
it carries `certificates` and `common` itself, so there is no second
command and no `--limit`. Running it converges every edge host, which is
the point: a run that touches only the new box tells you nothing about
the others.
## Peripherals
Each attached device is its own inventory group and its own playbook. Group
membership states which hardware a host physically has -- there are no flags
and no `when:` on the roles, so moving a stick between boxes is a one-line
inventory change.
| device | group | playbook |
|---|---|---|
| Innomaker USB2CAN (Vitocal/VX3 + E380) | `heating_can` | `plays/install-heating-can.yml` |
| Nabu Casa ZWA-2 Z-Wave radio | `zwave_gateway` | `plays/install-zwave-gateway.yml` |
| FTDI serial power-meter head | `power_meter` | `plays/install-power-meter.yml` |
Add the host to the matching group, then run that playbook. They depend on
the base play having run: all three need the local MQTT bridge, and two of
them need docker.
A host with nothing attached is simply absent from all three groups and needs
none of these commands.
## Secrets (before first play run)
@@ -85,7 +115,7 @@ zwave-js (order matters — never two servers on one radio):
`kubectl -n home-assistant exec deploy/zwave-js -- tar cf - -C /usr/src/app store | tar xf - -C /opt/edge-host/zwave-js/`
- [ ] Scale down in-cluster: `kubectl -n home-assistant scale deploy/zwave-js --replicas 0`
- [ ] Move the ZWA-2 stick to edge-1, `docker compose up -d zwave-js`
- [ ] Point HA `zwave_js` integration at `ws://edge-1.srv.renner.uno:3000`
- [ ] Point HA `zwave_js` integration at `ws://edge-1.iot.renner.uno:3000`
- [ ] Nodes alive in the UI on :8091 → remove the flux kustomization
`home-assistant-zwavejs` + PVC in a follow-up PR
@@ -113,8 +143,12 @@ Afterwards:
deliberate for sml2mqtt, acceptable for zwave-js). If a zwave-js bump
changes its Alpine major, also update `edge_zwave_alpine_base` and refresh
the preloaded `.so` (see "Logging & eMMC" → zwave-js).
- open3e updates: re-run the play; the pip task tracks the git default
branch (pin `edge_open3e_pkg` to a tag if that ever bites).
- open3e / E3onCAN updates: both are pinned to commits in
`roles/heating-can/defaults/main.yml`, so re-running the play does NOT pick
up upstream changes -- that is deliberate, these drive the heat pump. The
weekly update-audit reports when either falls behind (see
`ci/agents/update-audit/prompt.md`); bump the pin and re-run.
Pinning to a tag is not an option for open3e: it publishes none.
- Ad-hoc CAN scans: `systemctl stop open3e` first (single-instance rule),
restart afterwards.
- can-utils (`candump`) is AUR-only on Arch — install manually via
@@ -1,6 +1,6 @@
---
# Secret indirection — real values live in vault.yml (ansible-vault) next
# to this file; see roles/edge-host/README.md for how to populate it from
# to this file; see docs/edge-host.md for how to populate it from
# the cluster secrets.
edge_sml2mqtt_mqtt_password: "{{ vault_edge_sml2mqtt_mqtt_password }}"
edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}"
@@ -36,3 +36,18 @@ keepalived_ntp_sysctl:
# Realtek NIC on the Wyse 3040 has no hardware timestamping.
chrony_hwtimestamp: false
# --- Local MQTT contract -------------------------------------------------
# Shared between the broker and its clients, so it belongs to the inventory
# rather than to either side's role. mqtt-bridge listens here; heating-can and
# power-meter-sml publish here; and the bridge authenticates to the cluster
# broker AS the open3e user, so it needs that identity too.
#
# Keeping these in one role's defaults only worked while a single play loaded
# every role at once. Splitting the plays by capability exposed it: the bridge
# could not render without a heating-can default, and the CAN services could
# not render without an mqtt-bridge default.
edge_mqtt_host: 127.0.0.1
edge_mqtt_port: 1883
edge_open3e_mqtt_user: open3e
edge_open3e_mqtt_topic: open3e
+27
View File
@@ -41,9 +41,36 @@ nas-1.renner.uno ansible_user=ladmin ansible_python_interpreter=/usr/bin/python3
[grafana]
grafana.srv.renner.uno ansible_user=ladmin
# Every bare-metal edge thin client. Gets the host-generic roles only:
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
# sub-interface. Nothing here assumes any peripheral is attached.
[edge_host]
edge-1.iot.renner.uno ansible_user=root
# Capability groups. Membership states which hardware a host physically has,
# and each service playbook targets the group for its own concern. No flags and
# no `when:` on the roles -- moving a stick to another box is a one-line change
# here, which is what it should be.
#
# These are children of [edge_host], declared below: a peripheral is added to an
# edge client, it does not replace the base. Making that a :children relation
# rather than a comment means a host added only to a capability group still
# resolves group_vars/edge_host -- where the local MQTT contract lives -- so it
# cannot silently come up without edge_mqtt_host and friends.
[zwave_gateway]
edge-1.iot.renner.uno
[heating_can]
edge-1.iot.renner.uno
[power_meter]
edge-1.iot.renner.uno
[edge_host:children]
zwave_gateway
heating_can
power_meter
# NTP servers. Includes edge-1 so the role survives the PVE hosts being
# retired for bare-metal Talos, which cannot run chrony or keepalived itself.
# See plays/install-ntp-ha.yml for why keepalived is scoped more narrowly
+37 -9
View File
@@ -1,13 +1,41 @@
---
# Bare-metal Arch Linux edge host (Dell Wyse 3040, internal eMMC) — replaces
# the k8s-edge Talos VM
# (opentofu vm_153) as the home for hardware-bound services:
# - zwave-js-ui (ZWA-2 Z-Wave USB radio, docker compose)
# - sml2mqtt (FTDI power-meter reader, docker compose)
# - open3e (Innomaker USB2CAN -> Vitocal/VX3, native venv + systemd)
# Base install procedure and cutover checklist: ansible/roles/edge-host/README.md
- name: Install Arch edge host
# Bare-metal Arch edge hosts (Dell Wyse 3040 and similar) — the home for
# hardware-bound services, because Talos supports neither SocketCAN nor USB
# passthrough.
#
# This play is the BASE only: what every edge thin client gets regardless of
# what is plugged into it. The peripherals live in their own playbooks against
# their own inventory groups:
#
# plays/install-zwave-gateway.yml [zwave_gateway] ZWA-2 radio
# plays/install-heating-can.yml [heating_can] Innomaker USB2CAN
# plays/install-power-meter.yml [power_meter] FTDI meter head
#
# Splitting the roles without splitting the plays would have kept the coupling:
# one play applying every role to one group is the monolith again, a layer up.
# A host with no peripherals is simply absent from those groups -- no flags, no
# `when:` on the roles.
#
# Self-contained: `certificates` and `common` are the parts of plays/common.yml
# that apply to an edge host, carried here so bootstrapping one is a single
# unrestricted run over the group. Not carried over: qemu-guest-agent (bare
# metal, not VMs) and monitoring-agent (Debian-oriented; these hosts ship logs
# via edge-log-shipper).
#
# Order is dependency order: edge-flash-wear before docker, so the journal is
# volatile before containers start logging into it.
- name: Install Arch edge host (base)
hosts: edge_host
# Connection is already root (ansible_user=root), so this is a root-to-root
# no-op today. Kept explicit, matching plays/common.yml, so the roles below
# do not silently depend on the connection user staying privileged.
become: true
roles:
- certificates
- common
- edge-flash-wear
- docker-install
- edge-host
- edge-log-shipper
- mqtt-bridge
- edge-vlan
+18
View File
@@ -0,0 +1,18 @@
---
# Heating system over the Viessmann E3 CAN bus, for hosts with the Innomaker
# USB2CAN adapter attached.
#
# Targets [heating_can]: membership means the adapter is physically present.
# Two roles because they are two concerns -- the interface is host network
# state, the services consume it -- but one group, because both need the same
# hardware.
#
# Requires the base play (plays/install-edge-host.yml) to have run: these
# services publish to the local MQTT bridge it sets up.
- name: Install heating CAN services
hosts: heating_can
become: true
roles:
- socketcan
- heating-can
+14
View File
@@ -0,0 +1,14 @@
---
# Power-meter reader (sml2mqtt) for hosts with the FTDI serial meter head
# attached.
#
# Targets [power_meter]: membership means the meter head is physically present.
#
# Requires the base play (plays/install-edge-host.yml) to have run: it needs
# docker and publishes to the local MQTT bridge.
- name: Install power-meter reader
hosts: power_meter
become: true
roles:
- power-meter-sml
+15
View File
@@ -0,0 +1,15 @@
---
# Z-Wave gateway (zwave-js-ui) for hosts with the ZWA-2 USB radio attached.
#
# Targets [zwave_gateway]: membership means the radio is physically present.
# Moving the stick to another edge host is a one-line inventory change.
#
# Requires the base play (plays/install-edge-host.yml) to have run: it needs
# docker, and the flash-wear handling that the libeatmydata preload exists to
# complement.
- name: Install Z-Wave gateway
hosts: zwave_gateway
become: true
roles:
- zwave-gateway
@@ -0,0 +1,15 @@
---
# Flash-wear mitigation.
# --- Logging & eMMC wear mitigation ------------------------------------
# edge-1 runs on a small internal eMMC (Dell Wyse 3040, 7.3 GB), so logs are
# kept off local flash: containers + native services log to a RAM-only
# journal and Grafana Alloy ships it to Loki. TRIM, noatime and a weekly
# wear check round out the flash-longevity measures. See README "Logging &
# eMMC".
edge_journald_runtime_max: "128M" # cap the volatile (RAM) journal
# Container stdout -> journal, no json log files on the flash. The journal
# itself is volatile (see the tasks above), so container logs land in RAM and
# are shipped off the box by edge-log-shipper.
docker_log_driver: journald
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# edge-1 eMMC wear/health check. Managed by roles/edge-host.
# edge-1 eMMC wear/health check. Managed by roles/edge-flash-wear.
#
# Reads the JEDEC eMMC 5.x life-time and pre-EOL estimators exposed via sysfs
# and emits a warning-priority journal message when the flash leaves the
@@ -0,0 +1,17 @@
---
- name: Restart journald
ansible.builtin.service:
name: systemd-journald
state: restarted
# Handlers run in definition order, so this fires after 'Restart journald':
# once the journal is volatile, the on-disk store is dead weight on the flash.
- name: Purge persistent journal store
ansible.builtin.file:
path: /var/log/journal
state: absent
- name: Restart docker
ansible.builtin.service:
name: docker
state: restarted
@@ -0,0 +1,100 @@
---
# Flash-wear mitigation for hosts on small eMMC (Dell Wyse 3040 and similar).
#
# journald -> volatile (RAM) and size-capped, root mounted noatime+lazytime,
# periodic TRIM, and a weekly wear check. Shipping the volatile journal off the
# box is a separate concern -- see the edge-log-shipper role.
- name: Install mmc-utils
ansible.builtin.package:
name: mmc-utils # eMMC lifetime/EOL introspection (mmc extcsd read)
state: present
- name: Ensure journald drop-in directory exists
ansible.builtin.file:
path: /etc/systemd/journald.conf.d
state: directory
mode: "0755"
- name: Switch the systemd journal to volatile (RAM) storage
ansible.builtin.template:
src: journald-edge.conf.j2
dest: /etc/systemd/journald.conf.d/00-edge-emmc.conf
mode: "0644"
notify:
- Restart journald
- Purge persistent journal store
- name: Resolve root filesystem facts (for noatime)
ansible.builtin.set_fact:
edge_root_fs: "{{ ansible_facts.mounts | selectattr('mount', 'equalto', '/') | first }}"
- name: Refuse to touch fstab without a stable root UUID
ansible.builtin.assert:
that:
- edge_root_fs.uuid | default('') | length > 0
fail_msg: "Could not determine root UUID from facts; not rewriting fstab."
# lazytime batches inode-timestamp writeback (e.g. the zwave-js lockfile
# heartbeat that otherwise dirties the journal every couple seconds); noatime
# drops access-time writes. Neither weakens data durability.
- name: Mount root with noatime + lazytime (persisted in fstab)
ansible.posix.mount:
path: /
src: "UUID={{ edge_root_fs.uuid }}"
fstype: "{{ edge_root_fs.fstype }}"
opts: "{{ ((edge_root_fs.options.split(',') | reject('in', ['atime', 'noatime', 'relatime', 'diratime', 'lazytime', 'nolazytime']) | list) + ['noatime', 'lazytime']) | unique | join(',') }}"
dump: "0"
passno: "1" # root fs — keep boot-time fsck ordering
backup: true
state: mounted
- name: Enable periodic TRIM (helps eMMC wear-leveling)
ansible.builtin.systemd:
name: fstrim.timer
enabled: true
state: started
- name: Install eMMC health-check script
ansible.builtin.copy:
src: edge-emmc-health
dest: /usr/local/bin/edge-emmc-health
mode: "0755"
- name: Install eMMC health-check service + timer
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- edge-emmc-health.service
- edge-emmc-health.timer
- name: Enable eMMC health-check timer
ansible.builtin.systemd:
name: edge-emmc-health.timer
enabled: true
state: started
daemon_reload: true
# Container stdout into the journal instead of json files on disk. This lives
# here, with the rest of the flash-wear handling, rather than in docker-install:
# that role is shared by ten other plays (authentik, n8n, owncloud, grist,
# netbird, gatus, music-assistant, ical-proxy), none of which have a
# daemon.json today. Adding it there would have created one on the next run of
# any of those plays and restarted every container on those hosts.
#
# /etc/docker is created explicitly because this role runs before
# docker-install on a first bootstrap, so the directory may not exist yet. The
# handler runs at the end of the play, by which time docker is installed.
- name: Ensure the docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
mode: "0755"
- name: Route container logs to the journal (keeps json logs off the flash)
ansible.builtin.template:
src: docker-daemon.json.j2
dest: /etc/docker/daemon.json
mode: "0644"
notify: Restart docker
@@ -0,0 +1,3 @@
{
"log-driver": "{{ docker_log_driver }}"
}
@@ -1,4 +1,4 @@
# Managed by roles/edge-host. edge-1 boots off a small internal eMMC, so the
# Managed by roles/edge-flash-wear. edge-1 boots off a small internal eMMC, so the
# journal must not persist to flash: keep it in RAM (/run/log/journal) and cap
# its size. Grafana Alloy (edge-alloy.service) ships this journal to Loki
# before it rotates out of RAM — nothing durable lives here.
-118
View File
@@ -1,118 +0,0 @@
---
edge_base_dir: /opt/edge-host
edge_tz: Europe/Berlin
# --- Logging & eMMC wear mitigation ------------------------------------
# edge-1 runs on a small internal eMMC (Dell Wyse 3040, 7.3 GB), so logs are
# kept off local flash: containers + native services log to a RAM-only
# journal and Grafana Alloy ships it to Loki. TRIM, noatime and a weekly
# wear check round out the flash-longevity measures. See README "Logging &
# eMMC".
edge_journald_runtime_max: "128M" # cap the volatile (RAM) journal
edge_docker_log_driver: journald # container stdout -> journal, no json logs on eMMC
# Grafana Alloy log shipper. The srv-network Loki (loki.srv.renner.uno:3100,
# no auth) is firewalled off the IoT VLAN; the only endpoint edge-1 can reach
# is the authenticated cluster ingress (Traefik basic-auth).
edge_alloy_config_file: /etc/alloy/edge.alloy
edge_alloy_http_port: 12345
edge_alloy_bin: /usr/bin/grafana-alloy # Arch `grafana-alloy` pkg names the binary this, not `alloy`
edge_loki_push_url: "https://loki.renner.uno/loki/api/v1/push"
edge_loki_username: edge-1
edge_alloy_labels:
environment: production
datacenter: homelab
# --- MQTT: edge clients (open3e, e3oncan, sml2mqtt) publish to a LOCAL
# mosquitto on loopback, which bridges to the central cluster broker over TLS
# (open3e/e3oncan have no native MQTTS). This keeps plaintext on loopback only
# — off the untrusted IoT VLAN — and buffers telemetry in RAM during cluster
# blips. The local broker writes nothing to the eMMC (persistence off, logs to
# the volatile journal). See edge-mqtt-bridge.conf.j2 and README "MQTT bridge".
edge_mqtt_host: 127.0.0.1
edge_mqtt_port: 1883
# Upstream cluster broker the local bridge connects to (TLS; the public
# Let's Encrypt cert is validated against the system CA bundle).
edge_mqtt_cluster_host: mqtt.renner.uno
edge_mqtt_cluster_tls_port: 8883
edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
# --- USB devices (stable by-id symlinks). After physically moving the
# sticks to edge-1, verify with `ls -l /dev/serial/by-id/` — the by-id
# names travel with the device, not the host, so these should not change.
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
edge_stromzaehler_device: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0
# --- Images. Versions mirror the k8s manifests at migration time.
# sml2mqtt stays hand-pinned (critical meter bridge — was deliberately
# excluded from image-automation in k8s, keep that property here).
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
edge_sml2mqtt_image: spacemanspiff2007/sml2mqtt:3.6.0
# Alpine version the zwave-js-ui image is built on — the source for the musl
# libeatmydata.so preloaded into that container (see tasks "Extract musl
# libeatmydata.so"). Keep in sync with the base if edge_zwavejs_image bumps
# its Alpine major (check: docker exec zwave-js cat /etc/os-release).
edge_zwave_alpine_base: alpine:3.22
# --- CAN / open3e (Viessmann E3 external bus) ---
edge_can_interface: can0
edge_can_bitrate: 250000
edge_open3e_pkg: git+https://github.com/open3e/open3e.git
edge_open3e_mqtt_user: open3e
edge_open3e_mqtt_topic: open3e
edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}"
edge_open3e_poll_seconds: 30
# Read-only datapoint set (Phase 1 extended set, established 2026-07-02 on
# the commissioning workstation; see vican repo CLAUDE.md for the rationale).
# NO -w / write DIDs here — live heating + battery, writes are Phase 3.
# vx3.266 = ErrorDtcHistory (O3EList) — the VX3 fault log; polled to catch
# recurrences of FailureCommunicationEnergyMeter (the E380 energy-meter link
# the internal PV-surplus EMS depends on). Decodes via the master O3EList
# codec inherited through the vx3plus overlay.
# vitocal.543/2350/2543/2560: SmartGrid-/EMS-Status-Beobachtung (2026-07-06)
# — klärt, ob die Eigenstromoptimierung (EMS) bei PV-Überschuss von selbst
# den WW-Boost auslöst (2543.2 steht laut Scan bereits auf +10 K), bevor
# SG-Ready-Verdrahtung oder andere Trigger gebaut werden. Alle vier ro.
# vitocal.259/260 = InfoDtcList/InfoDtcHistory (O3EList) — the HPMU event log,
# the Vitocal-side counterpart to vx3.266. Both are capped lists (2 and 9
# entries when added), so the multi-frame read is cheap at a 30 s poll.
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
edge_open3e_datapoints: >-
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791
# ECU addresses; -dev selects codec set, tx selects the UDS address.
# vcmu (refrigerant-circuit controller) has no dedicated codec set —
# omitting dpList makes open3e fall back to the generic datapoint list,
# which decodes all polled VCMU DIDs.
edge_open3e_devices:
vitocal: { tx: "0x680", dpList: "vcal" }
# dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
vx3: { tx: "0x6a1", dpList: "/opt/edge-host/open3e/vx3plus.py" }
vcmu: { tx: "0x68c" }
# --- E3onCAN: passive E380 energy-meter listener (no bus writes, so it
# coexists with the single open3e instance). The meter broadcasts at
# ~5 Hz; the gap throttles MQTT to one update per DID per interval.
edge_e3oncan_repo: https://github.com/MyHomeMyData/E3onCAN.git
edge_e3oncan_version: 461dcbdf97e8d72d2edb9929c63b39382af6c3a1 # V0.5.2
edge_e3oncan_gap_seconds: 10
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
# --- Secrets: define these in inventory/group_vars/edge_host/vault.yml
# (ansible-vault). See README.md for the kubectl one-liners that read the
# current values out of the cluster during migration.
# edge_sml2mqtt_mqtt_password: "{{ vault_edge_sml2mqtt_mqtt_password }}"
# edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}"
# edge_zwavejs_session_secret: "{{ vault_edge_zwavejs_session_secret }}"
# --- Tagged VLAN 40 sub-interface. Off by default; enabled in
# inventory/group_vars/edge_host. Exists so edge-1 can join the NTP keepalived
# VRRP group, which needs layer-2 adjacency with the other members on the
# service VLAN. The untagged VLAN 30 lease stays this host's only default
# route -- see templates/10-vlan40.network.j2.
edge_vlan40_enabled: false
edge_vlan40_parent: enp1s0
edge_vlan40_id: 40
edge_vlan40_name: enp1s0.40
edge_vlan40_parent_network: 20-wired.network
-66
View File
@@ -1,66 +0,0 @@
---
# reload alone only re-reads config files; existing links keep their old
# state until reconfigured.
- name: Reload networkd
ansible.builtin.shell:
cmd: networkctl reload && networkctl reconfigure {{ edge_can_interface }}
changed_when: true
# Reconfiguring the parent is what actually materialises the VLAN child -- a
# bare reload leaves the existing link as-is (see above). This re-runs DHCP on
# enp1s0, which is this headless host's only path to the network, so it is the
# one genuinely risky step in the VLAN change.
- name: Reload networkd for VLAN
ansible.builtin.shell:
cmd: networkctl reload && networkctl reconfigure {{ edge_vlan40_parent }}
changed_when: true
# Ordered before 'Restart edge compose services': the daemon must pick up the
# new default log driver before the containers are recreated onto it.
- name: Restart docker
ansible.builtin.service:
name: docker
state: restarted
- name: Restart journald
ansible.builtin.service:
name: systemd-journald
state: restarted
# Handlers run in definition order, so this fires after 'Restart journald':
# once the journal is volatile, the on-disk store is dead weight on the eMMC.
- name: Purge persistent journal store
ansible.builtin.file:
path: /var/log/journal
state: absent
- name: Restart alloy
ansible.builtin.systemd:
name: edge-alloy
state: restarted
daemon_reload: true
# Ordered before the MQTT clients (compose/open3e/e3oncan) so they reconnect
# to a freshly-configured local broker.
- name: Restart mosquitto
ansible.builtin.service:
name: mosquitto
state: restarted
- name: Restart edge compose services
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ edge_base_dir }}"
changed_when: true
- name: Restart open3e
ansible.builtin.systemd:
name: open3e
state: restarted
daemon_reload: true
- name: Restart e3oncan
ansible.builtin.systemd:
name: e3oncan
state: restarted
daemon_reload: true
-440
View File
@@ -1,440 +0,0 @@
---
# Arch edge host: zwave-js-ui + sml2mqtt as docker compose services,
# open3e natively (venv + systemd) because it needs a SocketCAN interface,
# which is host network state, not something to hide in a container.
# Docker itself comes from the docker-install role (Arch-aware).
- name: Install base packages
ansible.builtin.package:
name:
- python
- python-packaging # ansible's pip module imports it on the target
- git
- mmc-utils # eMMC lifetime/EOL introspection (mmc extcsd read)
- grafana-alloy # log shipper -> Loki (see Alloy section below)
- mosquitto # local MQTT bridge broker -> cluster over TLS
state: present
# --- Local MQTT bridge broker. Edge clients publish to this over loopback;
# it bridges to the cluster broker over TLS (open3e/e3oncan can't do MQTTS).
# Configured with persistence off + stdout logging so it writes nothing to the
# eMMC. Deployed before the clients so they have somewhere to connect.
# See edge-mqtt-bridge.conf.j2 / README "MQTT bridge".
- name: Deploy local mosquitto bridge config
ansible.builtin.template:
src: edge-mqtt-bridge.conf.j2
dest: /etc/mosquitto/mosquitto.conf
owner: root
group: mosquitto
mode: "0640" # carries the cluster bridge credentials
notify: Restart mosquitto
- name: Enable local mosquitto bridge
ansible.builtin.systemd:
name: mosquitto
enabled: true
state: started
# ======================================================================
# eMMC wear mitigation + log shipping. edge-1 (Dell Wyse 3040) runs on a
# small internal eMMC, so logs must not churn local flash. Strategy:
# - journald -> volatile (RAM), size-capped (journald-edge.conf)
# - docker -> journald log driver, no json files (docker-daemon.json)
# - Grafana Alloy tails the RAM journal -> Loki, WAL on tmpfs
# plus TRIM, noatime and a weekly wear check for flash longevity.
# See roles/edge-host/README.md "Logging & eMMC".
# ======================================================================
- name: Ensure journald drop-in directory exists
ansible.builtin.file:
path: /etc/systemd/journald.conf.d
state: directory
mode: "0755"
- name: Switch the systemd journal to volatile (RAM) storage
ansible.builtin.template:
src: journald-edge.conf.j2
dest: /etc/systemd/journald.conf.d/00-edge-emmc.conf
mode: "0644"
notify:
- Restart journald
- Purge persistent journal store
- name: Resolve root filesystem facts (for noatime)
ansible.builtin.set_fact:
edge_root_fs: "{{ ansible_facts.mounts | selectattr('mount', 'equalto', '/') | first }}"
- name: Refuse to touch fstab without a stable root UUID
ansible.builtin.assert:
that:
- edge_root_fs.uuid | default('') | length > 0
fail_msg: "Could not determine root UUID from facts; not rewriting fstab."
# lazytime batches inode-timestamp writeback (e.g. the zwave-js lockfile
# heartbeat that otherwise dirties the journal every couple seconds); noatime
# drops access-time writes. Neither weakens data durability.
- name: Mount root with noatime + lazytime (persisted in fstab)
ansible.posix.mount:
path: /
src: "UUID={{ edge_root_fs.uuid }}"
fstype: "{{ edge_root_fs.fstype }}"
opts: "{{ ((edge_root_fs.options.split(',') | reject('in', ['atime', 'noatime', 'relatime', 'diratime', 'lazytime', 'nolazytime']) | list) + ['noatime', 'lazytime']) | unique | join(',') }}"
dump: "0"
passno: "1" # root fs — keep boot-time fsck ordering
backup: true
state: mounted
- name: Enable periodic TRIM (helps eMMC wear-leveling)
ansible.builtin.systemd:
name: fstrim.timer
enabled: true
state: started
- name: Install eMMC health-check script
ansible.builtin.copy:
src: edge-emmc-health
dest: /usr/local/bin/edge-emmc-health
mode: "0755"
- name: Install eMMC health-check service + timer
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- edge-emmc-health.service
- edge-emmc-health.timer
- name: Enable eMMC health-check timer
ansible.builtin.systemd:
name: edge-emmc-health.timer
enabled: true
state: started
daemon_reload: true
# --- Grafana Alloy: ship the volatile journal to Loki. Container logs reach
# the journal via docker's journald driver (configured below), native
# services log to the journal directly, so a single journal source covers
# everything. Storage/WAL sits on /run (tmpfs) — see edge-alloy.service.
- name: Create alloy system group
ansible.builtin.group:
name: alloy
system: true
- name: Create alloy system user
ansible.builtin.user:
name: alloy
group: alloy
groups: systemd-journal
system: true
create_home: false
shell: /usr/bin/nologin
- name: Create alloy config directory
ansible.builtin.file:
path: /etc/alloy
state: directory
owner: alloy
group: alloy
mode: "0750"
- name: Deploy alloy config (journal -> Loki)
ansible.builtin.template:
src: edge-alloy.alloy.j2
dest: "{{ edge_alloy_config_file }}"
owner: alloy
group: alloy
mode: "0600" # carries the Loki basic-auth password
notify: Restart alloy
- name: Deploy alloy systemd unit
ansible.builtin.template:
src: edge-alloy.service.j2
dest: /etc/systemd/system/edge-alloy.service
mode: "0644"
notify: Restart alloy
- name: Enable edge-alloy
ansible.builtin.systemd:
name: edge-alloy
enabled: true
state: started
daemon_reload: true
# --- SocketCAN: systemd-networkd owns can0. gs_usb is an in-tree module
# and autoloads when the Innomaker adapter enumerates; networkd then
# matches the interface and applies bitrate + up. RestartSec recovers the
# link after bus-off without manual intervention.
- name: Configure can0 via systemd-networkd
ansible.builtin.template:
src: 80-can0.network.j2
dest: /etc/systemd/network/80-can0.network
mode: "0644"
notify: Reload networkd
# --- Tagged VLAN 40. Additive by construction: the parent link keeps its own
# .network file untouched and gains the VLAN via a drop-in, so reverting is a
# matter of deleting these three paths. This host is headless and enp1s0 is its
# only route to the network, hence the caution.
- name: Create VLAN sub-interface netdev
ansible.builtin.template:
src: 10-vlan40.netdev.j2
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.netdev"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Configure VLAN sub-interface addressing
ansible.builtin.template:
src: 10-vlan40.network.j2
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.network"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Ensure parent network drop-in directory exists
ansible.builtin.file:
path: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d"
state: directory
mode: "0755"
when: edge_vlan40_enabled
tags: [vlan40]
- name: Attach VLAN to parent link via drop-in
ansible.builtin.template:
src: vlan40-parent-dropin.conf.j2
dest: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d/10-vlan40.conf"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Enable systemd-networkd
ansible.builtin.service:
name: systemd-networkd
state: started
enabled: true
# Docker networking needs no daemon.json (both services use network_mode:
# host — real addresses, v4+v6, no bridge/NAT). The daemon.json below exists
# only to route container stdout into the journald log driver so json logs
# never touch the eMMC; the journal itself is volatile (RAM) and shipped to
# Loki by edge-alloy. Both compose services inherit this default driver.
- name: Configure docker daemon (journald log driver, eMMC-friendly)
ansible.builtin.template:
src: docker-daemon.json.j2
dest: /etc/docker/daemon.json
mode: "0644"
notify:
- Restart docker
- Restart edge compose services
# --- Directory layout ---
- name: Create service directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ edge_base_dir }}"
- "{{ edge_base_dir }}/zwave-js/store"
- "{{ edge_base_dir }}/sml2mqtt"
# --- zwave-js eMMC write reduction: batch fsync via libeatmydata ---------
# zwave-js persists its value DB with an fsync on *every* value change
# (jsonl-db, throttleFS unset by zwave-js-ui). On a chatty network that
# fsync storm churns the eMMC (~2.6 GiB/day for ~0.1 KiB/s of real data).
# Preloading libeatmydata makes fsync a no-op so the kernel batches the
# writes via normal writeback — full HA data resolution and cache
# persistence are untouched (see docker-compose.yml.j2 for the mount + env).
# The .so must match the zwave-js-ui image's musl/Alpine base, so extract it
# from that exact Alpine version (bridge networking is broken on this host,
# hence --network=host). Refresh by deleting the .so if the base ever bumps
# its Alpine major version.
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
ansible.builtin.shell:
cmd: |
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
install -m 0644 "$tmp" {{ edge_base_dir }}/zwave-js/libeatmydata.so
creates: "{{ edge_base_dir }}/zwave-js/libeatmydata.so"
notify: Restart edge compose services
# Because fsync is neutralized, a hard power-loss during the (rare) settings
# write could truncate settings.json — which holds the Z-Wave network keys.
# Keep a validated rolling backup on the eMMC so the keys stay recoverable.
- name: Install zwave-js settings.json backup script
ansible.builtin.template:
src: edge-zwave-settings-backup.j2
dest: /usr/local/bin/edge-zwave-settings-backup
mode: "0755"
- name: Install zwave-js settings backup service + timer
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- edge-zwave-settings-backup.service
- edge-zwave-settings-backup.timer
- name: Enable zwave-js settings backup timer
ansible.builtin.systemd:
name: edge-zwave-settings-backup.timer
enabled: true
state: started
daemon_reload: true
# --- Compose services: zwave-js-ui + sml2mqtt ---
- name: Deploy docker-compose.yml
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ edge_base_dir }}/docker-compose.yml"
mode: "0644"
notify: Restart edge compose services
- name: Deploy zwave-js env (session secret)
ansible.builtin.template:
src: zwavejs.env.j2
dest: "{{ edge_base_dir }}/zwavejs.env"
mode: "0600"
notify: Restart edge compose services
# sml2mqtt reads only a config file and has no env override for the
# password, so the rendered config carries it — 0600, root-owned, same
# trust boundary as the k8s init-container approach it replaces.
- name: Deploy sml2mqtt config
ansible.builtin.template:
src: sml2mqtt-config.yml.j2
dest: "{{ edge_base_dir }}/sml2mqtt/config.yml"
mode: "0600"
notify: Restart edge compose services
- name: Pull compose images
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ edge_base_dir }}"
changed_when: false
# Tolerates missing USB devices: before the physical cutover the ZWA-2 /
# FTDI sticks are still on the old k8s-edge VM, and a container whose
# device is absent must not block the rest of the role (open3e/CAN moves
# first). Re-run the play — or `docker compose up -d` — after each stick
# arrives.
- name: Start compose services
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ edge_base_dir }}"
register: compose_up
failed_when: >-
compose_up.rc != 0
and 'error gathering device information' not in compose_up.stderr
changed_when: >-
'Started' in compose_up.stderr or 'Created' in compose_up.stderr
or 'Started' in compose_up.stdout or 'Creating' in compose_up.stdout
- name: Warn about containers waiting for their USB device
ansible.builtin.debug:
msg: >-
docker compose could not start every service (USB device not present
yet — expected before cutover): {{ compose_up.stderr_lines | select('search', 'device') | list }}
when: compose_up.rc != 0
# --- open3e: native install. CAN_RAW sockets need no capabilities, so
# the service runs as an unprivileged system user.
- name: Create open3e system user
ansible.builtin.user:
name: open3e
system: true
create_home: false
shell: /usr/bin/nologin
# Owned by the service user: udsoncan writes its log file into the
# working directory.
- name: Create open3e directory
ansible.builtin.file:
path: "{{ edge_base_dir }}/open3e"
state: directory
owner: open3e
group: open3e
mode: "0755"
- name: Install open3e into a venv
ansible.builtin.pip:
name: "{{ edge_open3e_pkg }}"
virtualenv: "{{ edge_base_dir }}/open3e/venv"
virtualenv_command: python -m venv
notify: Restart open3e
- name: Deploy open3e vx3 datapoint overlay (battery-module DIDs)
ansible.builtin.copy:
src: open3e-vx3plus.py
dest: "{{ edge_base_dir }}/open3e/vx3plus.py"
mode: "0644"
notify: Restart open3e
- name: Deploy open3e devices.json
ansible.builtin.copy:
content: "{{ edge_open3e_devices | to_nice_json }}\n"
dest: "{{ edge_base_dir }}/open3e/devices.json"
mode: "0644"
notify: Restart open3e
- name: Deploy open3e environment (broker credential)
ansible.builtin.template:
src: open3e.env.j2
dest: "{{ edge_base_dir }}/open3e/open3e.env"
owner: open3e
mode: "0600"
notify: Restart open3e
- name: Deploy open3e systemd unit
ansible.builtin.template:
src: open3e.service.j2
dest: /etc/systemd/system/open3e.service
mode: "0644"
notify: Restart open3e
- name: Enable open3e
ansible.builtin.systemd:
name: open3e
enabled: true
state: started
daemon_reload: true
# --- E3onCAN: direct E380 meter reads (passive; per-review decision not
# to trust the VX3's relayed meter values). Same broker user as open3e;
# topics land under the same open3e/ prefix as e380_<did>_<name>.
- name: Check out E3onCAN (pinned)
ansible.builtin.git:
repo: "{{ edge_e3oncan_repo }}"
dest: "{{ edge_base_dir }}/e3oncan/src"
version: "{{ edge_e3oncan_version }}"
notify: Restart e3oncan
- name: Create E3onCAN venv with requirements
ansible.builtin.pip:
requirements: "{{ edge_base_dir }}/e3oncan/src/requirements.txt"
virtualenv: "{{ edge_base_dir }}/e3oncan/venv"
virtualenv_command: python -m venv
notify: Restart e3oncan
- name: Deploy e3oncan systemd unit
ansible.builtin.template:
src: e3oncan.service.j2
dest: /etc/systemd/system/e3oncan.service
mode: "0644"
notify: Restart e3oncan
- name: Enable e3oncan
ansible.builtin.systemd:
name: e3oncan
enabled: true
state: started
daemon_reload: true
@@ -1,56 +0,0 @@
# Managed by ansible (roles/edge-host). Mirrors the former k8s manifests:
# kubernetes/apps/home-assistant-zwavejs/deployment.yaml and
# kubernetes/apps/home-assistant/sml2mqtt-deployment.yaml.
#
# No `privileged` needed here: docker's --device handles the device-cgroup
# grant that hostPath in k8s could not (that was the only reason the pods
# ran privileged).
# Both services use host networking: real host addresses (v4+v6), no
# bridge, no NAT. The broker LB is IPv6-first and docker bridge NAT66 is
# a tarpit (unmasqueraded SYNs die silently); the host stack just works.
services:
zwave-js:
image: {{ edge_zwavejs_image }}
container_name: zwave-js
restart: unless-stopped
stop_signal: SIGINT
tty: true
environment:
- TZ={{ edge_tz }}
# Batch the value-DB fsync storm to spare the eMMC (see role tasks /
# README "Logging & eMMC"). Neutralizes fsync only for this container;
# the WS/MQTT value stream to HA and cache persistence are unchanged.
- LD_PRELOAD=/usr/lib/libeatmydata.so
env_file:
- zwavejs.env
devices:
- "{{ edge_zwave_device }}:/dev/zwave"
volumes:
# musl libeatmydata.so extracted from the image's Alpine base (ro)
- ./zwave-js/libeatmydata.so:/usr/lib/libeatmydata.so:ro
# Persistent store: settings.json, device DB, network keys. Seeded
# from the k8s PVC `zwavejs-store` at cutover (see README). Since
# this host is stateful there is no init-container merge step —
# settings.json is edited via the UI and persists here; zwave.port
# stays /dev/zwave.
- ./zwave-js/store:/usr/src/app/store
# Host networking: binds 3000 (Z-Wave JS websocket for HA) and 8091
# (web UI) directly on the host.
network_mode: host
sml2mqtt:
image: {{ edge_sml2mqtt_image }}
container_name: sml2mqtt
restart: unless-stopped
# Outbound-only (serial in, MQTT out) — host networking sidesteps the
# docker NAT66 gap: the broker LB is IPv6-first and bridge-network v6
# SYNs leave unmasqueraded and die. The host stack reaches fd99::81
# fine (open3e proves it continuously).
network_mode: host
# Serial exclusivity: this is the ONLY reader of the meter port —
# HA's edl21 integration stays disabled (see sml2mqtt-config comment
# history in the k8s repo).
devices:
- "{{ edge_stromzaehler_device }}:/dev/stromzaehler"
volumes:
- ./sml2mqtt/config.yml:/sml2mqtt/config.yml:ro
@@ -1,3 +0,0 @@
{
"log-driver": "{{ edge_docker_log_driver }}"
}
@@ -1,32 +0,0 @@
# Managed by ansible (roles/edge-host).
# PASSIVE listener for the E380 energy meter's CANopen broadcasts — no
# writes on the bus, so it may run alongside the (single) open3e
# instance. Reads the meter directly instead of trusting the VX3's
# relayed values.
[Unit]
Description=E3onCAN E380 energy-meter CAN listener (read-only)
BindsTo=sys-subsystem-net-devices-{{ edge_can_interface }}.device
# mosquitto.service = the local MQTT bridge broker e3oncan publishes to (loopback).
After=sys-subsystem-net-devices-{{ edge_can_interface }}.device network-online.target mosquitto.service
Wants=network-online.target mosquitto.service
[Service]
User=open3e
EnvironmentFile={{ edge_base_dir }}/open3e/open3e.env
WorkingDirectory={{ edge_base_dir }}/e3oncan
# Journald-friendly (python block-buffers pipes; lost on restarts).
Environment=PYTHONUNBUFFERED=1
ExecStart={{ edge_base_dir }}/e3oncan/venv/bin/python {{ edge_base_dir }}/e3oncan/src/E3onCANcollect.py \
-c {{ edge_can_interface }} \
-dev e380 \
-g {{ edge_e3oncan_gap_seconds }} \
-m {{ edge_mqtt_host }}:{{ edge_mqtt_port }}:{{ edge_open3e_mqtt_topic }} \
-muser {{ edge_open3e_mqtt_user }} \
-mpass ${OPEN3E_MQTT_PASSWORD} \
-mfstr "{{ edge_e3oncan_mqtt_format }}" \
-j
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -1,7 +0,0 @@
# Managed by ansible (roles/edge-host). Read by open3e.service and
# e3oncan.service.
# Full user:password pair as one variable so the open3e unit can pass it
# as a single -muser argument without exposing it in the unit file;
# e3oncan takes user and password separately (-muser/-mpass).
OPEN3E_MUSER={{ edge_open3e_mqtt_user }}:{{ edge_open3e_mqtt_password }}
OPEN3E_MQTT_PASSWORD={{ edge_open3e_mqtt_password }}
@@ -1,39 +0,0 @@
# Managed by ansible (roles/edge-host).
# Read-mostly listener — no -w flag, no service 0x77. The ONE sanctioned
# write path is the MQTT command topic (-l): HA publishes the DHW
# one-time-charge command there (scope decision 2026-07-02, see vican
# CLAUDE.md). Anything beyond that single write gets its own review.
# -j is load-bearing: without it open3e flattens JSON payloads into
# per-key subtopics and the HA package (homeassistant repo,
# packages/open3e.yaml) receives nothing.
# Exactly ONE open3e instance may be on the bus at a time (CAN collisions
# stop open3e) — stop this service before running ad-hoc scans.
[Unit]
Description=open3e Viessmann E3 CAN-to-MQTT bridge (read-only)
# Tie the service to the CAN device: stops on unplug, starts on plug via
# the device unit rather than polling.
BindsTo=sys-subsystem-net-devices-{{ edge_can_interface }}.device
# mosquitto.service = the local MQTT bridge broker open3e publishes to (loopback).
After=sys-subsystem-net-devices-{{ edge_can_interface }}.device network-online.target mosquitto.service
Wants=network-online.target mosquitto.service
[Service]
User=open3e
EnvironmentFile={{ edge_base_dir }}/open3e/open3e.env
WorkingDirectory={{ edge_base_dir }}/open3e
ExecStart={{ edge_base_dir }}/open3e/venv/bin/open3e \
-c {{ edge_can_interface }} \
-cnfg {{ edge_base_dir }}/open3e/devices.json \
-m {{ edge_mqtt_host }}:{{ edge_mqtt_port }}:{{ edge_open3e_mqtt_topic }} \
-muser ${OPEN3E_MUSER} \
-mcid open3e-edge \
-mfstr "{{ edge_open3e_mqtt_format }}" \
-r {{ edge_open3e_datapoints }} \
-t {{ edge_open3e_poll_seconds }} \
-l {{ edge_open3e_mqtt_topic }}/cmnd \
-j
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,15 @@
---
# Journal -> Loki shipping.
#
# The srv-network Loki (loki.srv.renner.uno:3100, no auth) is firewalled off
# the IoT VLAN; the only endpoint these hosts can reach is the authenticated
# cluster ingress. edge_loki_password comes from the vault.
edge_alloy_config_file: /etc/alloy/edge.alloy
edge_alloy_http_port: 12345
edge_alloy_bin: /usr/bin/grafana-alloy # Arch `grafana-alloy` pkg names the binary this, not `alloy`
edge_loki_push_url: "https://loki.renner.uno/loki/api/v1/push"
edge_loki_username: edge-1
edge_alloy_labels:
environment: production
datacenter: homelab
@@ -0,0 +1,6 @@
---
- name: Restart alloy
ansible.builtin.systemd:
name: edge-alloy
state: restarted
daemon_reload: true
@@ -0,0 +1,57 @@
---
# Ship the systemd journal to Loki with Grafana Alloy.
#
# Pairs with edge-flash-wear, which makes the journal volatile: container logs
# reach the journal via docker s journald driver and native services log to it
# directly, so one journal source covers everything. Alloy s storage/WAL sits
# on /run (tmpfs) so it does not write to flash either.
- name: Install grafana-alloy
ansible.builtin.package:
name: grafana-alloy
state: present
- name: Create alloy system group
ansible.builtin.group:
name: alloy
system: true
- name: Create alloy system user
ansible.builtin.user:
name: alloy
group: alloy
groups: systemd-journal
system: true
create_home: false
shell: /usr/bin/nologin
- name: Create alloy config directory
ansible.builtin.file:
path: /etc/alloy
state: directory
owner: alloy
group: alloy
mode: "0750"
- name: Deploy alloy config (journal -> Loki)
ansible.builtin.template:
src: edge-alloy.alloy.j2
dest: "{{ edge_alloy_config_file }}"
owner: alloy
group: alloy
mode: "0600" # carries the Loki basic-auth password
notify: Restart alloy
- name: Deploy alloy systemd unit
ansible.builtin.template:
src: edge-alloy.service.j2
dest: /etc/systemd/system/edge-alloy.service
mode: "0644"
notify: Restart alloy
- name: Enable edge-alloy
ansible.builtin.systemd:
name: edge-alloy
enabled: true
state: started
daemon_reload: true
@@ -1,4 +1,4 @@
// Grafana Alloy — edge-1 log shipper. Managed by roles/edge-host.
// Grafana Alloy — edge-1 log shipper. Managed by roles/edge-log-shipper.
//
// edge-1 runs on internal eMMC, so logs are kept out of local flash: the
// systemd journal is volatile (RAM, see journald-edge.conf) and container
+11
View File
@@ -0,0 +1,11 @@
---
# --- Tagged VLAN 40 sub-interface. Off by default; enabled in
# inventory/group_vars/edge_host. Exists so edge-1 can join the NTP keepalived
# VRRP group, which needs layer-2 adjacency with the other members on the
# service VLAN. The untagged VLAN 30 lease stays this host's only default
# route -- see templates/10-vlan40.network.j2.
edge_vlan40_enabled: false
edge_vlan40_parent: enp1s0
edge_vlan40_id: 40
edge_vlan40_name: enp1s0.40
edge_vlan40_parent_network: 20-wired.network
@@ -0,0 +1,9 @@
---
# Reconfiguring the parent is what actually materialises the VLAN child -- a
# bare reload leaves the existing link as-is. This re-runs DHCP on the parent,
# which is a headless host's only path to the network, so it is the one
# genuinely risky step in the VLAN change.
- name: Reload networkd for VLAN
ansible.builtin.shell:
cmd: networkctl reload && networkctl reconfigure {{ edge_vlan40_parent }}
changed_when: true
+48
View File
@@ -0,0 +1,48 @@
---
# Tagged VLAN sub-interface via systemd-networkd.
#
# Additive by construction: the parent link keeps its own .network file and
# gains the VLAN through a drop-in, so reverting is deleting three paths. These
# hosts are headless and the parent is their only route to the network, hence
# the caution.
- name: Create VLAN sub-interface netdev
ansible.builtin.template:
src: 10-vlan40.netdev.j2
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.netdev"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Configure VLAN sub-interface addressing
ansible.builtin.template:
src: 10-vlan40.network.j2
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.network"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Ensure parent network drop-in directory exists
ansible.builtin.file:
path: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d"
state: directory
mode: "0755"
when: edge_vlan40_enabled
tags: [vlan40]
- name: Attach VLAN to parent link via drop-in
ansible.builtin.template:
src: vlan40-parent-dropin.conf.j2
dest: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d/10-vlan40.conf"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Enable systemd-networkd
ansible.builtin.service:
name: systemd-networkd
state: started
enabled: true
@@ -0,0 +1,60 @@
---
# Heating system over the Viessmann E3 CAN bus: open3e (heat pump) and
# e3oncan (E380 energy meter). The can0 interface itself is the socketcan
# role.
# Service root. Kept per-role so the role is usable on its own.
edge_base_dir: /opt/edge-host
edge_open3e_mqtt_user: open3e
edge_open3e_mqtt_topic: open3e
edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}"
edge_open3e_poll_seconds: 30
# Read-only datapoint set (Phase 1 extended set, established 2026-07-02 on
# the commissioning workstation; see vican repo CLAUDE.md for the rationale).
# NO -w / write DIDs here — live heating + battery, writes are Phase 3.
# vx3.266 = ErrorDtcHistory (O3EList) — the VX3 fault log; polled to catch
# recurrences of FailureCommunicationEnergyMeter (the E380 energy-meter link
# the internal PV-surplus EMS depends on). Decodes via the master O3EList
# codec inherited through the vx3plus overlay.
# vitocal.543/2350/2543/2560: SmartGrid-/EMS-Status-Beobachtung (2026-07-06)
# — klärt, ob die Eigenstromoptimierung (EMS) bei PV-Überschuss von selbst
# den WW-Boost auslöst (2543.2 steht laut Scan bereits auf +10 K), bevor
# SG-Ready-Verdrahtung oder andere Trigger gebaut werden. Alle vier ro.
# vitocal.259/260 = InfoDtcList/InfoDtcHistory (O3EList) — the HPMU event log,
# the Vitocal-side counterpart to vx3.266. Both are capped lists (2 and 9
# entries when added), so the multi-frame read is cheap at a 30 s poll.
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
edge_open3e_datapoints: >-
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791
# ECU addresses; -dev selects codec set, tx selects the UDS address.
# vcmu (refrigerant-circuit controller) has no dedicated codec set —
# omitting dpList makes open3e fall back to the generic datapoint list,
# which decodes all polled VCMU DIDs.
edge_open3e_devices:
vitocal: { tx: "0x680", dpList: "vcal" }
# dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
vx3: { tx: "0x6a1", dpList: "/config/vx3plus.py" }
vcmu: { tx: "0x68c" }
edge_e3oncan_gap_seconds: 10
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
# Secrets come from inventory/group_vars/edge_host/vault.yml:
# edge_open3e_mqtt_password
# The image carrying both readers, built and pinned in CI
# (ci/images/heating-can/Containerfile). The source pins live in the
# Containerfile, in the artifact -- not here -- so what is deployed is a tag,
# and `docker compose up -d` on an unchanged tag is a genuine no-op.
#
# Tracked by the weekly update-audit as an ordinary image, which is why this is
# simpler to keep current than the two git refs it replaced: one publishes no
# tags at all, the other was pinned to an untagged commit between releases.
heating_can_image: gitea.renner.uno/gitea_admin/heating-can:latest
# Compose project directory. Separate from the old native layout under
# {{ edge_base_dir }}/open3e, which this role removes.
heating_can_base_dir: "{{ edge_base_dir }}/heating-can"
@@ -1,4 +1,4 @@
# Managed by ansible (roles/edge-host). Datapoint overlay for the VX3:
# Managed by ansible (roles/heating-can). Datapoint overlay for the VX3:
# the stock vx3 list acts as a FILTER (DIDs missing from it are popped
# from the master table, Open3Eclass.py), so the per-battery-module DIDs
# were never pollable with dpList "vx3". This overlay re-adds them with
@@ -0,0 +1,10 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart heating-can
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ heating_can_base_dir }}"
changed_when: true
+119
View File
@@ -0,0 +1,119 @@
---
# Heating system over the Viessmann E3 CAN bus.
#
# Two readers of the same heating installation, supervised inside one container
# from one image (ci/images/heating-can):
# open3e UDS request/response to the Vitocal heat pump and Vitocharge VX3
# e3oncan passive CANopen listener for the E380 grid energy meter, which
# feeds the heat pump's own energy management
#
# They coexist because e3oncan issues no bus writes.
#
# Nothing is installed here. The image is built in CI with both sources pinned,
# published to the registry, and this host only pulls and runs it. That is what
# removes the churn: these were pip-installed into venvs on the node, and pip
# re-cloned and rebuilt a VCS requirement on every converge -- restarting the
# heating bridge each time -- which pinning the SHA did not fix.
#
# network_mode: host is required, not incidental: SocketCAN interfaces live in
# the host network namespace, so can0 is only reachable that way. It is also how
# the local MQTT broker on 127.0.0.1 is reached. CAN_RAW needs no capabilities,
# so both run unprivileged.
#
# The can0 interface itself belongs to the socketcan role; this role consumes it.
- name: Create heating-can directory
ansible.builtin.file:
path: "{{ heating_can_base_dir }}"
state: directory
mode: "0755"
# --- Migration off the native install ---------------------------------------
# Must happen before the containers start. Two open3e instances issuing UDS
# requests on one CAN bus is the failure mode to avoid, so the old units are
# stopped and masked rather than merely disabled -- masked so a stray
# `systemctl start` or a leftover dependency cannot bring them back alongside
# the containers.
- name: Stop and mask the native CAN services
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
enabled: false
masked: true
loop:
- open3e
- e3oncan
failed_when: false
- name: Remove the native unit files
ansible.builtin.file:
path: "/etc/systemd/system/{{ item }}.service"
state: absent
loop:
- open3e
- e3oncan
notify: Reload systemd
# The venvs are the point of this change -- leaving them would leave the thing
# that churned. Their configuration is NOT removed: devices.json, the overlay
# and the env file are still rendered below and bind-mounted into the
# containers.
- name: Remove the native venvs
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "{{ edge_base_dir }}/open3e/venv"
- "{{ edge_base_dir }}/e3oncan/venv"
- "{{ edge_base_dir }}/e3oncan/src"
- "{{ edge_base_dir }}/open3e"
- "{{ edge_base_dir }}/e3oncan"
# --- Configuration (bind-mounted into the containers) ------------------------
- name: Deploy open3e devices.json
ansible.builtin.copy:
content: "{{ edge_open3e_devices | to_nice_json }}\n"
dest: "{{ heating_can_base_dir }}/devices.json"
mode: "0644"
notify: Restart heating-can
- name: Deploy open3e vx3 datapoint overlay (battery-module DIDs)
ansible.builtin.copy:
src: open3e-vx3plus.py
dest: "{{ heating_can_base_dir }}/vx3plus.py"
mode: "0644"
notify: Restart heating-can
# --- Compose project ---------------------------------------------------------
- name: Deploy the broker credential env file
ansible.builtin.template:
src: heating-can.env.j2
dest: "{{ heating_can_base_dir }}/heating-can.env"
mode: "0600"
notify: Restart heating-can
- name: Deploy heating-can compose project
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ heating_can_base_dir }}/docker-compose.yml"
mode: "0644"
notify: Restart heating-can
- name: Pull the heating-can image
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ heating_can_base_dir }}"
changed_when: false
# Unlike the zwave and meter services there is no missing-device tolerance here:
# can0 is created by the socketcan role from a USB adapter that is part of this
# host's purpose. If it is absent the heating telemetry is down and the run
# should say so rather than pass quietly.
- name: Start heating-can services
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ heating_can_base_dir }}"
register: heating_can_up
changed_when: >-
'Started' in heating_can_up.stderr or 'Created' in heating_can_up.stderr
or 'Started' in heating_can_up.stdout or 'Creating' in heating_can_up.stdout
@@ -0,0 +1,35 @@
# Managed by ansible (roles/heating-can).
#
# One container. Both CAN readers live in the image and are supervised inside
# it -- this file supplies configuration and nothing else. It does not know
# open3e's or E3onCAN's command line.
#
# network_mode: host is required: SocketCAN interfaces live in the host network
# namespace, so {{ edge_can_interface }} is only reachable this way, and it is
# also how the local MQTT broker is reached. CAN_RAW needs no capabilities, so
# the image runs unprivileged.
services:
heating-can:
image: {{ heating_can_image }}
container_name: heating-can
restart: unless-stopped
network_mode: host
volumes:
- ./devices.json:/config/devices.json:ro
- ./vx3plus.py:/config/vx3plus.py:ro
env_file:
# Broker credential, 0600 -- see heating-can.env.j2.
- heating-can.env
environment:
CAN_INTERFACE: "{{ edge_can_interface }}"
MQTT_HOST: "{{ edge_mqtt_host }}"
MQTT_PORT: "{{ edge_mqtt_port }}"
MQTT_TOPIC: "{{ edge_open3e_mqtt_topic }}"
MQTT_FORMAT: "{{ edge_open3e_mqtt_format }}"
E3ONCAN_MQTT_FORMAT: "{{ edge_e3oncan_mqtt_format }}"
OPEN3E_CONFIG: /config/devices.json
OPEN3E_CLIENT_ID: open3e-edge
OPEN3E_POLL_SECONDS: "{{ edge_open3e_poll_seconds }}"
OPEN3E_DATAPOINTS: "{{ edge_open3e_datapoints }}"
E3ONCAN_DEVICE: e380
E3ONCAN_GAP_SECONDS: "{{ edge_e3oncan_gap_seconds }}"
@@ -0,0 +1,8 @@
# Managed by ansible (roles/heating-can). 0600 -- carries the broker credential.
#
# Kept out of docker-compose.yml, which is 0644: the sibling roles on this host
# do the same (power-meter-sml renders its config at 0600 while its compose file
# stays world-readable), and the native unit this replaced used a 0600 env file
# for exactly this value.
MQTT_USER={{ edge_open3e_mqtt_user }}
MQTT_PASSWORD={{ edge_open3e_mqtt_password }}
+4 -4
View File
@@ -94,7 +94,7 @@
- ansible_facts['os_family'] == 'Debian'
# On Arch (edge-1) the static assignment lives in a systemd-networkd file owned
# by the edge-host role, so this role must not rewrite it -- but it must not
# by the edge-vlan role, so this role must not rewrite it -- but it must not
# quietly proceed either. install-edge-host.yml is a separate play with no
# ordering relationship to this one, so nothing guarantees it has been re-run
# to drop the [Address] block. If it has not, edge-1 would end up holding
@@ -140,9 +140,9 @@
Handing the address to keepalived while networkd also assigns it means
it would NOT be withdrawn when check_ntp fails -- the host would keep
serving time on an address VRRP believes it has released.
Re-apply the edge-host role first, whose template no longer carries the
[Address] block: ansible-playbook -i inventory/ plays/install-edge-host.yml
--limit {{ inventory_hostname }} --tags vlan40
Re-apply the edge-vlan role first, whose template no longer carries the
[Address] block:
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
success_msg: >-
No static networkd assignment of {{ keepalived_ntp_vip6 }}; safe for
keepalived to own it.
@@ -23,7 +23,7 @@
# PVE nodes an `up ip -6 addr replace ... nodad` hook in
# /etc/network/interfaces
# edge-1 an [Address] block in /etc/systemd/network/, owned by the
# edge-host role and removed by re-applying it -- not touched
# edge-vlan role and removed by re-applying it -- not touched
# here, since this role must not rewrite another role's file
#
# There is a service gap between this play and the next, while no host holds
@@ -0,0 +1,12 @@
---
# --- MQTT: edge clients (open3e, e3oncan, sml2mqtt) publish to a LOCAL
# mosquitto on loopback, which bridges to the central cluster broker over TLS
# (open3e/e3oncan have no native MQTTS). This keeps plaintext on loopback only
# — off the untrusted IoT VLAN — and buffers telemetry in RAM during cluster
# blips. The local broker writes nothing to the eMMC (persistence off, logs to
# the volatile journal). See edge-mqtt-bridge.conf.j2 and README "MQTT bridge".
# Upstream cluster broker the local bridge connects to (TLS; the public
# Let's Encrypt cert is validated against the system CA bundle).
edge_mqtt_cluster_host: mqtt.renner.uno
edge_mqtt_cluster_tls_port: 8883
edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
@@ -0,0 +1,7 @@
---
# The MQTT clients (zwave-js, sml2mqtt, open3e, e3oncan) reconnect on their
# own once the local broker is back.
- name: Restart mosquitto
ansible.builtin.service:
name: mosquitto
state: restarted
+27
View File
@@ -0,0 +1,27 @@
---
# Local MQTT bridge broker.
#
# Edge clients (open3e, e3oncan, sml2mqtt) publish here over loopback; this
# broker bridges to the cluster broker over TLS, because those clients have no
# native MQTTS. Persistence is off and logging goes to stdout so it writes
# nothing to local flash.
- name: Install mosquitto
ansible.builtin.package:
name: mosquitto
state: present
- name: Deploy local mosquitto bridge config
ansible.builtin.template:
src: edge-mqtt-bridge.conf.j2
dest: /etc/mosquitto/mosquitto.conf
owner: root
group: mosquitto
mode: "0640" # carries the cluster bridge credentials
notify: Restart mosquitto
- name: Enable local mosquitto bridge
ansible.builtin.systemd:
name: mosquitto
enabled: true
state: started
@@ -1,4 +1,4 @@
# Managed by roles/edge-host. Local mosquitto that the edge clients (open3e,
# Managed by roles/mqtt-bridge. Local mosquitto that the edge clients (open3e,
# e3oncan, sml2mqtt) publish to over loopback; it bridges to the central
# cluster broker over TLS. open3e/e3oncan have no native MQTTS, so this is how
# edge-1's MQTT gets off plaintext on the untrusted IoT VLAN, and it buffers
@@ -0,0 +1,23 @@
---
# Power-meter reader: sml2mqtt against the FTDI serial meter head.
# Service root. Kept per-role so the role is usable on its own.
edge_base_dir: /opt/edge-host
# Own compose project directory. Unchanged from the path used under the
# previous shared project.
sml_base_dir: "{{ edge_base_dir }}/sml2mqtt"
# The compose file that used to hold both this service and zwave-js. Removed
# once, so the fixed container_name is released for the new projects.
edge_legacy_compose: "{{ edge_base_dir }}/docker-compose.yml"
# Stable by-id symlink -- see the zwave-gateway role.
edge_stromzaehler_device: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0
# Hand-pinned on purpose: critical meter bridge, deliberately excluded from
# image automation.
edge_sml2mqtt_image: spacemanspiff2007/sml2mqtt:3.6.0
# Secret from inventory/group_vars/edge_host/vault.yml:
# edge_sml2mqtt_mqtt_password
@@ -0,0 +1,6 @@
---
- name: Restart sml2mqtt
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ sml_base_dir }}"
changed_when: true
@@ -0,0 +1,55 @@
---
# Power-meter reader: sml2mqtt against the FTDI serial meter head.
#
# Its own compose project. It shared a compose file with the Z-Wave gateway,
# which meant a change to either recreated both -- two unrelated services bound
# to different USB devices. Data paths are unchanged.
- name: Create sml2mqtt directory
ansible.builtin.file:
path: "{{ sml_base_dir }}"
state: directory
mode: "0755"
# sml2mqtt reads only a config file and has no env override for the password,
# so the rendered config carries it -- 0600, root-owned, same trust boundary as
# the k8s init-container approach it replaced.
- name: Deploy sml2mqtt config
ansible.builtin.template:
src: sml2mqtt-config.yml.j2
dest: "{{ sml_base_dir }}/config.yml"
mode: "0600"
notify: Restart sml2mqtt
- name: Deploy sml2mqtt compose project
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ sml_base_dir }}/docker-compose.yml"
mode: "0644"
notify: Restart sml2mqtt
- name: Pull sml2mqtt image
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ sml_base_dir }}"
changed_when: false
# Tolerates a missing USB device, as for the Z-Wave gateway.
- name: Start sml2mqtt
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ sml_base_dir }}"
register: sml_compose_up
failed_when: >-
sml_compose_up.rc != 0
and 'error gathering device information' not in sml_compose_up.stderr
changed_when: >-
'Started' in sml_compose_up.stderr or 'Created' in sml_compose_up.stderr
or 'Started' in sml_compose_up.stdout or 'Creating' in sml_compose_up.stdout
- name: Warn if sml2mqtt is waiting for its USB device
ansible.builtin.debug:
msg: >-
sml2mqtt could not start -- the FTDI meter head is not present:
{{ sml_compose_up.stderr_lines | select('search', 'device') | list }}
when: sml_compose_up.rc != 0
@@ -0,0 +1,21 @@
# Managed by ansible (roles/power-meter-sml).
#
# Its own compose project -- see the role tasks for why it no longer shares a
# file with the Z-Wave gateway.
#
# Outbound-only (serial in, MQTT out). Host networking sidesteps the docker
# NAT66 gap: the broker LB is IPv6-first and bridge-network v6 SYNs leave
# unmasqueraded and die. The host stack reaches it fine (open3e proves it
# continuously).
services:
sml2mqtt:
image: {{ edge_sml2mqtt_image }}
container_name: sml2mqtt
restart: unless-stopped
network_mode: host
# Serial exclusivity: this is the ONLY reader of the meter port --
# HA's edl21 integration stays disabled.
devices:
- "{{ edge_stromzaehler_device }}:/dev/stromzaehler"
volumes:
- ./config.yml:/sml2mqtt/config.yml:ro
@@ -1,4 +1,4 @@
# Managed by ansible (roles/edge-host). Ported 1:1 from the k8s ConfigMap
# Managed by ansible (roles/power-meter-sml). Ported 1:1 from the k8s ConfigMap
# sml2mqtt-config; only host and password delivery differ (broker is now
# reached via its LoadBalancer hostname, password rendered by ansible
# instead of an init container).
@@ -0,0 +1,5 @@
---
# SocketCAN interface. Consumed by heating-can.
# --- CAN / open3e (Viessmann E3 external bus) ---
edge_can_interface: can0
edge_can_bitrate: 250000
@@ -0,0 +1,7 @@
---
# reload alone only re-reads config files; existing links keep their old state
# until reconfigured.
- name: Reload networkd
ansible.builtin.shell:
cmd: networkctl reload && networkctl reconfigure {{ edge_can_interface }}
changed_when: true
+20
View File
@@ -0,0 +1,20 @@
---
# SocketCAN interface, owned by systemd-networkd.
#
# gs_usb is an in-tree module and autoloads when the adapter enumerates;
# networkd then matches the interface and applies bitrate + up. Separate from
# the services that consume the bus (heating-can, e380-can-meter) because the
# interface is host network state shared by both.
- name: Configure can0 via systemd-networkd
ansible.builtin.template:
src: 80-can0.network.j2
dest: /etc/systemd/network/80-can0.network
mode: "0644"
notify: Reload networkd
- name: Enable systemd-networkd
ansible.builtin.service:
name: systemd-networkd
state: started
enabled: true
@@ -1,4 +1,4 @@
# Managed by ansible (roles/edge-host).
# Managed by ansible (roles/socketcan).
# Viessmann E3 external CAN bus via Innomaker USB2CAN (gs_usb).
# The adapter is a stub tap on an already-terminated bus (120R jumper OFF).
[Match]
@@ -0,0 +1,24 @@
---
# Z-Wave gateway: zwave-js-ui against the ZWA-2 USB radio.
# Service root. Kept per-role so the role is usable on its own.
edge_base_dir: /opt/edge-host
edge_tz: Europe/Berlin
# Own compose project directory. Unchanged from the path used under the
# previous shared project, so the store is not migrated.
zwave_base_dir: "{{ edge_base_dir }}/zwave-js"
# Stable by-id symlink: the name travels with the device, not the host, so it
# survives moving the stick between machines.
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
# Alpine version the zwave-js-ui image is built on -- the source for the musl
# libeatmydata.so preloaded into that container. Keep in sync if the image
# bumps its Alpine major (check: docker exec zwave-js cat /etc/os-release).
edge_zwave_alpine_base: alpine:3.22
# Secret from inventory/group_vars/edge_host/vault.yml:
# edge_zwavejs_session_secret
@@ -0,0 +1,6 @@
---
- name: Restart zwave-js
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ zwave_base_dir }}"
changed_when: true
@@ -0,0 +1,95 @@
---
# Z-Wave gateway: zwave-js-ui against the ZWA-2 USB radio.
#
# Its own compose project, in its own directory, so the Z-Wave gateway and the
# power-meter reader are not coupled through a shared compose file. Data paths
# are unchanged -- the store stays at {{ zwave_base_dir }}/store -- so this is a
# project boundary change, not a data migration.
- name: Create zwave-js directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ zwave_base_dir }}"
- "{{ zwave_base_dir }}/store"
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
ansible.builtin.shell:
cmd: |
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
install -m 0644 "$tmp" {{ zwave_base_dir }}/libeatmydata.so
creates: "{{ zwave_base_dir }}/libeatmydata.so"
notify: Restart zwave-js
# Because fsync is neutralized, a hard power-loss during the (rare) settings
# write could truncate settings.json — which holds the Z-Wave network keys.
# Keep a validated rolling backup on the eMMC so the keys stay recoverable.
- name: Install zwave-js settings.json backup script
ansible.builtin.template:
src: edge-zwave-settings-backup.j2
dest: /usr/local/bin/edge-zwave-settings-backup
mode: "0755"
- name: Install zwave-js settings backup service + timer
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- edge-zwave-settings-backup.service
- edge-zwave-settings-backup.timer
- name: Enable zwave-js settings backup timer
ansible.builtin.systemd:
name: edge-zwave-settings-backup.timer
enabled: true
state: started
daemon_reload: true
- name: Deploy zwave-js env (session secret)
ansible.builtin.template:
src: zwavejs.env.j2
dest: "{{ zwave_base_dir }}/zwavejs.env"
mode: "0600"
notify: Restart zwave-js
- name: Deploy zwave-js compose project
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ zwave_base_dir }}/docker-compose.yml"
mode: "0644"
notify: Restart zwave-js
- name: Pull zwave-js image
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ zwave_base_dir }}"
changed_when: false
# Tolerates a missing USB device: if the ZWA-2 is absent the container cannot
# start, and that must not fail the run for everything else on the host.
- name: Start zwave-js
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ zwave_base_dir }}"
register: zwave_compose_up
failed_when: >-
zwave_compose_up.rc != 0
and 'error gathering device information' not in zwave_compose_up.stderr
changed_when: >-
'Started' in zwave_compose_up.stderr or 'Created' in zwave_compose_up.stderr
or 'Started' in zwave_compose_up.stdout or 'Creating' in zwave_compose_up.stdout
- name: Warn if zwave-js is waiting for its USB device
ansible.builtin.debug:
msg: >-
zwave-js could not start -- the ZWA-2 radio is not present:
{{ zwave_compose_up.stderr_lines | select('search', 'device') | list }}
when: zwave_compose_up.rc != 0
@@ -0,0 +1,39 @@
# Managed by ansible (roles/zwave-gateway).
#
# Its own compose project. zwave-js and the power-meter reader used to share
# one file under {{ edge_base_dir | default('/opt/edge-host') }}; they are
# unrelated services bound to different USB devices, so a change to one had no
# business recreating the other.
#
# No `privileged` needed: docker's --device handles the device-cgroup grant
# that hostPath in k8s could not (that was the only reason the pod ran
# privileged).
#
# Host networking: real host addresses (v4+v6), no bridge, no NAT. The broker
# LB is IPv6-first and docker bridge NAT66 is a tarpit (unmasqueraded SYNs die
# silently); the host stack just works.
services:
zwave-js:
image: {{ edge_zwavejs_image }}
container_name: zwave-js
restart: unless-stopped
stop_signal: SIGINT
tty: true
environment:
- TZ={{ edge_tz }}
# Batch the value-DB fsync storm to spare the eMMC (see the edge-flash-wear
# role). Neutralizes fsync only for this container; the WS/MQTT value
# stream to HA and cache persistence are unchanged.
- LD_PRELOAD=/usr/lib/libeatmydata.so
env_file:
- zwavejs.env
devices:
- "{{ edge_zwave_device }}:/dev/zwave"
volumes:
# musl libeatmydata.so extracted from the image's Alpine base (ro)
- ./libeatmydata.so:/usr/lib/libeatmydata.so:ro
# Persistent store: settings.json, device DB, network keys. Path is
# unchanged from the previous shared project, so nothing is migrated.
- ./store:/usr/src/app/store
# Binds 3000 (Z-Wave JS websocket for HA) and 8091 (web UI) on the host.
network_mode: host
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Rolling backup of zwave-js settings.json (holds the Z-Wave network keys).
# Managed by roles/edge-host. edge-1 runs zwave-js under libeatmydata (fsync
# Managed by roles/zwave-gateway. edge-1 runs zwave-js under libeatmydata (fsync
# neutralized to spare the eMMC), so a hard power-loss during the rare
# settings write could truncate settings.json. Keep a validated backup on the
# eMMC so the keys are always recoverable. Runs hourly via a systemd timer.
+23
View File
@@ -78,6 +78,29 @@ For each, find the **current pin in the repo** (Read/Glob/Grep), then look up th
6. **Ansible roles & OpenTofu providers** — `ansible/requirements.yml` (Galaxy
roles/collections) and `opentofu/providers.tf` + `opentofu/.terraform.lock.hcl`.
Report any with a newer published version.
7. **Edge heating CAN services** — open3e (Vitocal heat pump, Vitocharge VX3)
and E3onCAN (E380 grid energy meter), shipped as one image built from
`ci/images/heating-can/Containerfile` and deployed by
`ansible/roles/heating-can`. Nothing else tracks them. They read and (for two
datapoints) write the heat pump over its CAN bus, so silent drift here
matters more than most.
Both upstream pins are `ARG`s in the Containerfile -- `OPEN3E_REF` and
`E3ONCAN_REF` -- not Ansible variables. Read them there, and note the
awkward bits so normal states are not reported as problems:
- open3e publishes **no tags at all** (its tags API returns an empty array),
so compare `OPEN3E_REF` against the default branch:
`https://api.github.com/repos/open3e/open3e/commits?per_page=1`. Report how
many commits behind and the dates. "No release found" is normal here.
- E3onCAN tags, but sparsely, and `E3ONCAN_REF` is an untagged commit later
than the newest tag. Compare against both
`https://api.github.com/repos/MyHomeMyData/E3onCAN/tags` and the default
branch, and say which the pin sits between.
The deployed image tag is `heating_can_image` in
`ansible/roles/heating-can/defaults/main.yml`. It is `:latest` today, so the
node follows whatever CI last built -- the pins above are what actually fix
the versions, and bumping them is what requires a rebuild.
## Method & rigor
+120
View File
@@ -0,0 +1,120 @@
# Heating system over the Viessmann E3 CAN bus: open3e (Vitocal heat pump and
# Vitocharge VX3) and E3onCAN (E380 grid energy meter).
#
# One image, two services. They read the same physical installation over the
# same bus and are versioned together, so they are one artifact -- but they run
# as two containers from this image so either can crash and restart on its own
# without a supervisor as PID 1.
#
# Why an image at all: both were pip-installed into venvs on the host, open3e
# straight from a git URL. pip cannot compare an installed VCS dist against a
# requested one without fetching it, so `pip install` re-cloned and rebuilt on
# every Ansible converge -- reporting changed, restarting the service, and
# putting a gap in heating telemetry each time. Pinning the SHA did not fix
# that (verified: pip still clones and rebuilds a pinned VCS requirement). A
# tagged image does: `docker compose up -d` on an unchanged tag is a genuine
# no-op.
#
# It also gives the weekly update-audit a normal artifact to track instead of
# two awkward special cases -- open3e publishes no tags at all, and E3onCAN is
# pinned to an untagged commit between releases.
#
# Runtime needs host networking: SocketCAN interfaces live in the host network
# namespace, so can0 is only reachable with network_mode: host. That is also
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
# no capabilities, so both run unprivileged.
FROM python:3.13-slim AS build
# Pins live here, in the artifact, rather than in Ansible variables.
#
# open3e: a commit, because the project publishes NO tags (its tags API returns
# an empty array). Self-reported package version at this commit: 0.7.5.
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
# E3onCAN: an untagged commit on the default branch (2026-06-21, "Fixed: MQTT
# client ID collision caused stale retained values"). Later than the repo's
# only tag, v0.5.1.
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
# git is needed to FETCH the sources and exists only in this stage. The runtime
# stage below copies the built venv and the E3onCAN source out, so neither git
# nor the apt cache ends up in the published image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir supervisor \
&& pip install --no-cache-dir "git+https://github.com/open3e/open3e.git@${OPEN3E_REF}"
# E3onCAN is not packaged -- it is run as a script out of its checkout, so the
# source has to travel into the runtime image alongside its dependencies.
RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
&& git -C /opt/e3oncan checkout --quiet "${E3ONCAN_REF}" \
&& pip install --no-cache-dir -r /opt/e3oncan/requirements.txt \
&& rm -rf /opt/e3oncan/.git
FROM python:3.13-slim
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
LABEL org.opencontainers.image.title="heating-can" \
org.opencontainers.image.description="open3e + E3onCAN, Viessmann E3 CAN bus readers" \
org.opencontainers.image.source="https://gitea.renner.uno/k.renner/homelab" \
uno.renner.open3e.ref="${OPEN3E_REF}" \
uno.renner.e3oncan.ref="${E3ONCAN_REF}"
# Everything is installed at build time. The runtime image carries the finished
# venv and source only -- no git, no compiler, no package index.
COPY --from=build /opt/venv /opt/venv
COPY --from=build /opt/e3oncan /opt/e3oncan
ENV PATH="/opt/venv/bin:$PATH"
# Both services log to stdout, which docker's journald driver forwards to the
# journal and edge-log-shipper ships to Loki. open3e's systemd unit did NOT set
# this, so its output was block-buffered and effectively never reached the
# journal -- a long-standing blind spot that this fixes for both.
ENV PYTHONUNBUFFERED=1
# One container, two services, so PID 1 is a supervisor. It restarts either
# reader on its own if it dies, reaps children and forwards shutdown -- which a
# bare process as PID 1 cannot do for a sibling.
#
# Configuration arrives as environment variables and supervisord expands them,
# so nothing outside this image constructs open3e's or E3onCAN's argv. Those
# CLIs are an implementation detail here; a compose file spelling them out
# would break whenever either upstream renamed a flag.
COPY supervisord.conf /etc/supervisord.conf
# Build-time self-test. Fails the build if either service cannot actually run,
# which is exactly what shipped before: supervisord pointed at
# /opt/e3oncan/src/E3onCANcollect.py, a path that does not exist -- the file is
# at the repo root. The image built green and would have run open3e while
# e3oncan crash-looped, the E380 meter silently absent.
#
# Done here rather than as a CI step because this needs no registry
# credentials and no runner able to pull a private image -- and because a
# broken image should never be published in the first place.
RUN open3e --help > /dev/null \
&& python /opt/e3oncan/E3onCANcollect.py --help > /dev/null \
&& supervisord --help > /dev/null \
&& grep -q '^\[program:open3e\]' /etc/supervisord.conf \
&& grep -q '^\[program:e3oncan\]' /etc/supervisord.conf \
&& echo "self-test OK: both services execute; supervisord has both programs"
# CAN_RAW needs no capabilities, so nothing here runs as root.
RUN useradd --system --no-create-home --shell /usr/sbin/nologin open3e
# A writable working directory, owned by the service user. udsoncan writes its
# log file into the CWD, and the native install this replaces gave open3e's
# directory `owner: open3e` for exactly that reason. /opt is root-owned from the
# COPY layers, so running there would fail that write.
RUN install -d -o open3e -g open3e -m 0755 /var/lib/open3e
USER open3e
WORKDIR /var/lib/open3e
ENTRYPOINT ["supervisord", "-c", "/etc/supervisord.conf"]
+62
View File
@@ -0,0 +1,62 @@
# Both CAN readers, supervised in one container.
#
# They read the same heating installation over the same bus and are versioned
# and deployed together, so they are one container. A container runs one PID 1;
# with two processes that PID 1 is a supervisor, which is what this is --
# supervisord restarts either service on its own if it dies, reaps children,
# and forwards shutdown.
#
# Configuration comes from the environment (see the compose file). supervisord
# expands %(ENV_NAME)s at start, so neither the compose file nor a wrapper
# script constructs open3e's or E3onCAN's argv -- those CLIs stay an
# implementation detail of this image.
[supervisord]
nodaemon=true
user=open3e
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
# open3e: UDS request/response to the Vitocal heat pump and Vitocharge VX3.
[program:open3e]
command=open3e
-c %(ENV_CAN_INTERFACE)s
-cnfg %(ENV_OPEN3E_CONFIG)s
-m %(ENV_MQTT_HOST)s:%(ENV_MQTT_PORT)s:%(ENV_MQTT_TOPIC)s
-muser %(ENV_MQTT_USER)s:%(ENV_MQTT_PASSWORD)s
-mcid %(ENV_OPEN3E_CLIENT_ID)s
-mfstr %(ENV_MQTT_FORMAT)s
-r %(ENV_OPEN3E_DATAPOINTS)s
-t %(ENV_OPEN3E_POLL_SECONDS)s
-l %(ENV_MQTT_TOPIC)s/cmnd
-j
autostart=true
autorestart=true
startsecs=10
stopsignal=INT
# Straight to the container's stdout/stderr, so docker's journald driver picks
# them up and edge-log-shipper ships them to Loki.
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
redirect_stderr=true
# e3oncan: passive CANopen listener for the E380 grid energy meter, which feeds
# the heat pump's own energy management. Issues no bus writes, which is why the
# two can share the bus.
[program:e3oncan]
command=python /opt/e3oncan/E3onCANcollect.py
-c %(ENV_CAN_INTERFACE)s
-dev %(ENV_E3ONCAN_DEVICE)s
-g %(ENV_E3ONCAN_GAP_SECONDS)s
-m %(ENV_MQTT_HOST)s:%(ENV_MQTT_PORT)s:%(ENV_MQTT_TOPIC)s
-muser %(ENV_MQTT_USER)s
-mpass %(ENV_MQTT_PASSWORD)s
-mfstr %(ENV_E3ONCAN_MQTT_FORMAT)s
-j
autostart=true
autorestart=true
startsecs=10
stopsignal=INT
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
redirect_stderr=true
+17 -6
View File
@@ -1,7 +1,12 @@
# edge-host installer ISO
Bootable Arch installer image for `edge-1.srv.renner.uno` (the bare-metal
N100 that replaces the k8s-edge VM — see `ansible/roles/edge-host`).
Bootable Arch installer image for the bare-metal edge hosts — the machines
that replaced the k8s-edge VM, because Talos cannot do CAN or USB
passthrough. See `ansible/docs/edge-host.md`.
Not tied to one box or one model: the installer autodetects the target disk
(NVMe, else eMMC, else the first non-USB disk), which covers both the Wyse
3040 thin clients and larger hardware.
The image is the official archiso **releng** profile plus:
- `edge-install` in the live environment — partitions the NVMe
@@ -12,7 +17,7 @@ The image is the official archiso **releng** profile plus:
Division of labour: the ISO produces an **ansible-ready base system**;
all service configuration (zwave-js-ui, sml2mqtt, open3e/CAN) stays in
`ansible/roles/edge-host` as the single source of truth. Rebuilding the
`ansible/docs/edge-host.md` as the single source of truth. Rebuilding the
box is: boot ISO → `edge-install` → run the play.
## Build
@@ -38,10 +43,16 @@ archiso updates flow in automatically.
(NVMe, else eMMC — covers the Wyse 3040 — else first non-USB disk),
shows the target and requires typing the hostname before wiping
anything. Override with `edge-install /dev/sdY`.
4. Reboot, then from the workstation:
4. Reboot. Confirm the box is reachable by name — `ssh root@<host>` — before
going further; it is identified by name everywhere and its address is
never recorded. Then add it to `[edge_host]` in
`ansible/inventory/pve_hosts.yml` and, from the workstation:
```
cd ansible
ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
```
5. Continue with the cutover checklist in `ansible/roles/edge-host/README.md`.
One run over the whole group, no `--limit`: the play carries
`certificates` and `common` itself, so there is no separate `common.yml`
step. Running it converges every edge host, which is the point — a run
that touches only the new box tells you nothing about the others.
5. Continue with the cutover checklist in `ansible/docs/edge-host.md`.
@@ -50,7 +50,7 @@ mount "$(part 2)" /mnt
mount --mkdir "$(part 1)" /mnt/boot
# Everything the edge-host ansible role expects to find; the role itself
# is applied over SSH after first boot (see ansible/roles/edge-host).
# is applied over SSH after first boot (see ansible/docs/edge-host.md).
pacstrap -K /mnt base linux linux-firmware intel-ucode openssh python git \
docker docker-compose
@@ -99,7 +99,13 @@ EOF
umount -R /mnt
echo
echo "${HOST} installed. Remove the USB stick, reboot, then from the"
echo "workstation: cd ansible &&"
echo " ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host"
echo " ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml"
echo "${HOST} installed. Remove the USB stick and reboot."
echo
echo "Then, from the workstation:"
echo " 1. confirm 'ssh root@${HOST}' works (it must be reachable by name)"
echo " 2. add ${HOST} to [edge_host] in ansible/inventory/pve_hosts.yml"
echo " 3. cd ansible && ansible-playbook -i inventory/pve_hosts.yml \\"
echo " plays/install-edge-host.yml"
echo
echo "One run, whole group, no --limit: the play carries certificates and"
echo "common itself, so there is no separate common.yml step."
@@ -38,7 +38,7 @@ spec:
# gets the heavy HA pod (8Gi) off the soon-to-be-tiny edge box.
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.8.0 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.8.1 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
@@ -27,7 +27,7 @@ spec:
type: RuntimeDefault
containers:
- name: pve-exporter
image: prompve/prometheus-pve-exporter:3.9.0 # {"$imagepolicy": "flux-system:pve-exporter"}
image: prompve/prometheus-pve-exporter:3.10.0 # {"$imagepolicy": "flux-system:pve-exporter"}
# Image ENTRYPOINT is already `pve_exporter`; pass flags only.
args:
- --config.file=/etc/pve-exporter/pve.yml
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
containers:
- name: gotenberg
image: gotenberg/gotenberg:8.34.0 # {"$imagepolicy": "flux-system:gotenberg"}
image: gotenberg/gotenberg:8.35.0 # {"$imagepolicy": "flux-system:gotenberg"}
args:
- "gotenberg"
- "--chromium-disable-javascript=true"
+1 -1
View File
@@ -25,7 +25,7 @@ ACCESS_STAGING="$REPO_ROOT/kubernetes/access-staging"
NODES=(k8s-1 k8s-2 k8s-3)
# No dedicated workers since the k8s-edge VM was retired (2026-07-02) —
# hardware-bound services live on the bare-metal edge-1 Arch host
# (ansible/roles/edge-host).
# (ansible/docs/edge-host.md).
WORKERS=()
ALL_NODES=("${NODES[@]}" "${WORKERS[@]}")
DOMAIN="srv.renner.uno"