diff --git a/kubernetes/apps/home-assistant/kustomization.yaml b/kubernetes/apps/home-assistant/kustomization.yaml index 9f2f2e1c..7f06041e 100644 --- a/kubernetes/apps/home-assistant/kustomization.yaml +++ b/kubernetes/apps/home-assistant/kustomization.yaml @@ -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 diff --git a/kubernetes/apps/home-assistant/mosquitto-bootstrap.yaml b/kubernetes/apps/home-assistant/mosquitto-bootstrap.yaml index 9e39485b..cecdc7b8 100644 --- a/kubernetes/apps/home-assistant/mosquitto-bootstrap.yaml +++ b/kubernetes/apps/home-assistant/mosquitto-bootstrap.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/null + + echo "generated $NAME" + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 64Mi diff --git a/kubernetes/apps/home-assistant/mosquitto-deployment.yaml b/kubernetes/apps/home-assistant/mosquitto-deployment.yaml index 895c3453..2e57b471 100644 --- a/kubernetes/apps/home-assistant/mosquitto-deployment.yaml +++ b/kubernetes/apps/home-assistant/mosquitto-deployment.yaml @@ -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 diff --git a/kubernetes/apps/home-assistant/sml2mqtt-config.yaml b/kubernetes/apps/home-assistant/sml2mqtt-config.yaml new file mode 100644 index 00000000..60a932cc --- /dev/null +++ b/kubernetes/apps/home-assistant/sml2mqtt-config.yaml @@ -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. diff --git a/kubernetes/apps/home-assistant/sml2mqtt-deployment.yaml b/kubernetes/apps/home-assistant/sml2mqtt-deployment.yaml new file mode 100644 index 00000000..a3930c3d --- /dev/null +++ b/kubernetes/apps/home-assistant/sml2mqtt-deployment.yaml @@ -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