chore(edge): retire the heating-can CAN stack from edge-1 #1595
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -1,54 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can). Datapoint overlay for the One Base
|
||||
# ZigBee gateway on ECU 0x6c3 (BACKENDGATEWAY).
|
||||
#
|
||||
# Upstream declares ZigBee<N>DeviceCurrentValues 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
|
||||
|
||||
# ZigBee<N>DeviceCurrentValues 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},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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 }}
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
# SocketCAN interface. Consumed by heating-can.
|
||||
# --- CAN / open3e (Viessmann E3 external bus) ---
|
||||
edge_can_interface: can0
|
||||
edge_can_bitrate: 250000
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user