Smart Home lokal · Teil 7 von 8

Fallen: Docker, Netzwerk und Home Assistant

Sechzehn Fallen aus dem Aufbau unseres Home-Assistant-Servers. Fast keine davon meldet einen Fehler, es geschieht nur nicht, was man erwartet.

Alexas Befehl kam an, die Matter-Brücke reichte ihn weiter, die Automation schickte das Weckpaket ab. Der Fernseher blieb aus. Im Protokoll stand nur ein Timeout im nächsten Schritt: action/1 FEHLER: TimeoutError, weil die Automation vergeblich darauf wartete, dass er angeht. Das Weckpaket war tatsächlich gesendet worden, nur in ein Netz, in dem es niemand hörte.

So sahen die meisten Fallen aus, in die wir beim Aufbau getreten sind. Hier stehen sechzehn davon, vom Netz über Docker bis zu Home Assistant. Zum Hintergrund: Home Assistant läuft bei uns bewusst in einem eigenen Docker-Bridge-Netz, getrennt vom Kamerasystem auf demselben Server (mehr dazu in Teil 1). Viele Netzfallen folgen aus genau dieser Entscheidung.

Netzwerk

1. Broadcast und Multicast verlassen das Bridge-Netz nicht

Symptom: Wake-on-LAN meldet Erfolg, das Gerät bleibt aus.

Ursache: wake_on_lan.send_magic_packet schickt ohne weitere Angabe an die Broadcast-Adresse 255.255.255.255. Aus einem Docker-Bridge-Netz heraus bleibt dieser Broadcast im Docker-Netz. Dasselbe gilt für alles, was auf Broadcast oder Multicast beruht: mDNS, SSDP, DLNA-Suche. Bei der Vorprüfung einer Govee-Integration, die per Multicast auf Port 4001 sucht, haben wir es gezählt: 0 Pakete aus dem Container auf der Netzwerkkarte, 3 vom Server selbst.

Lösung: Gezielt adressieren. Mit der IP des Fernsehers als broadcast_address wird aus dem Broadcast ein gewöhnliches UDP-Paket, das über den Host hinausgeroutet wird:

- action: wake_on_lan.send_magic_packet
  data:
    mac: "<MAC des Fernsehers>"
    broadcast_address: "<IP des Fernsehers>"   # sonst 255.255.255.255

Das trägt, solange die Netzwerkkarte des Geräts im Standby auf ARP antwortet. Beim LG-Fernseher stand der ARP-Eintrag durchgehend auf REACHABLE. Gegenprobe: ausgeschaltet, per homeassistant.turn_on nach 3 Sekunden wieder an.

Erkennen: tcpdump -ni eno1 'udp port 9' auf dem Host, während der Container sendet. Kommt dort nichts an, liegt es am Netz, nicht am Gerät. Braucht ein Dienst zwingend Multicast, bleibt nur das Host-Netz.

2. Im Host-Netz wirbt Matter auch auf den Docker-Brücken

Symptom: Die Matter-Brücke für Alexa warnte beim Start: „Matter mDNS is advertising on several interfaces including likely Docker-internal ones, so controllers may show devices as offline“. Der Matter-Server für Home Assistant meldete Using 'None' as primary interface.

Ursache: Beide laufen im Host-Netz, weil Matter auf mDNS und IPv6-Multicast beruht. Dort sehen sie alle fünf Docker-Brücken des Servers und bewerben sich auch über deren interne Adressen, die im Heimnetz niemand erreicht. Alexa hätte die Geräte als offline gezeigt.

Lösung: Die Netzwerkkarte ausdrücklich nennen:

  matterhub:
    network_mode: host
    environment:
      HAMH_MDNS_NETWORK_INTERFACE: eno1
  matter-server:
    network_mode: host
    command: [ "--storage-path", "/data", "--primary-interface", "eno1" ]  # ...

Erkennen: Nach jedem Start die ersten Protokollzeilen lesen. Bei der Brücke war die Warnung mit der Angabe verschwunden.

3. ufw greift erst beim Host-Netz

Symptom: Die Oberfläche der Matter-Brücke auf Port 8482 lieferte auf dem Server per curl HTTP 200, im Browser lief sie in eine Zeitüberschreitung. Container, Protokoll und Portbindung sahen gesund aus.

Ursache: ufw stand auf dem Server längst auf deny (incoming), war für Home Assistant (8123) und Zigbee2MQTT (8099) aber wirkungslos. Für veröffentlichte Ports im Bridge-Netz setzt Docker eigene iptables-Regeln vor die von ufw. Die Matter-Brücke war der erste Dienst mit network_mode: host, also ein gewöhnlicher Serverdienst, und stieß sofort auf die Standardregel.

Lösung: Jeden Dienst im Host-Netz gleich mitfreigeben, auf das Heimnetz begrenzt, und zwar für IPv4 und IPv6 getrennt:

8482/tcp        Oberfläche der Brücke
5540:5545/udp   Matter (eine Brücke = ein Port ab 5540)
5353/udp        mDNS, damit Alexa die Brücke findet

Matter arbeitet über IPv6, eine Regel für das IPv4-Heimnetz greift dort nicht. Als IPv6-Quellen dienen fe80::/10 und die ULA der FRITZ!Box, nicht das Präfix des Providers: Das wechselt, und die Regel wäre danach still wirkungslos.

Erkennen: „Vom Server erreichbar, vom Browser nicht“ heißt fehlende Freigabe, nicht defekter Dienst. Die Kehrseite des Host-Netzes gehört in dieselbe Prüfung: Von dort sind auch die internen Ports anderer Bridge-Netze erreichbar. Wer einen Dienst ins Host-Netz legt, prüft deshalb vorher, was der Host alles erreicht.

4. Pi-hole auf dem Host, und Container lösen keine Namen auf

Symptom: Temporary failure in name resolution. An einem Abend zweimal: Beim Bauen eines Containers fand pip keine Pakete, und ein Flash-Werkzeug für den Zigbee-Stick meldete „Firmware download requires internet“. Beide Male hielten wir es zuerst für einen Fehler der Anwendung.

Ursache: Der Server trägt sich selbst als Nameserver ein, weil dort Pi-hole im Host-Netz läuft. Aus einem Docker-Bridge-Netz ist dieser Dienst nicht erreichbar.

Lösung: Beim Bauen das Host-Netz nur für den Build, zur Laufzeit bei Bedarf ein eigener dns:-Eintrag im Compose-Dienst:

  presence:
    build:
      context: ./presence
      network: host        # nur für den Build, pip braucht DNS
    # zur Laufzeit, falls nötig:
    # dns: [ "<IP des Routers>", "<öffentlicher Resolver>" ]

Die globale Docker-Konfiguration zu ändern wäre bequemer gewesen, hätte aber jeden Container auf dem Server betroffen. Das haben wir bewusst nicht getan.

Erkennen: Kommt die Meldung aus einem Container auf einem Server mit eigenem DNS-Dienst, liegt es am Netz, nicht an der Anwendung.

Docker und Shell

5. restart ist nicht --force-recreate, und ein laufender Container ist kein erreichbarer Dienst

Symptom: Die Weboberfläche des Kamerasystems war nach mehreren docker compose restart nicht mehr erreichbar. Docker meldete den Container als „healthy“, das Statusskript meldete „alle vier Dienste laufen“. Nach einem automatischen Kernel-Update kam Home Assistant gar nicht wieder hoch:

zeroconf: add_multicast_member
OSError: [Errno 19] No such device

Ursache: Nach mehreren Neustarts war die Portweiterleitung verloren. Docker hatte sie noch in der Containerkonfiguration stehen, stellte sie aber nicht mehr her. Beim Systemstart war die Netzwerkkarte noch nicht bereit, als Home Assistant sie brauchte, und der Container blieb liegen. Ein restart behebt keins von beiden.

Lösung: docker compose up -d --force-recreate <dienst>. Seitdem fragt ein Wächter alle fünf Minuten per cron Home Assistant und das Kamerasystem über HTTP ab, erstellt den Container bei Bedarf neu und gibt nach drei erfolglosen Versuchen auf, statt in einer Dauerschleife zu laufen. Im Test war ein absichtlich gestoppter Dienst nach zehn Sekunden wieder erreichbar.

Eine Kleinigkeit dabei: curl -w '%{http_code}' gibt auch im Fehlerfall einen Code aus, nämlich 000. Ein angehängtes || echo 000 setzte ihn ein zweites Mal dahinter.

code=$(timeout 15 curl -sk -o /dev/null -w '%{http_code}' "$adresse" 2>/dev/null || true)
code=${code:-000}

Erkennen: Nie docker ps als Beweis nehmen, immer den Dienst selbst fragen.

6. Eine einzelne Datei einhängen reicht nicht, wenn das Programm sie ersetzt

Symptom: Im Protokoll unseres Sicherungscontainers, der mit rclone verschlüsselt nach OneDrive sichert, stand wiederholt Failed to save config ... permission denied. Die Sicherung wäre ausgefallen, sobald der OneDrive-Token abläuft.

Ursache, erster Teil: rclone.conf war schreibgeschützt eingehängt, rclone schreibt den erneuerten Token aber in diese Datei zurück. Wir machten die Datei beschreibbar. Sie wuchs danach von 4 336 auf 4 609 Byte, und wir hielten das für den Beweis.

Ursache, zweiter Teil: Über Nacht sammelten sich trotzdem 741 Meldungen. rclone legt beim Erneuern eine temporäre Datei neben der Konfiguration an und benennt sie um. Dafür braucht es Schreibrechte im Verzeichnis, und das stammte aus dem Abbild und gehörte root.

Lösung: Das ganze Verzeichnis einhängen:

    volumes:
      # - ./rclone/rclone.conf:/config/rclone/rclone.conf   # reicht nicht
      - ./rclone:/config/rclone

Seither null Fehler.

Erkennen: Die Fehlermeldung nannte die Datei, das Problem lag beim Verzeichnis. Bei permission denied auf eine eingehängte Einzeldatei auch das Verzeichnis im Container prüfen.

7. Konfiguration ändern, wenn es keine Schreibschnittstelle gibt

Symptom: Ein Eintrag sollte in die Einschlussliste der Matter-Brücke. OPTIONS /api/bridges antwortete mit Allow: GET, HEAD, also blieb nur die Datei. Dabei scheiterte es zunächst an den Rechten und an einem falschen Pfad.

Ursache: Drei Dinge zugleich. Die Dateien gehören root, ein Schreibversuch vom Host scheitert. Der Einhängepunkt im Container ist /data, nicht /app/data. Und eine laufende Brücke schreibt beim Beenden ihren Zustand über jede Änderung.

Lösung: Brücke anhalten, dann ein Einmal-Container aus demselben Abbild mit demselben Volume:

docker compose stop matterhub
docker run --rm --entrypoint node \
  -v ~/smarthome/matterhub:/data <abbild> -e '…'
# Datei im Container: /data/app/bridges.<id>
docker compose start matterhub

Verwandt: Ein zurückgebautes Brückengerät blieb trotz bereinigtem Filter als Endpunkt stehen, bis der ganze Container neu startete.

Erkennen: Vor dem Schreiben den Einhängepunkt mit docker inspect nachsehen statt ihn anzunehmen, und die Besitzrechte der Dateien prüfen.

8. Der Server läuft in UTC, und cron ignoriert CRON_TZ

Symptom: Die nächtliche Sicherung der Konfiguration sollte laut Dokumentation um 3:17 Uhr laufen und lief um 05:17 Ortszeit. Am selben Tag tauchte derselbe Versatz noch zweimal auf: in einem Nachtfenster, das um 00:00 statt um 22:00 gegriffen hätte, und in einem Diagnoseskript.

Ursache: Die Systemzeitzone ist Etc/UTC. cron rechnet in UTC, Container ohne TZ ebenfalls. Unser erster Fix war CRON_TZ=Europe/Berlin in der crontab. Ein Testeintrag zeigte dann: Ubuntus cron ignoriert die Variable. Der Fix täuschte eine Ortszeit-Planung vor, die es nicht gab, und wurde kurz darauf wieder entfernt.

Lösung: Zeiten in der crontab als UTC hinschreiben und so dokumentieren. Jedem Container TZ ausdrücklich mitgeben. In Skripten die Zeitzone dort setzen, wo Zeit ausgegeben wird:

# 17 3 * * *  heißt 03:17 UTC = 05:17 Sommerzeit
STAND=$(TZ=Europe/Berlin date '+%Y-%m-%d_%H%M')

Erkennen: Bei jeder Uhrzeit fragen, ob sie UTC oder Ortszeit meint. Eine Abweichung von genau zwei Stunden im Sommer (im Winter eine) deutet auf diese Falle.

9. Ein Dollarzeichen in der .env

Symptom: Eine Anmeldung aus einem Skript scheiterte mit einer Meldung, die nach falschen Zugangsdaten oder fehlenden Rechten aussah.

Ursache: Ein Dollarzeichen in einem Passwort muss in der .env als $$ stehen, weil docker compose es sonst als Variable liest. Docker macht daraus wieder ein $. Liest man dieselbe Datei in bash mit . .env oder set -a; . .env, expandiert die Shell $$ zur Prozessnummer. Das Passwort ist danach still ein anderes.

Lösung: Werte roh lesen und $$ selbst zurücksetzen:

lies() { grep -m1 -E "^$1=" ~/smarthome/.env | cut -d= -f2- | sed 's/\$\$/$/g'; }

Erkennen: Scheitert eine Anmeldung nur aus Skripten, nie aus dem Container, zuerst die Sonderzeichen im Passwort ansehen.

10. pkill -f trifft die eigene Shell

Symptom: Exit 144 mitten im Skript. Alles vor dem pkill ist gelaufen, alles danach nicht, und es sieht nach einem Fehler am Zielprozess aus. Claudes Shell hat sich so zweimal selbst beendet. Beim ersten Mal blieb eine Warteschlange für Firmware-Aktualisierungen gestoppt zurück, ohne dass es in der Ausgabe stand.

Ursache: pkill -f und pgrep -f durchsuchen alle Befehlszeilen, auch die der aufrufenden Shell. Steht das Muster im eigenen Befehl, trifft es einen selbst. Der bekannte Klammertrick (pkill -f 'ota_[k]ette') hilft nur, solange der Name sonst nirgends im Befehl steht. Beim zweiten Mal stand der gesuchte Skriptname im selben Befehl noch einmal im Klartext.

Lösung: PIDs beim Start merken und nur diese beenden, oder Prozesse über /proc nach ihrer vollständigen Form aussuchen:

./ota_kette.sh & echo $! > ota_kette.pid     # beim Start
kill "$(cat ota_kette.pid)"                  # später

# oder: nur Befehlszeilen, die genau so beginnen
for p in /proc/[0-9]*; do
  case "$(tr '\0' ' ' 2>/dev/null < "$p/cmdline")" in
    "/bin/bash <pfad>/ota_kette.sh"*) kill "${p##*/}" ;;
  esac
done

Erkennen: Endet ein Skript mit Exit 144 genau an der Zeile mit pkill, hat es sich selbst beendet.

Home Assistant

11. Kennungen entstehen aus dem Namen, nicht aus der Absicht

Symptom: Ein Bedarfsmelder für den Küchenentfeuchter sah plausibel aus, hätte seinen Schwellenregler aber nie gelesen. Ein Testaufruf auf eine Automation läuft ohne Fehlermeldung ins Leere und sieht aus wie eine defekte Automation.

Ursache: Home Assistant bildet Entitätskennungen aus dem Anzeigenamen, und zwar anders, als man tippen würde:

„Büro“                               ->  buro   (nicht buero)
„Küche Feuchteschwelle“              ->  input_number.kuche_feuchteschwelle
     die Vorlage las                     input_number.kuche_feuchte_schwelle
Automation „Fernsehen beendet, Licht zurueck“
                                     ->  automation.fernsehen_beendet_licht_zurueck
     nicht aus der id                    fernsehen_licht_zurueck

Aus ü wird u, aus ä wird a, ein e wird nicht angehängt. Bei Automationen zählt der Name, nicht die id. Im Melder kam dazu | float(65): Für die nicht vorhandene Entität hätte der Filter still den Standardwert geliefert, und der Melder hätte plausibel mit 65 gerechnet. Aufgefallen ist es erst beim Blick auf den tatsächlichen Zustand.

Lösung: Nach dem Anlegen die tatsächliche Kennung nachsehen und erst dann in Vorlagen schreiben, nie aus dem Namen erraten.

Erkennen: Ein Standardwert in float() verdeckt eine falsche Kennung. Rechnet eine Vorlage genau mit ihrem Standardwert, zuerst die Kennung prüfen.

12. Umbenennen zieht die Kennung nicht nach, und wenn doch, merkt es Alexa

Symptom: Ein AVM-Schalter hieß in Home Assistant „Schalter Gästezimmer“ und stand im richtigen Bereich, seine Entität aber hieß switch.schalter_schlafzimmer. Ein neues Zigbee-Ventil hieß climate.0x…, nach seiner IEEE-Adresse.

Ursache: Home Assistant übernimmt neue Anzeigenamen und Bereiche, behält aber die einmal vergebene Kennung. Den Schalter hatten wir in der FRITZ!Box umbenannt. Beim umgezogenen Thermostat zogen Anzeigename und Bereich mit, die Kennungen nicht: vierzehn Stück, einzeln nachzuziehen. Zigbee2MQTT meldet ein neues Gerät bei Home Assistant an, bevor man es umbenennt. Beim Sonoff-Ventil waren es dreißig Entitäten.

Lösung: Umbenennen über die WebSocket-Schnittstelle, die REST-Schnittstelle kann es nicht:

{ "id": 1, "type": "config/entity_registry/update",
  "entity_id": "switch.schalter_schlafzimmer",
  "new_entity_id": "switch.schalter_gastezimmer" }

Vorher prüfen, dass keine Automation auf die alte Kennung verweist. Und neue Zigbee-Geräte zuerst in Zigbee2MQTT benennen, dann in Home Assistant nachsehen.

Die Kehrseite: Unsere Matter-Brücke bildet ihre Endpunkte auf Entitätskennungen ab, eine neue Kennung ist für sie ein neues Gerät. Alexa behält dann eine Kachel „Heizung Gästezimmer“, die auf einen gelöschten Endpunkt zeigt. Die Gerätesuche legt nur die neuen Geräte an, die tote Kachel muss man von Hand löschen.

Erkennen: Nach einem Umbenennen alle Entitäten über das Gerät auflisten, nicht über die Kennung. Gefiltert nach der Kennung blieben bei uns vier Messentitäten unsichtbar, die noch den alten Raumnamen trugen.

13. Eine Vorlage lädt nicht, und check_config sagt valid

Symptom: Ein neuer Vorlagensensor erschien nicht. Die Konfigurationsprüfung meldete keinen Fehler.

Ursache: Hinter dem template:-Block stand in configuration.yaml noch ein oberster Schlüssel sensor: für einen Statistiksensor. Ans Dateiende gehängt, wurde der neue Eintrag zu dessen Listenelement:

template:
  - sensor:
      # ... bestehende Vorlagen
sensor:
  - platform: statistics
    # ...
  - sensor:                            # ans Ende gehängt: gehört
      - name: "Büro Zieltemperatur"    # jetzt zu sensor:, nicht zu template:

Lösung: Den Eintrag vor den nächsten obersten Schlüssel setzen, nicht ans Dateiende.

Erkennen: Nach jedem Einbau prüfen, ob die Entität existiert. valid heißt nur, dass die Datei gültiges YAML mit bekannten Schlüsseln ist.

14. input_number.set_value verwirft Werte außerhalb von min/max still

Symptom: Ein Test sah aus wie ein Fehler in der Logik. Beim ersten Mal griff eine Dämmerungsbedingung nicht, beim zweiten Mal schien ein Anwesenheitsmelder nie anzuspringen.

Ursache: Beide Male stand der Testwert jenseits der Grenze des Reglers: 25 bei einer Obergrenze von 15, später 25 bei einer Obergrenze von 24. Der Dienstaufruf meldet Erfolg, der Regler behält seinen alten Wert. Beim zweiten Mal hat das eine Fehlersuche an einer Vorlage gekostet, die richtig war.

Lösung: In Testskripten nach jedem Setzen zurücklesen und bei Abweichung laut melden. Testwerte innerhalb der Grenzen wählen, min und max vorher aus den Merkmalen lesen:

curl -s -H "Authorization: Bearer $TOKEN" "$HA/api/states/input_number.eingang_dammerungsschwelle" \
  | jq '.state, .attributes.min, .attributes.max'

Dasselbe gilt für Helfer, in die Automationen schreiben. Bei unseren Merkern für den zuletzt geschriebenen Sollwert haben wir deshalb nachgesehen: Sie reichen bis 28, und 28 ist der Wert, den die Steuerung beim Vorheizen schreibt (mehr dazu in Teil 3).

Erkennen: Grenzen setzt man nach dem Alltagsbereich, Testwerte gern „etwas darüber“. Genau dort verwirft der Regler sie.

15. Eine Überschreibung per State-API bleibt am MQTT-Sensor hängen

Symptom: Beim ersten Schaltpunkt nach einer Umstellung schrieb die Steuerung 26,0 °C ins Schlafzimmerventil statt 22,5. Sie rechnete mit einer Raumtemperatur von 16,2, und das war Claudes Testwert von einer Viertelstunde vorher:

07:20:08   Testwert 16,2 per State-API gesetzt
07:24:28   Gerät meldet 19,9   -> Home Assistant zeigt weiter 16,2
07:37:36   Gerät meldet 19,9   -> Home Assistant zeigt weiter 16,2

Ursache: Die MQTT-Entität vergleicht eine neue Meldung mit ihrer eigenen zuletzt empfangenen, nicht mit dem angezeigten Zustand. Meldet das Gerät denselben Wert wie vor dem Überschreiben, schreibt sie nichts. Der Testwert bleibt stehen, bis sich die Messung ändert. Bei einem Raumfühler mit 0,2 K Meldeschwelle womöglich eine Stunde.

Lösung: Nach jedem Test den letzten echten Gerätewert aus dem Protokoll von Zigbee2MQTT per POST /api/states/<entity> zurücksetzen und prüfen, dass Gerät und Home Assistant übereinstimmen. Danach rechnete die Steuerung wieder 22,5.

Erkennen: Nicht darauf verlassen, dass „das Gerät gleich wieder meldet“. Es meldet, nur übernimmt Home Assistant den Wert nicht.

16. wait_for_trigger wartet auf einen Wechsel, der nie kommt

Symptom: „Alexa, schalte den Fernseher ein“ sollte den Fernseher wecken und auf den Fire-TV-Eingang schalten. Lief der Fernseher schon, wäre die Automation nach 45 Sekunden abgebrochen und hätte die Quelle nie umgeschaltet.

Ursache: Die erste Fassung wartete mit wait_for_trigger auf den Zustandswechsel nach on. Ein Zustandsauslöser feuert nur beim Wechsel. War der Fernseher schon an, kam keiner.

Lösung: wait_template, das sofort wahr ist, wenn der Zustand schon erreicht ist:

    - wait_template: "{{ is_state('media_player.fernseher', 'on') }}"
      timeout: "00:00:45"
      continue_on_timeout: false

Erkennen: Bei jedem Warteschritt den Fall durchspielen, dass das Ziel schon erreicht ist. Gefunden haben wir den Fehler beim Nachweis der ganzen Kette, bevor er im Alltag auffiel.

Was wir mitnehmen

  • Die teuren Fallen sind die stummen. Nach jeder Handlung, die einen Zustand setzt, den Zustand zurücklesen: Regler, überschriebene Sensoren, Passwörter aus Dateien.
  • Ein laufender Container beweist nichts. Geprüft wird die Antwort des Dienstes, wiederhergestellt mit --force-recreate.
  • Bei jedem neuen Dienst im Bridge-Netz zuerst fragen, ob er Broadcast, Multicast oder DNS vom Host braucht. Bei jedem Dienst im Host-Netz: Freigabe in ufw, für IPv4 und IPv6, und die richtige Netzwerkkarte.
  • Kennungen festlegen, bevor sie in Automationen, Vorlagen und der Matter-Brücke stehen. Umbenennen ist in Home Assistant billig, in Alexa teuer.
  • Jede Uhrzeit mit Zeitzone hinschreiben, in Code, crontab und Dokumentation.
  • Eine Fehlermeldung zeigt oft auf die falsche Stelle: auf die Datei statt aufs Verzeichnis, auf die Zugangsdaten statt auf die Shell.