refactor(edge): split the edge-host role into one role per concern
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>
This commit is contained in:
@@ -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 }}"
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
---
|
||||
# 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
|
||||
# Bare-metal Arch edge hosts (Dell Wyse 3040 and similar) — the home for
|
||||
# hardware-bound services, because Talos supports neither SocketCAN nor USB
|
||||
# passthrough.
|
||||
#
|
||||
# Self-contained on purpose. `certificates` and `common` are the parts of
|
||||
# plays/common.yml that apply to an edge host, and they are included here so
|
||||
# that bootstrapping one is a single unrestricted run over the edge_host group.
|
||||
# One role per concern. Previously all of this lived in a single `edge-host`
|
||||
# role, which meant a host could not take the flash-wear handling or the log
|
||||
# shipper without also being handed the Z-Wave radio and the CAN bus. That is
|
||||
# what blocked a second thin client from joining as an NTP peer.
|
||||
#
|
||||
# Previously the runbook told you to reach them with
|
||||
# `ansible-playbook plays/common.yml -l edge_host`. That is the only way to get
|
||||
# base config onto a new edge box via common.yml, because that play is
|
||||
# `hosts: all:!...` and an unrestricted run during a single-host bootstrap would
|
||||
# reconfigure the whole fleet. So the split forced a --limit, which narrows the
|
||||
# target list rather than staging anything and leaves every other host outside
|
||||
# convergence with no report that they were skipped.
|
||||
# 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).
|
||||
#
|
||||
# Not carried over from common.yml:
|
||||
# qemu-guest-agent these are bare-metal thin clients, not VMs
|
||||
# monitoring-agent Debian-oriented; edge hosts ship logs via edge-alloy
|
||||
# instead (see roles/edge-host, "Logging & eMMC")
|
||||
# Its loki/prometheus vars are therefore not needed either -- they are consumed
|
||||
# by monitoring-agent, not by common.
|
||||
# Order is dependency order, not alphabetical:
|
||||
# mqtt-bridge before its clients, so they have somewhere to publish
|
||||
# edge-flash-wear before docker, so the journal is volatile before
|
||||
# containers start logging into it
|
||||
# socketcan before heating-can, which consumes can0
|
||||
- name: Install Arch edge host
|
||||
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
|
||||
|
||||
# Host-generic. An edge host with no attached peripherals takes these and
|
||||
# nothing below them.
|
||||
- edge-flash-wear
|
||||
- docker-install
|
||||
- edge-host
|
||||
- edge-log-shipper
|
||||
- mqtt-bridge
|
||||
- edge-vlan
|
||||
|
||||
# Hardware-bound. Each needs its own device present.
|
||||
- socketcan
|
||||
- heating-can
|
||||
- zwave-gateway
|
||||
- power-meter-sml
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
# Container stdout goes to the journal rather than json files on disk. On hosts
|
||||
# with small flash that keeps container logs off it entirely -- the journal
|
||||
# itself can then be made volatile (see the edge-flash-wear role). Set to
|
||||
# "json-file" to restore docker's default.
|
||||
docker_log_driver: journald
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
- name: Restart docker
|
||||
ansible.builtin.service:
|
||||
name: docker
|
||||
state: restarted
|
||||
@@ -67,3 +67,13 @@
|
||||
name: "{{ ansible_user }}"
|
||||
groups: docker
|
||||
append: true
|
||||
|
||||
# Docker networking needs no daemon.json here -- the services that use this
|
||||
# host run with network_mode: host (real addresses, v4+v6, no bridge/NAT).
|
||||
# This exists only to route container stdout into the journald log driver.
|
||||
- name: Configure docker daemon log driver
|
||||
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 }}"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
# 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
|
||||
+1
-1
@@ -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,12 @@
|
||||
---
|
||||
- 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
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
# 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
|
||||
+1
-1
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
}
|
||||
@@ -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
-1
@@ -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
|
||||
@@ -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
|
||||
@@ -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,52 @@
|
||||
---
|
||||
# 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_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 come from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_open3e_mqtt_password
|
||||
+1
-1
@@ -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,12 @@
|
||||
---
|
||||
- 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
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
# Heating system over the Viessmann E3 CAN bus.
|
||||
#
|
||||
# Two readers of the same heating installation:
|
||||
# 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. Both are native installs
|
||||
# (venv + systemd) rather than containers: they need a SocketCAN interface,
|
||||
# which is host network state. CAN_RAW sockets need no capabilities, so they
|
||||
# run as unprivileged system users.
|
||||
#
|
||||
# The can0 interface itself belongs to the socketcan role -- an interface is
|
||||
# not the same concern as the services that consume it -- so this role must
|
||||
# run after it.
|
||||
|
||||
- name: Install build/runtime packages for the CAN services
|
||||
ansible.builtin.package:
|
||||
name:
|
||||
- python
|
||||
- python-packaging
|
||||
- git
|
||||
state: present
|
||||
|
||||
- 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
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by ansible (roles/edge-host).
|
||||
# Managed by ansible (roles/heating-can).
|
||||
# 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
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by ansible (roles/edge-host). Read by open3e.service and
|
||||
# Managed by ansible (roles/heating-can). 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;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by ansible (roles/edge-host).
|
||||
# Managed by ansible (roles/heating-can).
|
||||
# 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
|
||||
@@ -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,14 @@
|
||||
---
|
||||
# --- 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
|
||||
@@ -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
|
||||
@@ -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
-1
@@ -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,71 @@
|
||||
---
|
||||
# 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"
|
||||
|
||||
# One-time migration off the shared project. The container_name is fixed, so
|
||||
# the old project must release it before this one can claim it. Guarded on the
|
||||
# legacy file, so it runs once and then never again.
|
||||
- name: Retire the legacy shared compose project
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose -f "{{ edge_legacy_compose }}" down
|
||||
args:
|
||||
removes: "{{ edge_legacy_compose }}"
|
||||
register: sml_legacy_down
|
||||
failed_when: false
|
||||
|
||||
- name: Remove the legacy shared compose file
|
||||
ansible.builtin.file:
|
||||
path: "{{ edge_legacy_compose }}"
|
||||
state: absent
|
||||
|
||||
# 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
-1
@@ -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
|
||||
@@ -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
-1
@@ -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 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
|
||||
|
||||
- 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
-1
@@ -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.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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/roles/edge-host`.
|
||||
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
|
||||
@@ -17,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
|
||||
@@ -55,4 +55,4 @@ archiso updates flow in automatically.
|
||||
`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/roles/edge-host/README.md`.
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user