diff --git a/ansible/docs/edge-host.md b/ansible/docs/edge-host.md index 7fd93251..41f532cb 100644 --- a/ansible/docs/edge-host.md +++ b/ansible/docs/edge-host.md @@ -1,13 +1,17 @@ # edge-host — bare-metal Arch thin client (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: +Talos node (opentofu `vm_153_k8s_edge.tf`): | 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) | + +The Viessmann E3 CAN bus is not read by an edge host: the ESP32 gateway +`vitocal-can` (open3e-esp32) at the Vitocal publishes `open3e/#` directly to +the cluster broker. `plays/install-edge-host.yml` removes the former +heating-can stack and the can0 configuration from edge hosts. 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 @@ -19,10 +23,6 @@ The deployed unit is a **Dell Wyse 3040** (Atom x5-Z8350, 2 GB RAM) on a flash-wear handling that hardware implies. The installer also supports an N100/NVMe box; it autodetects the target disk, so either works. -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 edge-host installer ISO) Build and boot the dedicated installer image — see @@ -58,14 +58,12 @@ inventory change. | device | group | playbook | |---|---|---| -| Innomaker USB2CAN (Vitocal/VX3 + E380) | `heating_can` | `plays/install-heating-can.yml` | | Nabu Casa ZWA-2 Z-Wave radio | `zwave_gateway` | `plays/install-zwave-gateway.yml` | -Add the host to the matching group, then run that playbook. They depend on -the base play having run: all three need the local MQTT bridge, and two of -them need docker. +Add the host to the matching group, then run that playbook. It depends on +the base play having run: it needs the local MQTT bridge and docker. -A host with nothing attached is simply absent from all three groups and needs +A host with nothing attached is simply absent from these groups and needs none of these commands. ## Secrets (before first play run) @@ -102,9 +100,6 @@ The Z-Wave network keys do NOT need separate handling — they live in Preparation: - [ ] `vault.yml` populated (above), play runs green against edge-1 -- [ ] 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: @@ -122,11 +117,6 @@ sml2mqtt: 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) @@ -141,17 +131,6 @@ Afterwards: mesh-connected workstation. The play reads the image's Alpine release itself and rebuilds the preloaded `.so` when it changes (see "Logging & eMMC" → zwave-js). -- open3e / E3onCAN updates: both are pinned to commits in - `roles/heating-can/defaults/main.yml`, so re-running the play does NOT pick - up upstream changes -- that is deliberate, these drive the heat pump. The - weekly update-audit reports when either falls behind (see - `ci/agents/update-audit/prompt.md`); bump the pin and re-run. - Pinning to a tag is not an option for open3e: it publishes none. -- Ad-hoc CAN scans: `systemctl stop open3e` first (single-instance rule), - restart afterwards. -- can-utils (`candump`) is AUR-only on Arch — install manually via - `pamac build can-utils` if needed for debugging; the role deliberately - doesn't manage AUR packages. ## Logging & eMMC diff --git a/ansible/inventory/group_vars/edge_host/vars.yml b/ansible/inventory/group_vars/edge_host/vars.yml index 8bfc25cd..0a3d375e 100644 --- a/ansible/inventory/group_vars/edge_host/vars.yml +++ b/ansible/inventory/group_vars/edge_host/vars.yml @@ -38,18 +38,11 @@ chrony_hwtimestamp: false # --- Local MQTT contract ------------------------------------------------- # Shared between the broker and its clients, so it belongs to the inventory -# rather than to either side's role. mqtt-bridge listens here; heating-can -# publishes here; and the bridge authenticates to the cluster -# broker AS the open3e user, so it needs that identity too. -# -# Keeping these in one role's defaults only worked while a single play loaded -# every role at once. Splitting the plays by capability exposed it: the bridge -# could not render without a heating-can default, and the CAN services could -# not render without an mqtt-bridge default. +# rather than to either side's role. mqtt-bridge listens here, and its legacy +# bridge authenticates to the cluster broker as the open3e user. edge_mqtt_host: 127.0.0.1 edge_mqtt_port: 1883 edge_open3e_mqtt_user: open3e -edge_open3e_mqtt_topic: open3e # Units claude-bot may restart (ssh-ca role); the containers go through docker. ssh_ca_claude_sudo_units: diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index da350a0b..4be76925 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -42,12 +42,8 @@ nas-2.renner.uno ansible_user=root [zwave_gateway] edge-1.iot.renner.uno -[heating_can] -edge-1.iot.renner.uno - [edge_host:children] zwave_gateway -heating_can # NTP servers: every edge host (edge-1 BACKUP prio 70, edge-2 BACKUP prio 60; # see the ntp_servers map in plays/install-ntp-ha.yml). Talos nodes cannot run diff --git a/ansible/plays/install-edge-host.yml b/ansible/plays/install-edge-host.yml index 16056a9a..ebb12f14 100644 --- a/ansible/plays/install-edge-host.yml +++ b/ansible/plays/install-edge-host.yml @@ -8,7 +8,6 @@ # their own inventory groups: # # plays/install-zwave-gateway.yml [zwave_gateway] ZWA-2 radio -# plays/install-heating-can.yml [heating_can] Innomaker USB2CAN # # Splitting the roles without splitting the plays would have kept the coupling: # one play applying every role to one group is the monolith again, a layer up. @@ -31,6 +30,55 @@ # do not silently depend on the connection user staying privileged. become: true + # The Viessmann E3 CAN bus is read by vitocal-can (open3e-esp32 at the + # Vitocal), not by an edge host. These remove the former heating-can stack + # and the can0 configuration; each is a no-op where they are absent. + pre_tasks: + - name: Check for the retired heating-can compose project + ansible.builtin.stat: + path: /opt/edge-host/heating-can/docker-compose.yml + register: edge_heating_can_compose + + - name: Stop and remove the retired heating-can containers and image + ansible.builtin.command: + cmd: docker compose down --remove-orphans --rmi all + chdir: /opt/edge-host/heating-can + when: edge_heating_can_compose.stat.exists + changed_when: true + + - name: Remove the retired heating-can directory + ansible.builtin.file: + path: /opt/edge-host/heating-can + state: absent + + - name: Remove the can0 networkd configuration + ansible.builtin.file: + path: /etc/systemd/network/80-can0.network + state: absent + register: edge_can0_network + + - name: Reload networkd without the can0 configuration + ansible.builtin.command: + cmd: networkctl reload + when: edge_can0_network.changed + changed_when: true + + - name: Read the can0 link flags + ansible.builtin.command: + cmd: ip -o link show can0 + register: edge_can0_link + check_mode: false + changed_when: false + failed_when: false + + - name: Take can0 down + ansible.builtin.command: + cmd: ip link set can0 down + when: + - edge_can0_link.rc == 0 + - edge_can0_link.stdout is search('[<,]UP[,>]') + changed_when: true + roles: - certificates - common diff --git a/ansible/plays/install-heating-can.yml b/ansible/plays/install-heating-can.yml deleted file mode 100644 index 58c3f550..00000000 --- a/ansible/plays/install-heating-can.yml +++ /dev/null @@ -1,18 +0,0 @@ ---- -# Heating system over the Viessmann E3 CAN bus, for hosts with the Innomaker -# USB2CAN adapter attached. -# -# Targets [heating_can]: membership means the adapter is physically present. -# Two roles because they are two concerns -- the interface is host network -# state, the services consume it -- but one group, because both need the same -# hardware. -# -# Requires the base play (plays/install-edge-host.yml) to have run: these -# services publish to the local MQTT bridge it sets up. -- name: Install heating CAN services - hosts: heating_can - become: true - - roles: - - socketcan - - heating-can diff --git a/ansible/roles/heating-can/defaults/main.yml b/ansible/roles/heating-can/defaults/main.yml deleted file mode 100644 index 85f45e46..00000000 --- a/ansible/roles/heating-can/defaults/main.yml +++ /dev/null @@ -1,71 +0,0 @@ ---- -# Heating system over the Viessmann E3 CAN bus: open3e (heat pump) and -# e3oncan (E380 energy meter). The can0 interface itself is the socketcan -# role. - -# Service root. Kept per-role so the role is usable on its own. -edge_base_dir: /opt/edge-host - -edge_open3e_mqtt_user: open3e -edge_open3e_mqtt_topic: open3e -edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}" -edge_open3e_poll_seconds: 30 -# Read-only datapoint set (Phase 1 extended set, established 2026-07-02 on -# the commissioning workstation; see vican repo CLAUDE.md for the rationale). -# NO -w / write DIDs here — live heating + battery, writes are Phase 3. -# vx3.266 = ErrorDtcHistory (O3EList) — the VX3 fault log; polled to catch -# recurrences of FailureCommunicationEnergyMeter (the E380 energy-meter link -# the internal PV-surplus EMS depends on). Decodes via the master O3EList -# codec inherited through the vx3plus overlay. -# vitocal.543/2350/2543/2560: SmartGrid-/EMS-Status-Beobachtung (2026-07-06) -# — klärt, ob die Eigenstromoptimierung (EMS) bei PV-Überschuss von selbst -# den WW-Boost auslöst (2543.2 steht laut Scan bereits auf +10 K), bevor -# SG-Ready-Verdrahtung oder andere Trigger gebaut werden. Alle vier ro. -# vitocal.259/260 = InfoDtcList/InfoDtcHistory (O3EList) — the HPMU event log, -# the Vitocal-side counterpart to vx3.266. Both are capped lists (2 and 9 -# entries when added), so the multi-frame read is cheap at a 30 s poll. -# Records faults/info events only (NormalOperation, LagDeviceDtcReported, -# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes. -# onebase.*: the 29 paired ZigBee slots on the One Base gateway (ECU 0x6c3) -- -# 8 radiator valves, 9 climate sensors, 9 underfloor actuators, 3 floor -# thermostats. Per-device room temperature, active setpoint, humidity, valve -# opening/demand, battery and RSSI; the local replacement for the ViCare cloud -# room sensors. Needs the onebase overlay (files/open3e-onebase.py) -- the stock -# codec declares 57 bytes and a paired slot answers with 68. -# Only CurrentValues are polled. The room and device registries (1884.., 2084..) -# are static configuration and are read on demand, not every 30 s. -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,onebase.2086,onebase.2089,onebase.2092,onebase.2095,onebase.2098,onebase.2101,onebase.2104,onebase.2107,onebase.2110,onebase.2113,onebase.2116,onebase.2119,onebase.2122,onebase.2125,onebase.2128,onebase.2131,onebase.2134,onebase.2137,onebase.2140,onebase.2143,onebase.2262,onebase.2265,onebase.2268,onebase.2271,onebase.2274,onebase.2277,onebase.2280,onebase.2283,onebase.2286 -# ECU addresses; -dev selects codec set, tx selects the UDS address. -# vcmu (refrigerant-circuit controller) has no dedicated codec set — -# omitting dpList makes open3e fall back to the generic datapoint list, -# which decodes all polled VCMU DIDs. -edge_open3e_devices: - vitocal: { tx: "0x680", dpList: "vcal" } - # dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe - # files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg. - vx3: { tx: "0x6a1", dpList: "/config/vx3plus.py" } - vcmu: { tx: "0x68c" } - # One Base ZigBee gateway. Overlay re-declares the paired ZigBee slots at - # their real 68-byte length (see files/open3e-onebase.py). - onebase: { tx: "0x6c3", dpList: "/config/onebase.py" } - -edge_e3oncan_gap_seconds: 10 -edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}" - -# Secrets come from inventory/group_vars/edge_host/vault.yml: -# edge_open3e_mqtt_password - -# The image carrying both readers, built and pinned in CI -# (ci/images/heating-can/Containerfile). The source pins live in the -# Containerfile, in the artifact -- not here -- so what is deployed is a tag, -# and `docker compose up -d` on an unchanged tag is a genuine no-op. -# -# Tracked by the weekly update-audit as an ordinary image, which is why this is -# simpler to keep current than the two git refs it replaced: one publishes no -# tags at all, the other was pinned to an untagged commit between releases. -heating_can_image: gitea.renner.uno/gitea_admin/heating-can:latest - -# Compose project directory. Separate from the old native layout under -# {{ edge_base_dir }}/open3e, which this role removes. -heating_can_base_dir: "{{ edge_base_dir }}/heating-can" diff --git a/ansible/roles/heating-can/files/open3e-onebase.py b/ansible/roles/heating-can/files/open3e-onebase.py deleted file mode 100644 index da39ea88..00000000 --- a/ansible/roles/heating-can/files/open3e-onebase.py +++ /dev/null @@ -1,54 +0,0 @@ -# Managed by ansible (roles/heating-can). Datapoint overlay for the One Base -# ZigBee gateway on ECU 0x6c3 (BACKENDGATEWAY). -# -# Upstream declares ZigBeeDeviceCurrentValues as 57 bytes. A PAIRED device -# slot on this gateway answers with 68: the 57 known bytes plus the parent -# device serial and three trailing bytes. udsoncan reads only the declared -# length and then treats the next two bytes -- the first half of that serial -- -# as a foreign data identifier, so every paired slot fails to decode with -# "Server returned values for data identifier 0x.... that was not requested". -# The bogus identifier differs per device because the serial does. -# -# This overlay re-declares the 29 paired slots at 68 bytes, reusing upstream's -# sub-type list so the per-device-type O3ESwitch (climate sensor / TRV / floor -# thermostat / actuator, incl. DeviceTemperatureSetpoint) is inherited rather -# than copied. -# -# EMPTY slots answer with 57 and decode against the stock codec, so they are -# left alone and are not polled. A slot that is later unpaired therefore starts -# reporting a decode error on its topic instead of a value. -# -# Verified on ECU 0x6c3, 2026-09-17: 29 paired slots, all 68 bytes. -from open3e.Open3Ecodecs import O3EComplexType, RawCodec -from open3e.Open3Edatapoints import dataIdentifiers as _master - -# ZigBeeDeviceCurrentValues of the paired slots: devices 1-20 run 2086..2143 -# in steps of 3, devices 21-29 continue at 2262..2286. -_PAIRED_CURRENT_VALUES = [2086 + 3 * n for n in range(20)] + [ - 2262 + 3 * n for n in range(9) -] - - -def _with_parent_serial(did): - stock = _master["dids"][did] - return O3EComplexType( - 68, - stock.id, - list(stock.subTypes) - + [ - RawCodec(8, "ParentDeviceSerialnumber"), - RawCodec(2, "Unknown_65_66"), - RawCodec(1, "Unknown_67"), - ], - desc=stock.desc, - info=stock.info, - acc=stock.acc, - ) - - -dataIdentifiers = { - "dids": { - **_master["dids"], - **{did: _with_parent_serial(did) for did in _PAIRED_CURRENT_VALUES}, - } -} diff --git a/ansible/roles/heating-can/files/open3e-vx3plus.py b/ansible/roles/heating-can/files/open3e-vx3plus.py deleted file mode 100644 index 2bebb675..00000000 --- a/ansible/roles/heating-can/files/open3e-vx3plus.py +++ /dev/null @@ -1,20 +0,0 @@ -# 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 -# codec None (= keep the master/general codec, RawCodec hex strings). -# -# Module DIDs (verified answering on ECU 0x6a1, 2026-07-03): -# 1577-1582 ElectricalEnergyStorageModule{One..Six}OperatingData -# (139 B: 8x temperature 0.1 °C + 15x cell voltage mV) -# 3085-3090 ElectricalEnergyStorageModule{One..Six}Information -# (18 B: module voltage 0.01 V, SoH?, counters) -from open3e.Open3EdatapointsVx3 import dataIdentifiers as _vx3 - -dataIdentifiers = { - "dids": { - **_vx3["dids"], - 1577: None, 1578: None, 1579: None, 1580: None, 1581: None, 1582: None, - 3085: None, 3086: None, 3087: None, 3088: None, 3089: None, 3090: None, - } -} diff --git a/ansible/roles/heating-can/handlers/main.yml b/ansible/roles/heating-can/handlers/main.yml deleted file mode 100644 index 93ac6ae8..00000000 --- a/ansible/roles/heating-can/handlers/main.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -- name: Reload systemd - ansible.builtin.systemd: - daemon_reload: true - -- name: Restart heating-can - ansible.builtin.command: - cmd: docker compose up -d --force-recreate - chdir: "{{ heating_can_base_dir }}" - changed_when: true diff --git a/ansible/roles/heating-can/tasks/main.yml b/ansible/roles/heating-can/tasks/main.yml deleted file mode 100644 index 51e32925..00000000 --- a/ansible/roles/heating-can/tasks/main.yml +++ /dev/null @@ -1,172 +0,0 @@ ---- -# Heating system over the Viessmann E3 CAN bus. -# -# Two readers of the same heating installation, supervised inside one container -# from one image (ci/images/heating-can): -# open3e UDS request/response to the Vitocal heat pump and Vitocharge VX3 -# e3oncan passive CANopen listener for the E380 grid energy meter, which -# feeds the heat pump's own energy management -# -# They coexist because e3oncan issues no bus writes. -# -# Nothing is installed here. The image is built in CI with both sources pinned, -# published to the registry, and this host only pulls and runs it. That is what -# removes the churn: these were pip-installed into venvs on the node, and pip -# re-cloned and rebuilt a VCS requirement on every converge -- restarting the -# heating bridge each time -- which pinning the SHA did not fix. -# -# network_mode: host is required, not incidental: SocketCAN interfaces live in -# the host network namespace, so can0 is only reachable that way. It is also how -# the local MQTT broker on 127.0.0.1 is reached. CAN_RAW needs no capabilities, -# so both run unprivileged. -# -# The can0 interface itself belongs to the socketcan role; this role consumes it. - -- name: Create heating-can directory - ansible.builtin.file: - path: "{{ heating_can_base_dir }}" - state: directory - mode: "0755" - -# --- Migration off the native install --------------------------------------- -# Must happen before the containers start. Two open3e instances issuing UDS -# requests on one CAN bus is the failure mode to avoid, so the old units are -# stopped and masked rather than merely disabled -- masked so a stray -# `systemctl start` or a leftover dependency cannot bring them back alongside -# the containers. -- name: Find which native CAN units still exist - ansible.builtin.stat: - path: "/etc/systemd/system/{{ item }}.service" - loop: - - open3e - - e3oncan - register: heating_can_native_units - -# Stop and disable BEFORE anything else, and do NOT swallow the result. -# -# The first version did stop + disable + mask in one call with -# failed_when: false, then deleted the unit files. Masking a unit whose file -# still exists conflicts, so the call failed, failed_when hid it, nothing was -# stopped -- and the next task deleted the unit files out from under two -# running processes. systemctl is-enabled then reported "not-found" and looked -# clean while is-active still said "active". -# -# The result on 2026-08-10 was two open3e instances issuing UDS requests on the -# same CAN bus for nine minutes, fighting over the open3e-edge client ID and -# kicking each other off the broker. -# -# Only units that actually exist are touched, so a fresh host does not need an -# error swallowed to get through this. -- name: Stop and disable the native CAN services - ansible.builtin.systemd: - name: "{{ item.item }}" - state: stopped - enabled: false - loop: "{{ heating_can_native_units.results }}" - loop_control: - label: "{{ item.item }}" - when: item.stat.exists - -# Masking is deliberately NOT used. It writes a symlink at the same path the -# next task removes, so the two cancel out -- which is half of how the original -# failed. With the unit files gone there is nothing left to start. -- name: Remove the native unit files - ansible.builtin.file: - path: "/etc/systemd/system/{{ item }}.service" - state: absent - loop: - - open3e - - e3oncan - notify: Reload systemd - -# The invariant that actually matters is "no native process is on the CAN bus", -# and systemd reporting success is not the same claim. Two open3e instances -# polling one bus is the failure this whole block exists to prevent, so it is -# asserted directly rather than inferred from the calls above. -- name: Check for surviving native CAN processes - ansible.builtin.command: - argv: [pgrep, -af, "edge-host/(open3e|e3oncan)"] - register: heating_can_survivors - check_mode: false - changed_when: false - failed_when: false - -- name: Assert no native CAN process survived - ansible.builtin.assert: - that: - - heating_can_survivors.stdout | default('') | length == 0 - fail_msg: >- - A native open3e/e3oncan process is still running after the units were - stopped and removed: - {{ heating_can_survivors.stdout_lines | default([]) | join('; ') }}. - Starting the container now would put two readers on the same CAN bus -- - two open3e instances issue competing UDS requests and take over each - other's MQTT session. Kill the survivors, then re-run. - success_msg: No native CAN processes remain; safe to start the container. - -- name: Remove the native venvs - ansible.builtin.file: - path: "{{ item }}" - state: absent - loop: - - "{{ edge_base_dir }}/open3e/venv" - - "{{ edge_base_dir }}/e3oncan/venv" - - "{{ edge_base_dir }}/e3oncan/src" - - "{{ edge_base_dir }}/open3e" - - "{{ edge_base_dir }}/e3oncan" - -# --- Configuration (bind-mounted into the containers) ------------------------ -- name: Deploy open3e devices.json - ansible.builtin.copy: - content: "{{ edge_open3e_devices | to_nice_json }}\n" - dest: "{{ heating_can_base_dir }}/devices.json" - mode: "0644" - notify: Restart heating-can - -- name: Deploy open3e vx3 datapoint overlay (battery-module DIDs) - ansible.builtin.copy: - src: open3e-vx3plus.py - dest: "{{ heating_can_base_dir }}/vx3plus.py" - mode: "0644" - notify: Restart heating-can - -- name: Deploy open3e One Base datapoint overlay (68-byte ZigBee slots) - ansible.builtin.copy: - src: open3e-onebase.py - dest: "{{ heating_can_base_dir }}/onebase.py" - mode: "0644" - notify: Restart heating-can - -# --- Compose project --------------------------------------------------------- -- name: Deploy the broker credential env file - ansible.builtin.template: - src: heating-can.env.j2 - dest: "{{ heating_can_base_dir }}/heating-can.env" - mode: "0600" - notify: Restart heating-can - -- name: Deploy heating-can compose project - ansible.builtin.template: - src: docker-compose.yml.j2 - dest: "{{ heating_can_base_dir }}/docker-compose.yml" - mode: "0644" - notify: Restart heating-can - -- name: Pull the heating-can image - ansible.builtin.command: - cmd: docker compose pull - chdir: "{{ heating_can_base_dir }}" - changed_when: false - -# Unlike the zwave and meter services there is no missing-device tolerance here: -# can0 is created by the socketcan role from a USB adapter that is part of this -# host's purpose. If it is absent the heating telemetry is down and the run -# should say so rather than pass quietly. -- name: Start heating-can services - ansible.builtin.command: - cmd: docker compose up -d - chdir: "{{ heating_can_base_dir }}" - register: heating_can_up - changed_when: >- - 'Started' in heating_can_up.stderr or 'Created' in heating_can_up.stderr - or 'Started' in heating_can_up.stdout or 'Creating' in heating_can_up.stdout diff --git a/ansible/roles/heating-can/templates/docker-compose.yml.j2 b/ansible/roles/heating-can/templates/docker-compose.yml.j2 deleted file mode 100644 index 0246ec91..00000000 --- a/ansible/roles/heating-can/templates/docker-compose.yml.j2 +++ /dev/null @@ -1,36 +0,0 @@ -# Managed by ansible (roles/heating-can). -# -# One container. Both CAN readers live in the image and are supervised inside -# it -- this file supplies configuration and nothing else. It does not know -# open3e's or E3onCAN's command line. -# -# network_mode: host is required: SocketCAN interfaces live in the host network -# namespace, so {{ edge_can_interface }} is only reachable this way, and it is -# also how the local MQTT broker is reached. CAN_RAW needs no capabilities, so -# the image runs unprivileged. -services: - heating-can: - image: {{ heating_can_image }} - container_name: heating-can - restart: unless-stopped - network_mode: host - volumes: - - ./devices.json:/config/devices.json:ro - - ./vx3plus.py:/config/vx3plus.py:ro - - ./onebase.py:/config/onebase.py:ro - env_file: - # Broker credential, 0600 -- see heating-can.env.j2. - - heating-can.env - environment: - CAN_INTERFACE: "{{ edge_can_interface }}" - MQTT_HOST: "{{ edge_mqtt_host }}" - MQTT_PORT: "{{ edge_mqtt_port }}" - MQTT_TOPIC: "{{ edge_open3e_mqtt_topic }}" - MQTT_FORMAT: "{{ edge_open3e_mqtt_format }}" - E3ONCAN_MQTT_FORMAT: "{{ edge_e3oncan_mqtt_format }}" - OPEN3E_CONFIG: /config/devices.json - OPEN3E_CLIENT_ID: open3e-edge - OPEN3E_POLL_SECONDS: "{{ edge_open3e_poll_seconds }}" - OPEN3E_DATAPOINTS: "{{ edge_open3e_datapoints }}" - E3ONCAN_DEVICE: e380 - E3ONCAN_GAP_SECONDS: "{{ edge_e3oncan_gap_seconds }}" diff --git a/ansible/roles/heating-can/templates/heating-can.env.j2 b/ansible/roles/heating-can/templates/heating-can.env.j2 deleted file mode 100644 index 5a737658..00000000 --- a/ansible/roles/heating-can/templates/heating-can.env.j2 +++ /dev/null @@ -1,8 +0,0 @@ -# Managed by ansible (roles/heating-can). 0600 -- carries the broker credential. -# -# Kept out of docker-compose.yml, which is 0644: the sibling roles on this host -# do the same (power-meter-sml renders its config at 0600 while its compose file -# stays world-readable), and the native unit this replaced used a 0600 env file -# for exactly this value. -MQTT_USER={{ edge_open3e_mqtt_user }} -MQTT_PASSWORD={{ edge_open3e_mqtt_password }} diff --git a/ansible/roles/socketcan/defaults/main.yml b/ansible/roles/socketcan/defaults/main.yml deleted file mode 100644 index c1aa11c2..00000000 --- a/ansible/roles/socketcan/defaults/main.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -# SocketCAN interface. Consumed by heating-can. -# --- CAN / open3e (Viessmann E3 external bus) --- -edge_can_interface: can0 -edge_can_bitrate: 250000 diff --git a/ansible/roles/socketcan/handlers/main.yml b/ansible/roles/socketcan/handlers/main.yml deleted file mode 100644 index e214e26d..00000000 --- a/ansible/roles/socketcan/handlers/main.yml +++ /dev/null @@ -1,7 +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 diff --git a/ansible/roles/socketcan/tasks/main.yml b/ansible/roles/socketcan/tasks/main.yml deleted file mode 100644 index 96d22b38..00000000 --- a/ansible/roles/socketcan/tasks/main.yml +++ /dev/null @@ -1,20 +0,0 @@ ---- -# 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 diff --git a/ansible/roles/socketcan/templates/80-can0.network.j2 b/ansible/roles/socketcan/templates/80-can0.network.j2 deleted file mode 100644 index 0987fec9..00000000 --- a/ansible/roles/socketcan/templates/80-can0.network.j2 +++ /dev/null @@ -1,17 +0,0 @@ -# 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] -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 }} -# NO RestartSec here: gs_usb does not support hardware bus-off restart, -# and networkd fails the ENTIRE link configuration on the unsupported -# option ("off (failed)", link never raised — found by reboot test). -# Bus-off recovery falls to the open3e service restart loop. diff --git a/ansible/site.yml b/ansible/site.yml index 241c682d..6fedda0e 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -31,8 +31,6 @@ ansible.builtin.import_playbook: plays/install-edge-host.yml - name: Z-Wave gateway ansible.builtin.import_playbook: plays/install-zwave-gateway.yml -- name: Heating CAN - ansible.builtin.import_playbook: plays/install-heating-can.yml - name: NTP service ansible.builtin.import_playbook: plays/install-ntp-ha.yml diff --git a/images/edge-host-iso/README.md b/images/edge-host-iso/README.md index bb79a7cb..f918b333 100644 --- a/images/edge-host-iso/README.md +++ b/images/edge-host-iso/README.md @@ -19,7 +19,7 @@ The image is the official archiso **releng** profile plus: - 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 +all service configuration (zwave-js-ui, sml2mqtt) stays in `ansible/docs/edge-host.md` as the single source of truth. Rebuilding the box is: boot ISO → `edge-install` → run the play.