Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9848d2bf5 | ||
|
|
fac46aa7ac | ||
|
|
6fb760edae | ||
|
|
96fcb426aa | ||
|
|
56a9859e50 | ||
|
|
4df1018e05 | ||
|
|
0e4d516722 | ||
|
|
603db4ed95 | ||
|
|
5b03d6b7af | ||
|
|
343e4845e2 | ||
|
|
5d56c35017 |
@@ -217,3 +217,32 @@ steps:
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest
|
||||
|
||||
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
|
||||
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
|
||||
# unpinned VCS requirement re-cloned and restarted the heating bridge on every
|
||||
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
|
||||
# builds the same way and lands in the same registry so the audit sees it.
|
||||
- name: build-heating-can
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
directory: ci/images/heating-can
|
||||
depends_on: []
|
||||
when:
|
||||
- event: manual
|
||||
- event: push
|
||||
path: ["ci/images/heating-can/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
|
||||
- TAG=$(date +%Y-%m-%d)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/heating-can:latest
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
# Service root. Kept per-role so the role is usable on its own.
|
||||
edge_base_dir: /opt/edge-host
|
||||
|
||||
edge_open3e_pkg: git+https://github.com/open3e/open3e.git
|
||||
edge_open3e_mqtt_user: open3e
|
||||
edge_open3e_mqtt_topic: open3e
|
||||
edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}"
|
||||
@@ -37,16 +36,25 @@ edge_open3e_devices:
|
||||
vitocal: { tx: "0x680", dpList: "vcal" }
|
||||
# dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe
|
||||
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
|
||||
vx3: { tx: "0x6a1", dpList: "/opt/edge-host/open3e/vx3plus.py" }
|
||||
vx3: { tx: "0x6a1", dpList: "/config/vx3plus.py" }
|
||||
vcmu: { tx: "0x68c" }
|
||||
|
||||
# --- E3onCAN: passive E380 energy-meter listener (no bus writes, so it
|
||||
# coexists with the single open3e instance). The meter broadcasts at
|
||||
# ~5 Hz; the gap throttles MQTT to one update per DID per interval.
|
||||
edge_e3oncan_repo: https://github.com/MyHomeMyData/E3onCAN.git
|
||||
edge_e3oncan_version: 461dcbdf97e8d72d2edb9929c63b39382af6c3a1 # V0.5.2
|
||||
edge_e3oncan_gap_seconds: 10
|
||||
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
|
||||
|
||||
# Secrets come from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_open3e_mqtt_password
|
||||
|
||||
# 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,12 +1,10 @@
|
||||
---
|
||||
- name: Restart open3e
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
name: open3e
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart e3oncan
|
||||
ansible.builtin.systemd:
|
||||
name: e3oncan
|
||||
state: restarted
|
||||
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,116 +1,119 @@
|
||||
---
|
||||
# Heating system over the Viessmann E3 CAN bus.
|
||||
#
|
||||
# Two readers of the same heating installation:
|
||||
# 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
|
||||
# feeds the heat pump's own energy management
|
||||
#
|
||||
# They coexist because e3oncan issues no bus writes. Both are native installs
|
||||
# (venv + systemd) rather than containers: they need a SocketCAN interface,
|
||||
# which is host network state. CAN_RAW sockets need no capabilities, so they
|
||||
# run as unprivileged system users.
|
||||
# They coexist because e3oncan issues no bus writes.
|
||||
#
|
||||
# The can0 interface itself belongs to the socketcan role -- an interface is
|
||||
# not the same concern as the services that consume it -- so this role must
|
||||
# run after it.
|
||||
# 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: Install build/runtime packages for the CAN services
|
||||
ansible.builtin.package:
|
||||
name:
|
||||
- python
|
||||
- python-packaging
|
||||
- git
|
||||
state: present
|
||||
|
||||
- name: Create open3e system user
|
||||
ansible.builtin.user:
|
||||
name: open3e
|
||||
system: true
|
||||
create_home: false
|
||||
shell: /usr/bin/nologin
|
||||
|
||||
# Owned by the service user: udsoncan writes its log file into the
|
||||
# working directory.
|
||||
- name: Create open3e directory
|
||||
- name: Create heating-can directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ edge_base_dir }}/open3e"
|
||||
path: "{{ heating_can_base_dir }}"
|
||||
state: directory
|
||||
owner: open3e
|
||||
group: open3e
|
||||
mode: "0755"
|
||||
|
||||
- name: Install open3e into a venv
|
||||
ansible.builtin.pip:
|
||||
name: "{{ edge_open3e_pkg }}"
|
||||
virtualenv: "{{ edge_base_dir }}/open3e/venv"
|
||||
virtualenv_command: python -m venv
|
||||
notify: Restart open3e
|
||||
# --- Migration off the native install ---------------------------------------
|
||||
# Must happen before the containers start. Two open3e instances issuing UDS
|
||||
# requests on one CAN bus is the failure mode to avoid, so the old units are
|
||||
# stopped and masked rather than merely disabled -- masked so a stray
|
||||
# `systemctl start` or a leftover dependency cannot bring them back alongside
|
||||
# the containers.
|
||||
- name: Stop and mask the native CAN services
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
enabled: false
|
||||
masked: true
|
||||
loop:
|
||||
- open3e
|
||||
- e3oncan
|
||||
failed_when: false
|
||||
|
||||
- name: Remove the native unit files
|
||||
ansible.builtin.file:
|
||||
path: "/etc/systemd/system/{{ item }}.service"
|
||||
state: absent
|
||||
loop:
|
||||
- open3e
|
||||
- e3oncan
|
||||
notify: Reload systemd
|
||||
|
||||
# The venvs are the point of this change -- leaving them would leave the thing
|
||||
# that churned. Their configuration is NOT removed: devices.json, the overlay
|
||||
# and the env file are still rendered below and bind-mounted into the
|
||||
# containers.
|
||||
- name: Remove the native venvs
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- "{{ edge_base_dir }}/open3e/venv"
|
||||
- "{{ edge_base_dir }}/e3oncan/venv"
|
||||
- "{{ edge_base_dir }}/e3oncan/src"
|
||||
- "{{ edge_base_dir }}/open3e"
|
||||
- "{{ edge_base_dir }}/e3oncan"
|
||||
|
||||
# --- Configuration (bind-mounted into the containers) ------------------------
|
||||
- name: Deploy open3e devices.json
|
||||
ansible.builtin.copy:
|
||||
content: "{{ edge_open3e_devices | to_nice_json }}\n"
|
||||
dest: "{{ heating_can_base_dir }}/devices.json"
|
||||
mode: "0644"
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Deploy open3e vx3 datapoint overlay (battery-module DIDs)
|
||||
ansible.builtin.copy:
|
||||
src: open3e-vx3plus.py
|
||||
dest: "{{ edge_base_dir }}/open3e/vx3plus.py"
|
||||
dest: "{{ heating_can_base_dir }}/vx3plus.py"
|
||||
mode: "0644"
|
||||
notify: Restart open3e
|
||||
notify: Restart heating-can
|
||||
|
||||
- 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)
|
||||
# --- Compose project ---------------------------------------------------------
|
||||
- name: Deploy the broker credential env file
|
||||
ansible.builtin.template:
|
||||
src: open3e.env.j2
|
||||
dest: "{{ edge_base_dir }}/open3e/open3e.env"
|
||||
owner: open3e
|
||||
src: heating-can.env.j2
|
||||
dest: "{{ heating_can_base_dir }}/heating-can.env"
|
||||
mode: "0600"
|
||||
notify: Restart open3e
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Deploy open3e systemd unit
|
||||
- name: Deploy heating-can compose project
|
||||
ansible.builtin.template:
|
||||
src: open3e.service.j2
|
||||
dest: /etc/systemd/system/open3e.service
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ heating_can_base_dir }}/docker-compose.yml"
|
||||
mode: "0644"
|
||||
notify: Restart open3e
|
||||
notify: Restart heating-can
|
||||
|
||||
- name: Enable open3e
|
||||
ansible.builtin.systemd:
|
||||
name: open3e
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
- name: Pull the heating-can image
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose pull
|
||||
chdir: "{{ heating_can_base_dir }}"
|
||||
changed_when: false
|
||||
|
||||
|
||||
# --- E3onCAN: direct E380 meter reads (passive; per-review decision not
|
||||
# to trust the VX3's relayed meter values). Same broker user as open3e;
|
||||
# topics land under the same open3e/ prefix as e380_<did>_<name>.
|
||||
- name: Check out E3onCAN (pinned)
|
||||
ansible.builtin.git:
|
||||
repo: "{{ edge_e3oncan_repo }}"
|
||||
dest: "{{ edge_base_dir }}/e3oncan/src"
|
||||
version: "{{ edge_e3oncan_version }}"
|
||||
notify: Restart e3oncan
|
||||
|
||||
- name: Create E3onCAN venv with requirements
|
||||
ansible.builtin.pip:
|
||||
requirements: "{{ edge_base_dir }}/e3oncan/src/requirements.txt"
|
||||
virtualenv: "{{ edge_base_dir }}/e3oncan/venv"
|
||||
virtualenv_command: python -m venv
|
||||
notify: Restart e3oncan
|
||||
|
||||
- name: Deploy e3oncan systemd unit
|
||||
ansible.builtin.template:
|
||||
src: e3oncan.service.j2
|
||||
dest: /etc/systemd/system/e3oncan.service
|
||||
mode: "0644"
|
||||
notify: Restart e3oncan
|
||||
|
||||
- name: Enable e3oncan
|
||||
ansible.builtin.systemd:
|
||||
name: e3oncan
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
# Unlike the zwave and meter services there is no missing-device tolerance here:
|
||||
# can0 is created by the socketcan role from a USB adapter that is part of this
|
||||
# host's purpose. If it is absent the heating telemetry is down and the run
|
||||
# should say so rather than pass quietly.
|
||||
- name: Start heating-can services
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d
|
||||
chdir: "{{ heating_can_base_dir }}"
|
||||
register: heating_can_up
|
||||
changed_when: >-
|
||||
'Started' in heating_can_up.stderr or 'Created' in heating_can_up.stderr
|
||||
or 'Started' in heating_can_up.stdout or 'Creating' in heating_can_up.stdout
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Managed by ansible (roles/heating-can).
|
||||
#
|
||||
# One container. Both CAN readers live in the image and are supervised inside
|
||||
# it -- this file supplies configuration and nothing else. It does not know
|
||||
# open3e's or E3onCAN's command line.
|
||||
#
|
||||
# network_mode: host is required: SocketCAN interfaces live in the host network
|
||||
# namespace, so {{ edge_can_interface }} is only reachable this way, and it is
|
||||
# also how the local MQTT broker is reached. CAN_RAW needs no capabilities, so
|
||||
# the image runs unprivileged.
|
||||
services:
|
||||
heating-can:
|
||||
image: {{ heating_can_image }}
|
||||
container_name: heating-can
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./devices.json:/config/devices.json:ro
|
||||
- ./vx3plus.py:/config/vx3plus.py:ro
|
||||
env_file:
|
||||
# Broker credential, 0600 -- see heating-can.env.j2.
|
||||
- heating-can.env
|
||||
environment:
|
||||
CAN_INTERFACE: "{{ edge_can_interface }}"
|
||||
MQTT_HOST: "{{ edge_mqtt_host }}"
|
||||
MQTT_PORT: "{{ edge_mqtt_port }}"
|
||||
MQTT_TOPIC: "{{ edge_open3e_mqtt_topic }}"
|
||||
MQTT_FORMAT: "{{ edge_open3e_mqtt_format }}"
|
||||
E3ONCAN_MQTT_FORMAT: "{{ edge_e3oncan_mqtt_format }}"
|
||||
OPEN3E_CONFIG: /config/devices.json
|
||||
OPEN3E_CLIENT_ID: open3e-edge
|
||||
OPEN3E_POLL_SECONDS: "{{ edge_open3e_poll_seconds }}"
|
||||
OPEN3E_DATAPOINTS: "{{ edge_open3e_datapoints }}"
|
||||
E3ONCAN_DEVICE: e380
|
||||
E3ONCAN_GAP_SECONDS: "{{ edge_e3oncan_gap_seconds }}"
|
||||
@@ -1,32 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can).
|
||||
# PASSIVE listener for the E380 energy meter's CANopen broadcasts — no
|
||||
# writes on the bus, so it may run alongside the (single) open3e
|
||||
# instance. Reads the meter directly instead of trusting the VX3's
|
||||
# relayed values.
|
||||
[Unit]
|
||||
Description=E3onCAN E380 energy-meter CAN listener (read-only)
|
||||
BindsTo=sys-subsystem-net-devices-{{ edge_can_interface }}.device
|
||||
# mosquitto.service = the local MQTT bridge broker e3oncan publishes to (loopback).
|
||||
After=sys-subsystem-net-devices-{{ edge_can_interface }}.device network-online.target mosquitto.service
|
||||
Wants=network-online.target mosquitto.service
|
||||
|
||||
[Service]
|
||||
User=open3e
|
||||
EnvironmentFile={{ edge_base_dir }}/open3e/open3e.env
|
||||
WorkingDirectory={{ edge_base_dir }}/e3oncan
|
||||
# Journald-friendly (python block-buffers pipes; lost on restarts).
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart={{ edge_base_dir }}/e3oncan/venv/bin/python {{ edge_base_dir }}/e3oncan/src/E3onCANcollect.py \
|
||||
-c {{ edge_can_interface }} \
|
||||
-dev e380 \
|
||||
-g {{ edge_e3oncan_gap_seconds }} \
|
||||
-m {{ edge_mqtt_host }}:{{ edge_mqtt_port }}:{{ edge_open3e_mqtt_topic }} \
|
||||
-muser {{ edge_open3e_mqtt_user }} \
|
||||
-mpass ${OPEN3E_MQTT_PASSWORD} \
|
||||
-mfstr "{{ edge_e3oncan_mqtt_format }}" \
|
||||
-j
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,8 @@
|
||||
# Managed by ansible (roles/heating-can). 0600 -- carries the broker credential.
|
||||
#
|
||||
# Kept out of docker-compose.yml, which is 0644: the sibling roles on this host
|
||||
# do the same (power-meter-sml renders its config at 0600 while its compose file
|
||||
# stays world-readable), and the native unit this replaced used a 0600 env file
|
||||
# for exactly this value.
|
||||
MQTT_USER={{ edge_open3e_mqtt_user }}
|
||||
MQTT_PASSWORD={{ edge_open3e_mqtt_password }}
|
||||
@@ -1,7 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can). Read by open3e.service and
|
||||
# e3oncan.service.
|
||||
# Full user:password pair as one variable so the open3e unit can pass it
|
||||
# as a single -muser argument without exposing it in the unit file;
|
||||
# e3oncan takes user and password separately (-muser/-mpass).
|
||||
OPEN3E_MUSER={{ edge_open3e_mqtt_user }}:{{ edge_open3e_mqtt_password }}
|
||||
OPEN3E_MQTT_PASSWORD={{ edge_open3e_mqtt_password }}
|
||||
@@ -1,39 +0,0 @@
|
||||
# Managed by ansible (roles/heating-can).
|
||||
# Read-mostly listener — no -w flag, no service 0x77. The ONE sanctioned
|
||||
# write path is the MQTT command topic (-l): HA publishes the DHW
|
||||
# one-time-charge command there (scope decision 2026-07-02, see vican
|
||||
# CLAUDE.md). Anything beyond that single write gets its own review.
|
||||
# -j is load-bearing: without it open3e flattens JSON payloads into
|
||||
# per-key subtopics and the HA package (homeassistant repo,
|
||||
# packages/open3e.yaml) receives nothing.
|
||||
# Exactly ONE open3e instance may be on the bus at a time (CAN collisions
|
||||
# stop open3e) — stop this service before running ad-hoc scans.
|
||||
[Unit]
|
||||
Description=open3e Viessmann E3 CAN-to-MQTT bridge (read-only)
|
||||
# Tie the service to the CAN device: stops on unplug, starts on plug via
|
||||
# the device unit rather than polling.
|
||||
BindsTo=sys-subsystem-net-devices-{{ edge_can_interface }}.device
|
||||
# mosquitto.service = the local MQTT bridge broker open3e publishes to (loopback).
|
||||
After=sys-subsystem-net-devices-{{ edge_can_interface }}.device network-online.target mosquitto.service
|
||||
Wants=network-online.target mosquitto.service
|
||||
|
||||
[Service]
|
||||
User=open3e
|
||||
EnvironmentFile={{ edge_base_dir }}/open3e/open3e.env
|
||||
WorkingDirectory={{ edge_base_dir }}/open3e
|
||||
ExecStart={{ edge_base_dir }}/open3e/venv/bin/open3e \
|
||||
-c {{ edge_can_interface }} \
|
||||
-cnfg {{ edge_base_dir }}/open3e/devices.json \
|
||||
-m {{ edge_mqtt_host }}:{{ edge_mqtt_port }}:{{ edge_open3e_mqtt_topic }} \
|
||||
-muser ${OPEN3E_MUSER} \
|
||||
-mcid open3e-edge \
|
||||
-mfstr "{{ edge_open3e_mqtt_format }}" \
|
||||
-r {{ edge_open3e_datapoints }} \
|
||||
-t {{ edge_open3e_poll_seconds }} \
|
||||
-l {{ edge_open3e_mqtt_topic }}/cmnd \
|
||||
-j
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,120 @@
|
||||
# Heating system over the Viessmann E3 CAN bus: open3e (Vitocal heat pump and
|
||||
# Vitocharge VX3) and E3onCAN (E380 grid energy meter).
|
||||
#
|
||||
# One image, two services. They read the same physical installation over the
|
||||
# same bus and are versioned together, so they are one artifact -- but they run
|
||||
# as two containers from this image so either can crash and restart on its own
|
||||
# without a supervisor as PID 1.
|
||||
#
|
||||
# Why an image at all: both were pip-installed into venvs on the host, open3e
|
||||
# straight from a git URL. pip cannot compare an installed VCS dist against a
|
||||
# requested one without fetching it, so `pip install` re-cloned and rebuilt on
|
||||
# every Ansible converge -- reporting changed, restarting the service, and
|
||||
# putting a gap in heating telemetry each time. Pinning the SHA did not fix
|
||||
# that (verified: pip still clones and rebuilds a pinned VCS requirement). A
|
||||
# tagged image does: `docker compose up -d` on an unchanged tag is a genuine
|
||||
# no-op.
|
||||
#
|
||||
# It also gives the weekly update-audit a normal artifact to track instead of
|
||||
# two awkward special cases -- open3e publishes no tags at all, and E3onCAN is
|
||||
# pinned to an untagged commit between releases.
|
||||
#
|
||||
# Runtime needs host networking: SocketCAN interfaces live in the host network
|
||||
# namespace, so can0 is only reachable with network_mode: host. That is also
|
||||
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
|
||||
# no capabilities, so both run unprivileged.
|
||||
FROM python:3.13-slim AS build
|
||||
|
||||
# Pins live here, in the artifact, rather than in Ansible variables.
|
||||
#
|
||||
# open3e: a commit, because the project publishes NO tags (its tags API returns
|
||||
# an empty array). Self-reported package version at this commit: 0.7.5.
|
||||
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
|
||||
# E3onCAN: an untagged commit on the default branch (2026-06-21, "Fixed: MQTT
|
||||
# client ID collision caused stale retained values"). Later than the repo's
|
||||
# only tag, v0.5.1.
|
||||
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
|
||||
|
||||
# git is needed to FETCH the sources and exists only in this stage. The runtime
|
||||
# stage below copies the built venv and the E3onCAN source out, so neither git
|
||||
# nor the apt cache ends up in the published image.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
RUN pip install --no-cache-dir supervisor \
|
||||
&& pip install --no-cache-dir "git+https://github.com/open3e/open3e.git@${OPEN3E_REF}"
|
||||
|
||||
# E3onCAN is not packaged -- it is run as a script out of its checkout, so the
|
||||
# source has to travel into the runtime image alongside its dependencies.
|
||||
RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
|
||||
&& git -C /opt/e3oncan checkout --quiet "${E3ONCAN_REF}" \
|
||||
&& pip install --no-cache-dir -r /opt/e3oncan/requirements.txt \
|
||||
&& rm -rf /opt/e3oncan/.git
|
||||
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
|
||||
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
|
||||
|
||||
LABEL org.opencontainers.image.title="heating-can" \
|
||||
org.opencontainers.image.description="open3e + E3onCAN, Viessmann E3 CAN bus readers" \
|
||||
org.opencontainers.image.source="https://gitea.renner.uno/k.renner/homelab" \
|
||||
uno.renner.open3e.ref="${OPEN3E_REF}" \
|
||||
uno.renner.e3oncan.ref="${E3ONCAN_REF}"
|
||||
|
||||
# Everything is installed at build time. The runtime image carries the finished
|
||||
# venv and source only -- no git, no compiler, no package index.
|
||||
COPY --from=build /opt/venv /opt/venv
|
||||
COPY --from=build /opt/e3oncan /opt/e3oncan
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Both services log to stdout, which docker's journald driver forwards to the
|
||||
# journal and edge-log-shipper ships to Loki. open3e's systemd unit did NOT set
|
||||
# this, so its output was block-buffered and effectively never reached the
|
||||
# journal -- a long-standing blind spot that this fixes for both.
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# One container, two services, so PID 1 is a supervisor. It restarts either
|
||||
# reader on its own if it dies, reaps children and forwards shutdown -- which a
|
||||
# bare process as PID 1 cannot do for a sibling.
|
||||
#
|
||||
# Configuration arrives as environment variables and supervisord expands them,
|
||||
# so nothing outside this image constructs open3e's or E3onCAN's argv. Those
|
||||
# CLIs are an implementation detail here; a compose file spelling them out
|
||||
# would break whenever either upstream renamed a flag.
|
||||
COPY supervisord.conf /etc/supervisord.conf
|
||||
|
||||
# Build-time self-test. Fails the build if either service cannot actually run,
|
||||
# which is exactly what shipped before: supervisord pointed at
|
||||
# /opt/e3oncan/src/E3onCANcollect.py, a path that does not exist -- the file is
|
||||
# at the repo root. The image built green and would have run open3e while
|
||||
# e3oncan crash-looped, the E380 meter silently absent.
|
||||
#
|
||||
# Done here rather than as a CI step because this needs no registry
|
||||
# credentials and no runner able to pull a private image -- and because a
|
||||
# broken image should never be published in the first place.
|
||||
RUN open3e --help > /dev/null \
|
||||
&& python /opt/e3oncan/E3onCANcollect.py --help > /dev/null \
|
||||
&& supervisord --help > /dev/null \
|
||||
&& grep -q '^\[program:open3e\]' /etc/supervisord.conf \
|
||||
&& grep -q '^\[program:e3oncan\]' /etc/supervisord.conf \
|
||||
&& echo "self-test OK: both services execute; supervisord has both programs"
|
||||
|
||||
# CAN_RAW needs no capabilities, so nothing here runs as root.
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin open3e
|
||||
|
||||
# A writable working directory, owned by the service user. udsoncan writes its
|
||||
# log file into the CWD, and the native install this replaces gave open3e's
|
||||
# directory `owner: open3e` for exactly that reason. /opt is root-owned from the
|
||||
# COPY layers, so running there would fail that write.
|
||||
RUN install -d -o open3e -g open3e -m 0755 /var/lib/open3e
|
||||
|
||||
USER open3e
|
||||
WORKDIR /var/lib/open3e
|
||||
|
||||
ENTRYPOINT ["supervisord", "-c", "/etc/supervisord.conf"]
|
||||
@@ -0,0 +1,62 @@
|
||||
# Both CAN readers, supervised in one container.
|
||||
#
|
||||
# They read the same heating installation over the same bus and are versioned
|
||||
# and deployed together, so they are one container. A container runs one PID 1;
|
||||
# with two processes that PID 1 is a supervisor, which is what this is --
|
||||
# supervisord restarts either service on its own if it dies, reaps children,
|
||||
# and forwards shutdown.
|
||||
#
|
||||
# Configuration comes from the environment (see the compose file). supervisord
|
||||
# expands %(ENV_NAME)s at start, so neither the compose file nor a wrapper
|
||||
# script constructs open3e's or E3onCAN's argv -- those CLIs stay an
|
||||
# implementation detail of this image.
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=open3e
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
pidfile=/tmp/supervisord.pid
|
||||
|
||||
# open3e: UDS request/response to the Vitocal heat pump and Vitocharge VX3.
|
||||
[program:open3e]
|
||||
command=open3e
|
||||
-c %(ENV_CAN_INTERFACE)s
|
||||
-cnfg %(ENV_OPEN3E_CONFIG)s
|
||||
-m %(ENV_MQTT_HOST)s:%(ENV_MQTT_PORT)s:%(ENV_MQTT_TOPIC)s
|
||||
-muser %(ENV_MQTT_USER)s:%(ENV_MQTT_PASSWORD)s
|
||||
-mcid %(ENV_OPEN3E_CLIENT_ID)s
|
||||
-mfstr %(ENV_MQTT_FORMAT)s
|
||||
-r %(ENV_OPEN3E_DATAPOINTS)s
|
||||
-t %(ENV_OPEN3E_POLL_SECONDS)s
|
||||
-l %(ENV_MQTT_TOPIC)s/cmnd
|
||||
-j
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=10
|
||||
stopsignal=INT
|
||||
# Straight to the container's stdout/stderr, so docker's journald driver picks
|
||||
# them up and edge-log-shipper ships them to Loki.
|
||||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
||||
|
||||
# e3oncan: passive CANopen listener for the E380 grid energy meter, which feeds
|
||||
# the heat pump's own energy management. Issues no bus writes, which is why the
|
||||
# two can share the bus.
|
||||
[program:e3oncan]
|
||||
command=python /opt/e3oncan/E3onCANcollect.py
|
||||
-c %(ENV_CAN_INTERFACE)s
|
||||
-dev %(ENV_E3ONCAN_DEVICE)s
|
||||
-g %(ENV_E3ONCAN_GAP_SECONDS)s
|
||||
-m %(ENV_MQTT_HOST)s:%(ENV_MQTT_PORT)s:%(ENV_MQTT_TOPIC)s
|
||||
-muser %(ENV_MQTT_USER)s
|
||||
-mpass %(ENV_MQTT_PASSWORD)s
|
||||
-mfstr %(ENV_E3ONCAN_MQTT_FORMAT)s
|
||||
-j
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=10
|
||||
stopsignal=INT
|
||||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
||||
@@ -38,7 +38,7 @@ spec:
|
||||
# gets the heavy HA pod (8Gi) off the soon-to-be-tiny edge box.
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.8.0 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.8.1 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gotenberg
|
||||
image: gotenberg/gotenberg:8.34.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||
image: gotenberg/gotenberg:8.35.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||
args:
|
||||
- "gotenberg"
|
||||
- "--chromium-disable-javascript=true"
|
||||
|
||||
Reference in New Issue
Block a user