Add dormant sml2mqtt power-meter bridge (phase 1 of moving HA off edge) #83

Merged
k.renner merged 1 commits from worktree-ha-meter-mqtt-bridge into master 2026-06-27 20:46:35 +00:00
5 changed files with 228 additions and 0 deletions
@@ -17,6 +17,8 @@ resources:
- mosquitto-service.yaml
- mosquitto-iot-service.yaml
- mosquitto-deployment.yaml
- sml2mqtt-config.yaml
- sml2mqtt-deployment.yaml
- shelly-manager-pvc.yaml
- shelly-manager-network-attachment.yaml
- shelly-manager-deployment.yaml
@@ -235,3 +235,69 @@ spec:
memory: 32Mi
limits:
memory: 64Mi
---
# Fourth broker user: `sml2mqtt` — the power-meter bridge. Publish-only
# under `sml2mqtt/#` (ACL in mosquitto-deployment.yaml). Own credential so
# its lifecycle is independent of the others. The sml2mqtt pod reads this
# password via its init container and renders it into config.yml.
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-sml2mqtt-credentials-bootstrap
namespace: home-assistant
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-sml2mqtt-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
@@ -40,6 +40,7 @@ spec:
mosquitto_passwd -c -b /mosquitto/auth/passwd homeassistant "$HA_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd shellies "$SHELLY_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd admin "$ADMIN_PASSWORD"
mosquitto_passwd -b /mosquitto/auth/passwd sml2mqtt "$SML2MQTT_PASSWORD"
# ACL: HA keeps full access (subscribes to everything); the
# shellies user is scoped to `shellies/#` for device state +
# `homeassistant/#` so HA MQTT-discovery payloads from Shelly
@@ -48,6 +49,9 @@ spec:
# `admin` is a workstation/debugging credential — full
# readwrite, kept on its own credential so its password can
# be rotated independently of the HA session.
# `sml2mqtt` is the power-meter bridge — publish-only under
# its own `sml2mqtt/#` subtree (HA reads it via the full-access
# homeassistant user).
cat > /mosquitto/auth/acl <<'EOF'
user homeassistant
topic readwrite #
@@ -58,6 +62,9 @@ spec:
user admin
topic readwrite #
user sml2mqtt
topic readwrite sml2mqtt/#
EOF
chown 1883:1883 /mosquitto/auth/passwd /mosquitto/auth/acl
chmod 0600 /mosquitto/auth/passwd /mosquitto/auth/acl
@@ -78,6 +85,11 @@ spec:
secretKeyRef:
name: mosquitto-admin-credentials
key: password
- name: SML2MQTT_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-sml2mqtt-credentials
key: password
ports:
- name: mqtt
containerPort: 1883
@@ -0,0 +1,44 @@
---
# sml2mqtt v3 config TEMPLATE. The broker password is injected at pod
# start by the deployment's init container (sed of __MQTT_PASSWORD__ from
# the mosquitto-sml2mqtt-credentials Secret) into an emptyDir, because
# sml2mqtt reads only a config file and has no env-var override for the
# password. The generated password is [A-Za-z0-9] only, so it is sed-safe.
#
# `Wh in kWh: false` is deliberate: the legacy edl21 sensors publish Wh,
# and the new MQTT sensors reuse the same entity_ids — keeping Wh means
# Home Assistant long-term statistics continue with zero unit discontinuity.
apiVersion: v1
kind: ConfigMap
metadata:
name: sml2mqtt-config
namespace: home-assistant
data:
config.yml: |
logging:
level: INFO
file: '' # stdout -> kubectl logs / Loki
mqtt:
connection:
identifier: sml2mqtt-meter
host: mosquitto.home-assistant.svc.cluster.local
port: 1883
user: sml2mqtt
password: __MQTT_PASSWORD__
topic prefix: sml2mqtt
defaults:
qos: 0
retain: false
last will:
topic: status
general:
Wh in kWh: false # keep raw Wh to match edl21 history (see header)
republish after: 120 # re-emit unchanged values every 120 s
inputs:
- type: serial
url: /dev/stromzaehler # FTDI FT230X, mounted by the deployment
timeout: 3
# 9600 8N1 are sml2mqtt defaults for SML/EDL21 — no override needed.
@@ -0,0 +1,104 @@
---
# sml2mqtt — reads the German SML/EDL21 power meter off its USB FTDI
# serial reader and publishes OBIS values to the in-cluster Mosquitto
# broker. This replaces Home Assistant's built-in `edl21` integration so
# the heavy HA pod no longer needs the USB device and can leave k8s-edge.
#
# Pinned to k8s-edge because the meter's USB reader is physically on that
# node (a genuine hardware constraint, not a stability pin). The pod is
# tiny, so it's an acceptable resident of the soon-to-be-tiny edge box.
#
# SERIAL EXCLUSIVITY: the meter's /dev/ttyUSB0 is a single serial stream;
# edl21 (in the HA pod) and sml2mqtt cannot both read it without
# corrupting each other's SML framing. Therefore this Deployment ships at
# `replicas: 0` (dormant). The cutover is: disable edl21 in HA -> scale
# this to 1 -> add HA MQTT sensors -> evict HA from edge. Until then,
# merging this changes nothing at runtime.
apiVersion: apps/v1
kind: Deployment
metadata:
name: sml2mqtt
namespace: home-assistant
labels:
app.kubernetes.io/name: sml2mqtt
spec:
# Dormant until the edl21 -> MQTT cutover frees the serial port. See header.
replicas: 0
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: sml2mqtt
template:
metadata:
labels:
app.kubernetes.io/name: sml2mqtt
spec:
# USB meter reader is on k8s-edge; tolerate the pinned taint to land there.
nodeSelector:
topology.kubernetes.io/zone: edge
tolerations:
- key: workload-class
operator: Equal
value: pinned
effect: NoSchedule
enableServiceLinks: false
# The injected config.yml (with the broker password) is written here
# by the init container and read by the main container at /sml2mqtt.
initContainers:
- name: render-config
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
sed "s/__MQTT_PASSWORD__/${MQTT_PASSWORD}/" \
/tmpl/config.yml > /sml2mqtt/config.yml
env:
- name: MQTT_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-sml2mqtt-credentials
key: password
volumeMounts:
- name: config-tmpl
mountPath: /tmpl
readOnly: true
- name: rendered
mountPath: /sml2mqtt
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 32Mi
containers:
- name: sml2mqtt
image: spacemanspiff2007/sml2mqtt:3.6.0 # pinned; no image-automation by design (critical meter bridge)
volumeMounts:
- name: rendered
mountPath: /sml2mqtt
- name: stromzaehler
mountPath: /dev/stromzaehler
securityContext:
# The meter device is root:root 0600 on the node, so the
# process must run as root to open it. No `privileged` needed —
# owner-root + the hostPath device mount is sufficient.
runAsUser: 0
runAsGroup: 0
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
memory: 128Mi
volumes:
- name: config-tmpl
configMap:
name: sml2mqtt-config
- name: rendered
emptyDir: {}
- name: stromzaehler
hostPath:
path: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0