From 42a5cdc27553437ae2e510e0b0edd3b89606c42d Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 12:03:14 +0200 Subject: [PATCH 01/14] =?UTF-8?q?ansible:=20add=20edge-host=20role=20?= =?UTF-8?q?=E2=80=94=20bare-metal=20Arch=20N100=20replaces=20k8s-edge=20VM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the k8s-edge Talos VM (vm_153) with a dedicated Arch host running the hardware-bound services: zwave-js-ui and sml2mqtt via docker compose (configs ported 1:1 from the k8s manifests), plus the new open3e CAN bridge (SocketCAN via systemd-networkd, venv + systemd unit, read-only datapoint set). Driver for leaving k8s here: stock Talos ships CONFIG_CAN unset (siderolabs/talos#7686), so the Vitocal/VX3 CAN adapter cannot work in any pod without a custom kernel per release. Base-install procedure, secret bootstrap (vault.yml from cluster secrets) and per-service cutover checklist in roles/edge-host/README.md. Co-Authored-By: Claude Fable 5 --- .../inventory/group_vars/edge_host/vars.yml | 7 + ansible/inventory/pve_hosts.yml | 3 + ansible/plays/install-edge-host.yml | 12 ++ ansible/roles/edge-host/README.md | 109 +++++++++++++++ ansible/roles/edge-host/defaults/main.yml | 45 +++++++ ansible/roles/edge-host/handlers/main.yml | 17 +++ ansible/roles/edge-host/tasks/main.yml | 126 ++++++++++++++++++ .../edge-host/templates/80-can0.network.j2 | 10 ++ .../edge-host/templates/docker-compose.yml.j2 | 42 ++++++ .../roles/edge-host/templates/open3e.env.j2 | 4 + .../edge-host/templates/open3e.service.j2 | 31 +++++ .../templates/sml2mqtt-config.yml.j2 | 32 +++++ .../roles/edge-host/templates/zwavejs.env.j2 | 1 + opentofu/vm_153_k8s_edge.tf | 7 +- 14 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 ansible/inventory/group_vars/edge_host/vars.yml create mode 100644 ansible/plays/install-edge-host.yml create mode 100644 ansible/roles/edge-host/README.md create mode 100644 ansible/roles/edge-host/defaults/main.yml create mode 100644 ansible/roles/edge-host/handlers/main.yml create mode 100644 ansible/roles/edge-host/tasks/main.yml create mode 100644 ansible/roles/edge-host/templates/80-can0.network.j2 create mode 100644 ansible/roles/edge-host/templates/docker-compose.yml.j2 create mode 100644 ansible/roles/edge-host/templates/open3e.env.j2 create mode 100644 ansible/roles/edge-host/templates/open3e.service.j2 create mode 100644 ansible/roles/edge-host/templates/sml2mqtt-config.yml.j2 create mode 100644 ansible/roles/edge-host/templates/zwavejs.env.j2 diff --git a/ansible/inventory/group_vars/edge_host/vars.yml b/ansible/inventory/group_vars/edge_host/vars.yml new file mode 100644 index 00000000..c8d429e8 --- /dev/null +++ b/ansible/inventory/group_vars/edge_host/vars.yml @@ -0,0 +1,7 @@ +--- +# 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 +# the cluster secrets. +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 }}" diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index 503292b0..e810dc6f 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -39,3 +39,6 @@ woodpecker.srv.renner.uno ansible_user=root [woodpecker_agents] wp-agent-1.srv.renner.uno ansible_user=root wp-agent-2.srv.renner.uno ansible_user=root + +[edge_host] +edge-1.srv.renner.uno ansible_user=root diff --git a/ansible/plays/install-edge-host.yml b/ansible/plays/install-edge-host.yml new file mode 100644 index 00000000..cb00babb --- /dev/null +++ b/ansible/plays/install-edge-host.yml @@ -0,0 +1,12 @@ +--- +# Bare-metal Arch Linux edge host (N100) — 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 + hosts: edge_host + roles: + - docker-install + - edge-host diff --git a/ansible/roles/edge-host/README.md b/ansible/roles/edge-host/README.md new file mode 100644 index 00000000..3a9c7e4f --- /dev/null +++ b/ansible/roles/edge-host/README.md @@ -0,0 +1,109 @@ +# edge-host — bare-metal Arch N100 (replaces the k8s-edge Talos VM) + +Runs the hardware-bound services that used to be pinned to the `k8s-edge` +Talos node (opentofu `vm_153_k8s_edge.tf`), plus the new CAN bridge: + +| Service | How it runs | Hardware | Talks to | +|---|---|---|---| +| zwave-js-ui | docker compose | ZWA-2 Z-Wave USB radio | HA `zwave_js` via ws://:3000, UI :8091 | +| sml2mqtt | docker compose | FTDI FT230X meter reader | Mosquitto (`sml2mqtt` user) | +| open3e | venv + systemd | Innomaker USB2CAN (gs_usb) | Mosquitto (`open3e` user) | + +Everything else that once lived on k8s-edge (HA core, matter-server, OTBR, +music-assistant) already moved to the general node pool and stays in the +cluster — they need VLAN L2 presence, not USB, so a single LAN NIC on the +N100 is sufficient. + +Rationale for leaving Kubernetes for the CAN part: the stock Talos kernel +ships with `CONFIG_CAN` unset (siderolabs/talos#7686), so SocketCAN cannot +work in any pod without a custom kernel image per Talos release. + +## Base install (once, from the Arch ISO) + +Keep it minimal — everything after step 4 is ansible's job. + +1. Boot the current Arch ISO on the N100. +2. `archinstall` (guided): minimal profile, systemd-boot, single ext4 root + on the NVMe, **network: systemd-networkd with DHCP**, hostname + `edge-1`, timezone Europe/Berlin, create no desktop, additional + packages: `openssh python`. Enable sshd in the post-install chroot: + `systemctl enable sshd`. +3. Add the common SSH pubkeys to root (or copy from another srv host), + confirm `ssh root@edge-1.srv.renner.uno` works, add the DHCP + reservation/DNS entry on the UDM. +4. 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 + ``` + +## Secrets (before first play run) + +Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault; the +current live values come straight out of the cluster: + +``` +# sml2mqtt broker password +kubectl -n home-assistant get secret mosquitto-sml2mqtt-credentials -o jsonpath='{.data.password}' | base64 -d +# open3e broker password +kubectl -n home-assistant get secret mosquitto-open3e-credentials -o jsonpath='{.data.password}' | base64 -d +# zwave-js session secret +kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.session-secret}' | base64 -d +``` + +```yaml +# vault.yml +vault_edge_sml2mqtt_mqtt_password: "..." +vault_edge_open3e_mqtt_password: "..." +vault_edge_zwavejs_session_secret: "..." +``` + +The Z-Wave network keys do NOT need separate handling — they live in +`settings.json` inside the store that gets migrated below. + +## Cutover checklist (service downtime: minutes, do per-service) + +Preparation: +- [ ] `vault.yml` populated (above), play runs green against the N100 +- [ ] CAN: Innomaker adapter connected to the VX3 external CAN port, + `ip -details link show can0` shows `state ERROR-ACTIVE bitrate 250000`, + `candump can0 -n 5` shows frames + +zwave-js (order matters — never two servers on one radio): +- [ ] Copy the store out of the PVC while the pod still runs: + `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 the N100, `docker compose up -d zwave-js` +- [ ] Point HA `zwave_js` integration at `ws://edge-1.srv.renner.uno:3000` +- [ ] Nodes alive in the UI on :8091 → remove the flux kustomization + `home-assistant-zwavejs` + PVC in a follow-up PR + +sml2mqtt: +- [ ] Scale down in-cluster: `kubectl -n home-assistant scale deploy/sml2mqtt --replicas 0` +- [ ] Move the FTDI stick, `docker compose up -d sml2mqtt` +- [ ] HA meter entities update again (same entity_ids — config is a 1:1 + port, Wh stays Wh) → remove sml2mqtt from the home-assistant + kustomization in the follow-up PR + +open3e: +- [ ] Stop any workstation listener (ONE instance per bus, hard rule) +- [ ] Move the USB2CAN adapter, `systemctl start open3e` +- [ ] Topics under `open3e/#` update on the broker + +Afterwards: +- [ ] Retire VM 153: remove `vm_153_k8s_edge.tf`, `talos/patches/k8s-edge.yaml`, + node from the cluster (`kubectl delete node k8s-edge`, tofu apply) +- [ ] Update wiki (k8s-edge-node-pattern note is then historical) + +## Day-2 notes + +- Image bumps: edit `edge_zwavejs_image` / `edge_sml2mqtt_image` in + defaults and re-run the play (no flux image-automation out here — + deliberate for sml2mqtt, acceptable for 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). +- Ad-hoc CAN scans: `systemctl stop open3e` first (single-instance rule), + restart afterwards. +- can-utils (`candump`) is AUR-only on Arch — install manually via + `pamac build can-utils` if needed for debugging; the role deliberately + doesn't manage AUR packages. diff --git a/ansible/roles/edge-host/defaults/main.yml b/ansible/roles/edge-host/defaults/main.yml new file mode 100644 index 00000000..2884a7c1 --- /dev/null +++ b/ansible/roles/edge-host/defaults/main.yml @@ -0,0 +1,45 @@ +--- +edge_base_dir: /opt/edge-host +edge_tz: Europe/Berlin + +# --- MQTT: all services publish to the central in-cluster Mosquitto via +# its LoadBalancer hostname (the edge host is outside the cluster). +edge_mqtt_host: mqtt.renner.uno +edge_mqtt_port: 1883 + +# --- USB devices (stable by-id symlinks). After physically moving the +# sticks to the N100, 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 + +# --- 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. +edge_open3e_datapoints: >- + vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.318,vitocal.1043,vitocal.1190,vitocal.2351,vitocal.2352,vitocal.2622,vitocal.2624,vitocal.2625,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1810 +# ECU addresses; -dev selects codec set, tx selects the UDS address. +edge_open3e_devices: + vitocal: { tx: "0x680", dpList: "vcal" } + vx3: { tx: "0x6a1", dpList: "vx3" } + +# --- 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 }}" diff --git a/ansible/roles/edge-host/handlers/main.yml b/ansible/roles/edge-host/handlers/main.yml new file mode 100644 index 00000000..47e2bb9b --- /dev/null +++ b/ansible/roles/edge-host/handlers/main.yml @@ -0,0 +1,17 @@ +--- +- name: Reload networkd + ansible.builtin.command: + cmd: networkctl reload + changed_when: true + +- 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 diff --git a/ansible/roles/edge-host/tasks/main.yml b/ansible/roles/edge-host/tasks/main.yml new file mode 100644 index 00000000..538de601 --- /dev/null +++ b/ansible/roles/edge-host/tasks/main.yml @@ -0,0 +1,126 @@ +--- +# 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 + - git + state: present + +# --- 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 + +- name: Enable systemd-networkd + ansible.builtin.service: + name: systemd-networkd + state: started + enabled: true + +# --- 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" + - "{{ edge_base_dir }}/open3e" + +# --- 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 + +- name: Start compose services + ansible.builtin.command: + cmd: docker compose up -d + chdir: "{{ edge_base_dir }}" + register: compose_up + 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 + +# --- 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 + +- 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 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 diff --git a/ansible/roles/edge-host/templates/80-can0.network.j2 b/ansible/roles/edge-host/templates/80-can0.network.j2 new file mode 100644 index 00000000..4fb7159d --- /dev/null +++ b/ansible/roles/edge-host/templates/80-can0.network.j2 @@ -0,0 +1,10 @@ +# Managed by ansible (roles/edge-host). +# 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] +Name={{ edge_can_interface }} + +[CAN] +BitRate={{ edge_can_bitrate }} +# Auto-recover the link if the controller ever goes bus-off. +RestartSec=1s diff --git a/ansible/roles/edge-host/templates/docker-compose.yml.j2 b/ansible/roles/edge-host/templates/docker-compose.yml.j2 new file mode 100644 index 00000000..53c510aa --- /dev/null +++ b/ansible/roles/edge-host/templates/docker-compose.yml.j2 @@ -0,0 +1,42 @@ +# 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). +services: + zwave-js: + image: {{ edge_zwavejs_image }} + container_name: zwave-js + restart: unless-stopped + stop_signal: SIGINT + tty: true + environment: + - TZ={{ edge_tz }} + env_file: + - zwavejs.env + devices: + - "{{ edge_zwave_device }}:/dev/zwave" + volumes: + # 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 + ports: + - "3000:3000" # Z-Wave JS websocket — HA zwave_js integration + - "8091:8091" # web UI + + sml2mqtt: + image: {{ edge_sml2mqtt_image }} + container_name: sml2mqtt + restart: unless-stopped + # 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 diff --git a/ansible/roles/edge-host/templates/open3e.env.j2 b/ansible/roles/edge-host/templates/open3e.env.j2 new file mode 100644 index 00000000..5eb4f8c1 --- /dev/null +++ b/ansible/roles/edge-host/templates/open3e.env.j2 @@ -0,0 +1,4 @@ +# Managed by ansible (roles/edge-host). Read by open3e.service. +# Full user:password pair as one variable so the unit can pass it as a +# single -muser argument without exposing it in the unit file. +OPEN3E_MUSER={{ edge_open3e_mqtt_user }}:{{ edge_open3e_mqtt_password }} diff --git a/ansible/roles/edge-host/templates/open3e.service.j2 b/ansible/roles/edge-host/templates/open3e.service.j2 new file mode 100644 index 00000000..2044333f --- /dev/null +++ b/ansible/roles/edge-host/templates/open3e.service.j2 @@ -0,0 +1,31 @@ +# Managed by ansible (roles/edge-host). +# READ-ONLY listener — no -w, no service 0x77. Writes are Phase 3 and get +# their own review; this unit must never grow write flags casually. +# 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 +After=sys-subsystem-net-devices-{{ edge_can_interface }}.device network-online.target +Wants=network-online.target + +[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 }} +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/edge-host/templates/sml2mqtt-config.yml.j2 b/ansible/roles/edge-host/templates/sml2mqtt-config.yml.j2 new file mode 100644 index 00000000..1eefe315 --- /dev/null +++ b/ansible/roles/edge-host/templates/sml2mqtt-config.yml.j2 @@ -0,0 +1,32 @@ +# Managed by ansible (roles/edge-host). 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). +logging: + level: INFO + file: stdout # 'stdout' -> docker logs (NOT '' — that opens the dir) + +mqtt: + connection: + identifier: sml2mqtt-meter + host: {{ edge_mqtt_host }} + port: {{ edge_mqtt_port }} + user: sml2mqtt + password: {{ edge_sml2mqtt_mqtt_password }} + topic prefix: sml2mqtt + defaults: + qos: 0 + retain: false + last will: + topic: status + +general: + Wh in kWh: false # keep raw Wh to match edl21 history — HA long-term + # stats continue with zero unit discontinuity + republish after: 120 # re-emit unchanged values every 120 s + +inputs: + - type: serial + url: /dev/stromzaehler # FTDI FT230X, mapped by docker compose + timeout: 3 + # 9600 8N1 are sml2mqtt defaults for SML/EDL21 — no override needed. diff --git a/ansible/roles/edge-host/templates/zwavejs.env.j2 b/ansible/roles/edge-host/templates/zwavejs.env.j2 new file mode 100644 index 00000000..cd203657 --- /dev/null +++ b/ansible/roles/edge-host/templates/zwavejs.env.j2 @@ -0,0 +1 @@ +SESSION_SECRET={{ edge_zwavejs_session_secret }} diff --git a/opentofu/vm_153_k8s_edge.tf b/opentofu/vm_153_k8s_edge.tf index 163bb375..d336c379 100644 --- a/opentofu/vm_153_k8s_edge.tf +++ b/opentofu/vm_153_k8s_edge.tf @@ -13,8 +13,11 @@ # Pinned to neptune: VM 103 (legacy HAOS) currently holds the ZWA-2 + # stromzaehler USB mappings on neptune; at HA cutover the USBs rebind # from VM 103 to this VM in-place on the same host (no physical move). -# k8s-edge stays a Proxmox VM (decision 2026-05-05) — bare-metal N100 -# trajectory is shelved. +# DEPRECATION PATH (decision 2026-07-02, reverses 2026-05-05): this VM is +# being replaced by a bare-metal Arch N100 (`edge-1`, see +# ansible/roles/edge-host) because the new CAN/open3e service needs a +# kernel with CONFIG_CAN, which stock Talos does not ship. Remove this +# file after the cutover checklist in roles/edge-host/README.md is done. module "k8s_edge" { source = "./modules/proxmox_vm" From ee98c88ef4444f9904c262f603da62d9fcc77454 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 12:14:36 +0200 Subject: [PATCH 02/14] images: add edge-host installer ISO Releng-based archiso overlay: `edge-install` in the live env wipes the N100 NVMe and installs the ansible-ready base system (systemd-boot, networkd DHCP, sshd key-only, docker). Service config stays in the edge-host ansible role; the ISO only produces the base. Co-Authored-By: Claude Fable 5 --- ansible/roles/edge-host/README.md | 20 +++-- images/edge-host-iso/README.md | 43 ++++++++++ .../airootfs/root/edge-authorized_keys | 4 + .../airootfs/usr/local/bin/edge-install | 81 +++++++++++++++++++ images/edge-host-iso/build.sh | 31 +++++++ 5 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 images/edge-host-iso/README.md create mode 100644 images/edge-host-iso/airootfs/root/edge-authorized_keys create mode 100755 images/edge-host-iso/airootfs/usr/local/bin/edge-install create mode 100755 images/edge-host-iso/build.sh diff --git a/ansible/roles/edge-host/README.md b/ansible/roles/edge-host/README.md index 3a9c7e4f..a7fc4f63 100644 --- a/ansible/roles/edge-host/README.md +++ b/ansible/roles/edge-host/README.md @@ -18,19 +18,17 @@ Rationale for leaving Kubernetes for the CAN part: the stock Talos kernel ships with `CONFIG_CAN` unset (siderolabs/talos#7686), so SocketCAN cannot work in any pod without a custom kernel image per Talos release. -## Base install (once, from the Arch ISO) +## Base install (once, from the edge-host installer ISO) -Keep it minimal — everything after step 4 is ansible's job. +Build and boot the dedicated installer image — see +`images/edge-host-iso/README.md`. Short version: -1. Boot the current Arch ISO on the N100. -2. `archinstall` (guided): minimal profile, systemd-boot, single ext4 root - on the NVMe, **network: systemd-networkd with DHCP**, hostname - `edge-1`, timezone Europe/Berlin, create no desktop, additional - packages: `openssh python`. Enable sshd in the post-install chroot: - `systemctl enable sshd`. -3. Add the common SSH pubkeys to root (or copy from another srv host), - confirm `ssh root@edge-1.srv.renner.uno` works, add the DHCP - reservation/DNS entry on the UDM. +1. `sudo pacman -S archiso && sudo images/edge-host-iso/build.sh` +2. Write ISO to a USB stick, boot the N100 from it, run `edge-install` + (partitions the NVMe, 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: ``` ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host diff --git a/images/edge-host-iso/README.md b/images/edge-host-iso/README.md new file mode 100644 index 00000000..2f5f8523 --- /dev/null +++ b/images/edge-host-iso/README.md @@ -0,0 +1,43 @@ +# 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`). + +The image is the official archiso **releng** profile plus: +- `edge-install` in the live environment — partitions the NVMe + (ESP + ext4 root, systemd-boot), pacstraps the base system incl. + docker/python, configures DHCP via systemd-networkd, enables sshd and + drops the common homelab SSH keys for root (key-only, no root password) +- the extra disk tools the installer needs + +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 +box is: boot ISO → `edge-install` → run the play. + +## Build + +``` +sudo pacman -S archiso +cd images/edge-host-iso +sudo ./build.sh # ISO lands in ./out/ +``` + +The profile is assembled at build time from `/usr/share/archiso/configs/releng` +plus the overlay in `airootfs/` — the repo carries only the delta, so +archiso updates flow in automatically. + +## Install + +1. Write the ISO to a USB stick: `cp out/edge-host-*.iso /dev/sdX` +2. Boot the N100 from it (UEFI), log in as root (auto-login) +3. Run `edge-install` (or `edge-install /dev/sdY` for a different target + disk). It shows the target and requires typing the hostname before + wiping anything. +4. Reboot, then 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`. diff --git a/images/edge-host-iso/airootfs/root/edge-authorized_keys b/images/edge-host-iso/airootfs/root/edge-authorized_keys new file mode 100644 index 00000000..0852b517 --- /dev/null +++ b/images/edge-host-iso/airootfs/root/edge-authorized_keys @@ -0,0 +1,4 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfe3HJqFR+jIGrboweseAO7XjIVE24sje/b1HybgU9C ansible-runner@ci-new +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB1OFjh6Q5xeioI6Cwfuv8Xka27veomYy9tKBbpMo8mcrsB4yIN4FX8HovBLtogetBhwBUefBar8nhA9GbRh6HQ= krenner@krenner-XPS-13-9310 diff --git a/images/edge-host-iso/airootfs/usr/local/bin/edge-install b/images/edge-host-iso/airootfs/usr/local/bin/edge-install new file mode 100755 index 00000000..e768a497 --- /dev/null +++ b/images/edge-host-iso/airootfs/usr/local/bin/edge-install @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Installer for edge-1 (bare-metal Arch N100, replaces the k8s-edge VM). +# Runs from the edge-host ISO live environment. WIPES the target disk. +# Usage: edge-install [disk] (default /dev/nvme0n1) +set -euo pipefail + +DISK="${1:-/dev/nvme0n1}" +HOST=edge-1 + +[ -b "$DISK" ] || { echo "no such block device: $DISK"; exit 1; } + +echo "Target disk: $DISK" +lsblk "$DISK" +echo +read -rp "This WIPES ${DISK} and installs ${HOST}. Type '${HOST}' to continue: " ans +[ "$ans" = "$HOST" ] || { echo "aborted"; exit 1; } + +part() { + case "$DISK" in + *nvme*|*mmcblk*) echo "${DISK}p$1" ;; + *) echo "${DISK}$1" ;; + esac +} + +sgdisk --zap-all "$DISK" +sgdisk -n1:0:+512M -t1:ef00 -c1:ESP -N2 -t2:8304 -c2:root "$DISK" +partprobe "$DISK" +mkfs.fat -F32 "$(part 1)" +mkfs.ext4 -F "$(part 2)" + +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). +pacstrap -K /mnt base linux linux-firmware intel-ucode openssh python git \ + docker docker-compose + +genfstab -U /mnt >> /mnt/etc/fstab +echo "$HOST" > /mnt/etc/hostname +ln -sf /usr/share/zoneinfo/Europe/Berlin /mnt/etc/localtime +echo 'en_US.UTF-8 UTF-8' > /mnt/etc/locale.gen +echo 'LANG=en_US.UTF-8' > /mnt/etc/locale.conf +arch-chroot /mnt locale-gen + +cat > /mnt/etc/systemd/network/20-wired.network <<'EOF' +[Match] +Type=ether + +[Network] +DHCP=yes +EOF +ln -sf /run/systemd/resolve/stub-resolv.conf /mnt/etc/resolv.conf + +# Key-only root SSH (no root password is set — console login stays locked, +# which is fine for a headless box; recovery goes through this ISO). +install -d -m700 /mnt/root/.ssh +install -m600 /root/edge-authorized_keys /mnt/root/.ssh/authorized_keys + +arch-chroot /mnt systemctl enable \ + systemd-networkd systemd-resolved systemd-timesyncd sshd docker + +arch-chroot /mnt bootctl install +cat > /mnt/boot/loader/loader.conf <<'EOF' +default edge.conf +timeout 3 +EOF +cat > /mnt/boot/loader/entries/edge.conf <> "$profile/packages.x86_64" +done + +# Brand the image and make the installer executable inside the ISO. +sed -i 's/^iso_name=.*/iso_name="edge-host"/' "$profile/profiledef.sh" +sed -i '/^file_permissions=(/a\ ["/usr/local/bin/edge-install"]="0:0:755"' "$profile/profiledef.sh" + +mkarchiso -v -w "$work/build" -o "$out" "$profile" +echo +echo "ISO in $out — write it with:" +echo " cp out/edge-host-*.iso /dev/sdX # or dd/ventoy; USB stick, NOT the NVMe" From dbe8efdc621329b2c6c61a0ddb2753117772e296 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 12:34:13 +0200 Subject: [PATCH 03/14] images: build edge-host ISO in an archlinux container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mkarchiso against the host repos fails on Manjaro (no archinstall/ reflector, linux is a virtual package) — releng needs real Arch repos. Split the profile assembly into build-inner.sh and run it in archlinux:latest (--privileged for pacstrap's bind mounts); verified build on the Manjaro workstation. gitignore the ISO output dir. Co-Authored-By: Claude Fable 5 --- images/edge-host-iso/.gitignore | 1 + images/edge-host-iso/build-inner.sh | 23 +++++++++++++++++++ images/edge-host-iso/build.sh | 34 ++++++++++------------------- 3 files changed, 36 insertions(+), 22 deletions(-) create mode 100644 images/edge-host-iso/.gitignore create mode 100755 images/edge-host-iso/build-inner.sh diff --git a/images/edge-host-iso/.gitignore b/images/edge-host-iso/.gitignore new file mode 100644 index 00000000..89f9ac04 --- /dev/null +++ b/images/edge-host-iso/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/images/edge-host-iso/build-inner.sh b/images/edge-host-iso/build-inner.sh new file mode 100755 index 00000000..3e281138 --- /dev/null +++ b/images/edge-host-iso/build-inner.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Runs INSIDE the archlinux container (invoked by build.sh). +# Assembles the profile from the container's releng + the repo overlay, +# then builds the ISO to /out. +set -euo pipefail + +pacman -Syu --noconfirm archiso + +profile=/tmp/profile +cp -r /usr/share/archiso/configs/releng/. "$profile" +cp -r /src/airootfs/. "$profile/airootfs/" + +# Tools the installer script needs in the live environment (idempotent — +# skip entries releng already ships). +for pkg in gptfdisk dosfstools e2fsprogs; do + grep -qx "$pkg" "$profile/packages.x86_64" || echo "$pkg" >> "$profile/packages.x86_64" +done + +# Brand the image and make the installer executable inside the ISO. +sed -i 's/^iso_name=.*/iso_name="edge-host"/' "$profile/profiledef.sh" +sed -i '/^file_permissions=(/a\ ["/usr/local/bin/edge-install"]="0:0:755"' "$profile/profiledef.sh" + +mkarchiso -v -w /tmp/work -o /out "$profile" diff --git a/images/edge-host-iso/build.sh b/images/edge-host-iso/build.sh index 5a8193d1..4aa9008e 100755 --- a/images/edge-host-iso/build.sh +++ b/images/edge-host-iso/build.sh @@ -1,31 +1,21 @@ #!/usr/bin/env bash -# Build the edge-host installer ISO (see README.md). -# Requires the `archiso` package; mkarchiso needs root. -# sudo ./build.sh [output-dir] +# Build the edge-host installer ISO inside an Arch container. +# Building must happen against real Arch repos — a Manjaro (or any +# non-Arch) host can't feed mkarchiso, so the container is the default +# and only path. Needs docker; --privileged because pacstrap bind-mounts +# into the chroot. +# ./build.sh [output-dir] set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)" out="${1:-$here/out}" -work="$(mktemp -d /tmp/edge-host-iso.XXXXXX)" -profile="$work/profile" -trap 'rm -rf "$work"' EXIT +mkdir -p "$out" -# Base on the official releng profile and overlay our bits — this way the -# repo carries only the delta, not a fork of the whole profile. -cp -r /usr/share/archiso/configs/releng/. "$profile" -cp -r "$here/airootfs/." "$profile/airootfs/" +docker run --rm --privileged \ + -v "$here:/src:ro" \ + -v "$out:/out" \ + archlinux:latest /src/build-inner.sh -# Tools the installer script needs in the live environment (idempotent — -# skip entries releng already ships). -for pkg in gptfdisk dosfstools e2fsprogs; do - grep -qx "$pkg" "$profile/packages.x86_64" || echo "$pkg" >> "$profile/packages.x86_64" -done - -# Brand the image and make the installer executable inside the ISO. -sed -i 's/^iso_name=.*/iso_name="edge-host"/' "$profile/profiledef.sh" -sed -i '/^file_permissions=(/a\ ["/usr/local/bin/edge-install"]="0:0:755"' "$profile/profiledef.sh" - -mkarchiso -v -w "$work/build" -o "$out" "$profile" echo echo "ISO in $out — write it with:" -echo " cp out/edge-host-*.iso /dev/sdX # or dd/ventoy; USB stick, NOT the NVMe" +echo " sudo sh -c 'cp $out/edge-host-*.iso /dev/sdX && sync' # USB stick, NOT the NVMe" From 5586f4bfee3f48748e749ffd272ce6eb126b26c0 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 12:38:01 +0200 Subject: [PATCH 04/14] edge-host: open3e must publish JSON payloads (-j) Without -j open3e flattens complex DIDs into per-key subtopics; the HA package parses value_json on the base topic and receives nothing. Co-Authored-By: Claude Fable 5 --- ansible/roles/edge-host/templates/open3e.service.j2 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ansible/roles/edge-host/templates/open3e.service.j2 b/ansible/roles/edge-host/templates/open3e.service.j2 index 2044333f..ea29f87b 100644 --- a/ansible/roles/edge-host/templates/open3e.service.j2 +++ b/ansible/roles/edge-host/templates/open3e.service.j2 @@ -1,6 +1,9 @@ # Managed by ansible (roles/edge-host). # READ-ONLY listener — no -w, no service 0x77. Writes are Phase 3 and get # their own review; this unit must never grow write flags casually. +# -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] @@ -23,7 +26,8 @@ ExecStart={{ edge_base_dir }}/open3e/venv/bin/open3e \ -mcid open3e-edge \ -mfstr "{{ edge_open3e_mqtt_format }}" \ -r {{ edge_open3e_datapoints }} \ - -t {{ edge_open3e_poll_seconds }} + -t {{ edge_open3e_poll_seconds }} \ + -j Restart=on-failure RestartSec=10 From 81dc3d0a8558b3b7f1d985adb7dfca8c655f727d Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 12:42:40 +0200 Subject: [PATCH 05/14] edge-host-iso: headless install support + internal-disk autodetect Target changed from N100 to a Wyse 3040 (2G RAM / 8G eMMC): default disk is now autodetected (nvme, else mmcblk0, else first non-USB disk) and the live environment carries the homelab SSH keys, so the install runs over SSH without a monitor (firmware boot-order/Secure-Boot still needs one console visit if not already set). Co-Authored-By: Claude Fable 5 --- images/edge-host-iso/README.md | 12 ++++++++---- .../airootfs/root/.ssh/authorized_keys | 4 ++++ .../airootfs/usr/local/bin/edge-install | 16 +++++++++++++--- images/edge-host-iso/build-inner.sh | 4 ++++ 4 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 images/edge-host-iso/airootfs/root/.ssh/authorized_keys diff --git a/images/edge-host-iso/README.md b/images/edge-host-iso/README.md index 2f5f8523..66a70dd0 100644 --- a/images/edge-host-iso/README.md +++ b/images/edge-host-iso/README.md @@ -30,10 +30,14 @@ archiso updates flow in automatically. ## Install 1. Write the ISO to a USB stick: `cp out/edge-host-*.iso /dev/sdX` -2. Boot the N100 from it (UEFI), log in as root (auto-login) -3. Run `edge-install` (or `edge-install /dev/sdY` for a different target - disk). It shows the target and requires typing the hostname before - wiping anything. +2. Boot the target from it (UEFI, Secure Boot off; one-time F12/setup + visit if the box doesn't already boot USB first) +3. Either on the console (auto-login as root) or headless — + `ssh root@` works out of the box, the live env carries the + homelab keys — run `edge-install`. It auto-detects the internal disk + (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: ``` cd ansible diff --git a/images/edge-host-iso/airootfs/root/.ssh/authorized_keys b/images/edge-host-iso/airootfs/root/.ssh/authorized_keys new file mode 100644 index 00000000..0852b517 --- /dev/null +++ b/images/edge-host-iso/airootfs/root/.ssh/authorized_keys @@ -0,0 +1,4 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfe3HJqFR+jIGrboweseAO7XjIVE24sje/b1HybgU9C ansible-runner@ci-new +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB1OFjh6Q5xeioI6Cwfuv8Xka27veomYy9tKBbpMo8mcrsB4yIN4FX8HovBLtogetBhwBUefBar8nhA9GbRh6HQ= krenner@krenner-XPS-13-9310 diff --git a/images/edge-host-iso/airootfs/usr/local/bin/edge-install b/images/edge-host-iso/airootfs/usr/local/bin/edge-install index e768a497..02dc1ee8 100755 --- a/images/edge-host-iso/airootfs/usr/local/bin/edge-install +++ b/images/edge-host-iso/airootfs/usr/local/bin/edge-install @@ -1,12 +1,22 @@ #!/usr/bin/env bash -# Installer for edge-1 (bare-metal Arch N100, replaces the k8s-edge VM). +# Installer for edge-1 (bare-metal edge host, replaces the k8s-edge VM). # Runs from the edge-host ISO live environment. WIPES the target disk. -# Usage: edge-install [disk] (default /dev/nvme0n1) +# Usage: edge-install [disk] +# Default: the internal disk — first NVMe, else eMMC (Wyse 3040), else +# the first non-USB, non-optical disk. The live USB stick itself is +# excluded via the ISO9660 label mount. set -euo pipefail -DISK="${1:-/dev/nvme0n1}" HOST=edge-1 +detect_disk() { + [ -b /dev/nvme0n1 ] && { echo /dev/nvme0n1; return; } + [ -b /dev/mmcblk0 ] && { echo /dev/mmcblk0; return; } + lsblk -dno NAME,TRAN,TYPE | awk '$2!="usb" && $3=="disk" {print "/dev/"$1; exit}' +} + +DISK="${1:-$(detect_disk)}" +[ -n "$DISK" ] || { echo "no internal disk found; pass one explicitly"; exit 1; } [ -b "$DISK" ] || { echo "no such block device: $DISK"; exit 1; } echo "Target disk: $DISK" diff --git a/images/edge-host-iso/build-inner.sh b/images/edge-host-iso/build-inner.sh index 3e281138..97ab4baf 100755 --- a/images/edge-host-iso/build-inner.sh +++ b/images/edge-host-iso/build-inner.sh @@ -19,5 +19,9 @@ done # Brand the image and make the installer executable inside the ISO. sed -i 's/^iso_name=.*/iso_name="edge-host"/' "$profile/profiledef.sh" sed -i '/^file_permissions=(/a\ ["/usr/local/bin/edge-install"]="0:0:755"' "$profile/profiledef.sh" +# Headless install path: releng enables sshd; with the homelab keys on the +# live root you can `ssh root@` and run edge-install monitor-free. +sed -i '/^file_permissions=(/a\ ["/root/.ssh"]="0:0:700"' "$profile/profiledef.sh" +sed -i '/^file_permissions=(/a\ ["/root/.ssh/authorized_keys"]="0:0:600"' "$profile/profiledef.sh" mkarchiso -v -w /tmp/work -o /out "$profile" From 4576c2d13d419311e0a0ae17d647b5380533a37a Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 12:55:14 +0200 Subject: [PATCH 06/14] edge-host: extend open3e datapoint set to 39 DIDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PV energy counters (1802, Energy dashboard solar slot), battery power/energy/temperature and dynamic charge limits, heating/DHW energy split (electric in, thermal out — live COP), compressor modulation and starts/hours, refrigerant inlet/outlet temps, buffer temp, DHW + valve states. Selected from the full DID scans (539+189 DPs). Co-Authored-By: Claude Fable 5 --- ansible/roles/edge-host/defaults/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/roles/edge-host/defaults/main.yml b/ansible/roles/edge-host/defaults/main.yml index 2884a7c1..daa54f11 100644 --- a/ansible/roles/edge-host/defaults/main.yml +++ b/ansible/roles/edge-host/defaults/main.yml @@ -31,7 +31,7 @@ edge_open3e_poll_seconds: 30 # the commissioning workstation; see vican repo CLAUDE.md for the rationale). # NO -w / write DIDs here — live heating + battery, writes are Phase 3. edge_open3e_datapoints: >- - vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.318,vitocal.1043,vitocal.1190,vitocal.2351,vitocal.2352,vitocal.2622,vitocal.2624,vitocal.2625,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1810 + vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.318,vitocal.321,vitocal.324,vitocal.548,vitocal.565,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.2320,vitocal.2346,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,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.1834,vx3.1836,vx3.2240 # ECU addresses; -dev selects codec set, tx selects the UDS address. edge_open3e_devices: vitocal: { tx: "0x680", dpList: "vcal" } From ed80d83a73d5dd63381be67621725498251c9ede Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 13:36:08 +0200 Subject: [PATCH 07/14] edge-host-iso: fix eMMC detection on the Wyse 3040 The live env detects the eMMC card (sdhci_acpi) but does not autoload mmc_block, so /dev/mmcblk0 never appears and edge-install finds no target. Autoload via modules-load.d, belt-and-braces modprobe in edge-install, check mmcblk0-2, and fix the lsblk fallback (empty TRAN column shifted the awk fields). Diagnosed live over SSH on the actual 3040 (10.2.50.109). Co-Authored-By: Claude Fable 5 --- .../airootfs/etc/modules-load.d/edge-mmc.conf | 5 +++++ .../airootfs/usr/local/bin/edge-install | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 images/edge-host-iso/airootfs/etc/modules-load.d/edge-mmc.conf diff --git a/images/edge-host-iso/airootfs/etc/modules-load.d/edge-mmc.conf b/images/edge-host-iso/airootfs/etc/modules-load.d/edge-mmc.conf new file mode 100644 index 00000000..9a0526e4 --- /dev/null +++ b/images/edge-host-iso/airootfs/etc/modules-load.d/edge-mmc.conf @@ -0,0 +1,5 @@ +# The Wyse 3040's eMMC card is detected by sdhci_acpi, but the live env +# does not reliably autoload mmc_block — without it no /dev/mmcblk* +# appears and edge-install finds no target. Load explicitly. +mmc_block +sdhci_acpi diff --git a/images/edge-host-iso/airootfs/usr/local/bin/edge-install b/images/edge-host-iso/airootfs/usr/local/bin/edge-install index 02dc1ee8..4709f27f 100755 --- a/images/edge-host-iso/airootfs/usr/local/bin/edge-install +++ b/images/edge-host-iso/airootfs/usr/local/bin/edge-install @@ -10,9 +10,17 @@ set -euo pipefail HOST=edge-1 detect_disk() { + # eMMC needs mmc_block before any /dev/mmcblk* exists (see + # modules-load.d/edge-mmc.conf; belt-and-braces here for older sticks). + modprobe -q mmc_block sdhci_acpi 2>/dev/null || true + udevadm settle 2>/dev/null || true [ -b /dev/nvme0n1 ] && { echo /dev/nvme0n1; return; } - [ -b /dev/mmcblk0 ] && { echo /dev/mmcblk0; return; } - lsblk -dno NAME,TRAN,TYPE | awk '$2!="usb" && $3=="disk" {print "/dev/"$1; exit}' + for d in /dev/mmcblk0 /dev/mmcblk1 /dev/mmcblk2; do + [ -b "$d" ] && { echo "$d"; return; } + done + # NAME,TYPE first — TRAN is often empty (eMMC, virtio) and would shift + # whitespace-split fields if it sat in the middle. + lsblk -dno NAME,TYPE,TRAN | awk '$2=="disk" && $3!="usb" {print "/dev/"$1; exit}' } DISK="${1:-$(detect_disk)}" From ecadc983beef1acda93cff5c22078b816b0a028b Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 13:37:11 +0200 Subject: [PATCH 08/14] =?UTF-8?q?edge-host:=20poll=20the=20VCMU=20(0x68c)?= =?UTF-8?q?=20refrigerant-circuit=20ECU=20=E2=80=94=2053=20DIDs=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds suction/discharge pressure, superheat, EEV positions, fan speeds, compressor rps, four-way valve + circuit status, evaporator/economizer/ oil temps and defrost energy target, discovered via depictSystem ECU census + full VCMU DID scan. Co-Authored-By: Claude Fable 5 --- ansible/roles/edge-host/defaults/main.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ansible/roles/edge-host/defaults/main.yml b/ansible/roles/edge-host/defaults/main.yml index daa54f11..4d3b7145 100644 --- a/ansible/roles/edge-host/defaults/main.yml +++ b/ansible/roles/edge-host/defaults/main.yml @@ -31,11 +31,15 @@ edge_open3e_poll_seconds: 30 # the commissioning workstation; see vican repo CLAUDE.md for the rationale). # NO -w / write DIDs here — live heating + battery, writes are Phase 3. edge_open3e_datapoints: >- - vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.318,vitocal.321,vitocal.324,vitocal.548,vitocal.565,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.2320,vitocal.2346,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,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.1834,vx3.1836,vx3.2240 + vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.318,vitocal.321,vitocal.324,vitocal.548,vitocal.565,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.2320,vitocal.2346,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,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.1834,vx3.1836,vx3.2240,vcmu.322,vcmu.325,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571 # 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" } vx3: { tx: "0x6a1", dpList: "vx3" } + vcmu: { tx: "0x68c" } # --- Secrets: define these in inventory/group_vars/edge_host/vault.yml # (ansible-vault). See README.md for the kubectl one-liners that read the From 244929fff2ad240cae534de41d632d75eb934777 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 13:48:19 +0200 Subject: [PATCH 09/14] edge-host-iso: pin mmc modules into the installed initramfs mkinitcpio's autodetect missed the eMMC stack in the chroot; the installed system hung unbootable with root on mmcblk0p2. Pin sdhci_acpi/sdhci_pci/mmc_block in MODULES and regenerate during install. Co-Authored-By: Claude Fable 5 --- images/edge-host-iso/airootfs/usr/local/bin/edge-install | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/images/edge-host-iso/airootfs/usr/local/bin/edge-install b/images/edge-host-iso/airootfs/usr/local/bin/edge-install index 4709f27f..135e8836 100755 --- a/images/edge-host-iso/airootfs/usr/local/bin/edge-install +++ b/images/edge-host-iso/airootfs/usr/local/bin/edge-install @@ -55,6 +55,12 @@ pacstrap -K /mnt base linux linux-firmware intel-ucode openssh python git \ docker docker-compose genfstab -U /mnt >> /mnt/etc/fstab + +# eMMC boot: autodetect does not reliably include the mmc stack, and a +# root on /dev/mmcblk0p2 without mmc_block in the initramfs is unbootable +# (observed on the Wyse 3040). Pin the modules and regenerate. +sed -i 's/^MODULES=.*/MODULES=(sdhci_acpi sdhci_pci mmc_block)/' /mnt/etc/mkinitcpio.conf +arch-chroot /mnt mkinitcpio -P echo "$HOST" > /mnt/etc/hostname ln -sf /usr/share/zoneinfo/Europe/Berlin /mnt/etc/localtime echo 'en_US.UTF-8 UTF-8' > /mnt/etc/locale.gen From f8d6025048fa5a001682f26bea45bffd4cdccfa8 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 14:07:18 +0200 Subject: [PATCH 10/14] =?UTF-8?q?edge-host:=20edge-1=20lives=20on=20the=20?= =?UTF-8?q?IoT=20VLAN=20=E2=80=94=20fix=20inventory=20FQDN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ansible/inventory/pve_hosts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index e810dc6f..4a8fbaf1 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -41,4 +41,4 @@ wp-agent-1.srv.renner.uno ansible_user=root wp-agent-2.srv.renner.uno ansible_user=root [edge_host] -edge-1.srv.renner.uno ansible_user=root +edge-1.iot.renner.uno ansible_user=root From 89da3ef0d4be728f95b11febb075649645094554 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 14:10:56 +0200 Subject: [PATCH 11/14] edge-host: don't abort the play on not-yet-moved USB devices The cutover order moves devices one at a time; a compose service whose stick is still on the old VM must not block open3e installation. Co-Authored-By: Claude Fable 5 --- ansible/roles/edge-host/tasks/main.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ansible/roles/edge-host/tasks/main.yml b/ansible/roles/edge-host/tasks/main.yml index 538de601..76b01fd0 100644 --- a/ansible/roles/edge-host/tasks/main.yml +++ b/ansible/roles/edge-host/tasks/main.yml @@ -71,15 +71,30 @@ 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 From 46152c0bdfd49b7c764ca3201172910946f430b0 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 14:12:50 +0200 Subject: [PATCH 12/14] edge-host: pip module needs python-packaging on the target Co-Authored-By: Claude Fable 5 --- ansible/roles/edge-host/tasks/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/ansible/roles/edge-host/tasks/main.yml b/ansible/roles/edge-host/tasks/main.yml index 76b01fd0..d95dad68 100644 --- a/ansible/roles/edge-host/tasks/main.yml +++ b/ansible/roles/edge-host/tasks/main.yml @@ -8,6 +8,7 @@ ansible.builtin.package: name: - python + - python-packaging # ansible's pip module imports it on the target - git state: present From 9e03cfa21384752d89ffcf7b6e77cc0eda8b4d31 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 14:16:50 +0200 Subject: [PATCH 13/14] edge-host: force can0 up via ActivationPolicy=always-up networkd applies [CAN] bitrate but does not raise the link by itself; also make the handler reconfigure after reload (reload alone leaves existing links untouched). Co-Authored-By: Claude Fable 5 --- ansible/roles/edge-host/handlers/main.yml | 6 ++++-- ansible/roles/edge-host/templates/80-can0.network.j2 | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ansible/roles/edge-host/handlers/main.yml b/ansible/roles/edge-host/handlers/main.yml index 47e2bb9b..8962059a 100644 --- a/ansible/roles/edge-host/handlers/main.yml +++ b/ansible/roles/edge-host/handlers/main.yml @@ -1,7 +1,9 @@ --- +# reload alone only re-reads config files; existing links keep their old +# state until reconfigured. - name: Reload networkd - ansible.builtin.command: - cmd: networkctl reload + ansible.builtin.shell: + cmd: networkctl reload && networkctl reconfigure {{ edge_can_interface }} changed_when: true - name: Restart edge compose services diff --git a/ansible/roles/edge-host/templates/80-can0.network.j2 b/ansible/roles/edge-host/templates/80-can0.network.j2 index 4fb7159d..b091154a 100644 --- a/ansible/roles/edge-host/templates/80-can0.network.j2 +++ b/ansible/roles/edge-host/templates/80-can0.network.j2 @@ -4,6 +4,11 @@ [Match] Name={{ edge_can_interface }} +[Link] +# networkd applies [CAN] settings but does not raise a CAN link on its +# own (observed on edge-1); force it up, and back up after any down. +ActivationPolicy=always-up + [CAN] BitRate={{ edge_can_bitrate }} # Auto-recover the link if the controller ever goes bus-off. From 4a49470cd2d3bb6adf7fdf4b61b7b50da3277230 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Thu, 2 Jul 2026 14:23:26 +0200 Subject: [PATCH 14/14] edge-host: open3e dir owned by service user (udsoncan log file) Co-Authored-By: Claude Fable 5 --- ansible/roles/edge-host/tasks/main.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ansible/roles/edge-host/tasks/main.yml b/ansible/roles/edge-host/tasks/main.yml index d95dad68..b88c47b0 100644 --- a/ansible/roles/edge-host/tasks/main.yml +++ b/ansible/roles/edge-host/tasks/main.yml @@ -39,7 +39,6 @@ - "{{ edge_base_dir }}" - "{{ edge_base_dir }}/zwave-js/store" - "{{ edge_base_dir }}/sml2mqtt" - - "{{ edge_base_dir }}/open3e" # --- Compose services: zwave-js-ui + sml2mqtt --- - name: Deploy docker-compose.yml @@ -105,6 +104,16 @@ 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 }}"