#!/usr/bin/env python3 """Brücke von Vogelalbum nach Home Assistant, über MQTT. Holt den Zustand aus ``GET /api/zustand/`` und veröffentlicht ihn als MQTT-Discovery. Home Assistant legt die Entitäten daraufhin von selbst an — es braucht dort **kein** Integrationspaket. VOGELALBUM_URL=http://sammlung.local:8000 \\ VOGELALBUM_LESE_TOKEN=… MQTT_HOST=… MQTT_PASSWORT=… \\ python bruecke.py 🔑 **Warum über die Schnittstelle und nicht an die Datenbank:** Die Brücke ist damit zugleich der Beweis, dass die Schnittstelle taugt — dieselbe braucht später eine HACS-Integration. Eine Brücke mit Datenbankzugriff hätte den Beweis umgangen und die Schnittstelle ungetestet gelassen. ⚠️ **Die Kennungen sind der Vertrag.** Anzeigenamen darf jeder ändern; die `unique_id` nicht. Wer sie nachträglich ändert, bekommt in Home Assistant eine NEUE Entität, während die alte als Leiche im Register stehen bleibt — mitsamt den Automationen, die daran hängen. ⚠️ **Das Livebild kommt vom DETECTOR, nicht von der Kamera.** Die Kamera erlaubt genau einen Zuschauer. Klinkt sich Home Assistant dort ein, verliert der Detector sein Bild und die Arterkennung steht still — mit einer Ursache weit weg vom Symptom. 🔑 **Steht die Web-App außer Haus, kennt die Brücke die Adresse des Verteilers selbst** — ``VOGELALBUM_LIVE_``, siehe :func:`live_adresse`. Sonst sperrt ``VOGELALBUM_LIVE_ERLAUBT=0`` (richtig gegen die Öffentlichkeit) auch das eigene Haus aus. """ from __future__ import annotations import base64 import json import os import signal import sys import time import urllib.error import urllib.parse import urllib.request import paho.mqtt.client as mqtt # --- Stellschrauben -------------------------------------------------- # Diese Brücke läuft als einzelne Datei auf einem dritten Rechner, ohne das # übrige Projekt. Deshalb steht diese kleine Funktion hier noch einmal — siehe # die Begründung in detector/umgebung.py. def umgebung(name, default=""): """Liest VOGELALBUM_. ⚠️ Ein ausdruecklich leerer Wert schlaegt den Vorgabewert — ein leerer Lesetoken heisst "Endpunkt zu", nicht "nimm den Standard". Dieselbe Regel wie in detector/umgebung.py. """ wert = os.environ.get(f"VOGELALBUM_{name}") if wert is None: return default return wert VOGELALBUM_URL = umgebung("URL", "http://127.0.0.1:8130").rstrip("/") LESE_TOKEN = umgebung("LESE_TOKEN", "") MQTT_HOST = os.environ.get("MQTT_HOST", "127.0.0.1") MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) MQTT_BENUTZER = os.environ.get("MQTT_BENUTZER", "") MQTT_PASSWORT = os.environ.get("MQTT_PASSWORT", "") # Wie oft gefragt wird. Zehn Sekunden sind ein Kompromiss: Haeufiger belastet # die Web-App, ohne dass jemand schneller einen Vogel sieht -- eine Sichtung # dauert ohnehin Sekunden bis Minuten. TAKT = float(umgebung("TAKT", "10")) # Vorsilbe der MQTT-Themen. 'homeassistant' ist die Vorgabe, unter der HA # nach Discovery-Nachrichten sucht. DISCOVERY = os.environ.get("MQTT_DISCOVERY_PRAEFIX", "homeassistant") # ⚠️ Wer eine bestehende Anlage umstellt, sollte MQTT_BASIS auf den alten Wert # setzen, BEVOR er diese Fassung einspielt. Sonst legt Home Assistant alle # Entitaeten unter neuen Namen an, und die bestehenden Dashboard-Karten zeigen # ins Leere. Beim Umstellen danach die alten retained Topics mit abraeumen. BASIS = os.environ.get("MQTT_BASIS", "vogelalbum") # 🔑 Eine Anlage kann mehrere Vogelalbum-Instanzen haben (Garten, Ferienhaus). # Die Kennung steckt deshalb in jeder unique_id. INSTANZ = umgebung("INSTANZ", "garten") ZEITGRENZE = 10 def sagen(text: str) -> None: print(text, flush=True) # --- Vogelalbum fragen -------------------------------------------------- def zustand_holen() -> dict | None: anfrage = urllib.request.Request( f"{VOGELALBUM_URL}/api/zustand/", headers={"Authorization": f"Bearer {LESE_TOKEN}"}, ) try: with urllib.request.urlopen(anfrage, timeout=ZEITGRENZE) as antwort: return json.load(antwort) except urllib.error.HTTPError as fehler: sagen(f"Vogelalbum antwortet mit {fehler.code} — Token richtig?") except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError) as fehler: sagen(f"Vogelalbum nicht erreicht: {fehler}") return None # Welcher Port beim jeweiligen Gerät funktioniert hat. Einmal herausfinden, # dann merken -- sonst klopft die Brücke alle zehn Sekunden zweimal an. _statusport: dict[str, str] = {} def geraetestatus_holen(geraet_url: str | None) -> dict | None: """Signalstärke, Temperatur, Bildrate — direkt vom Gerät. ⚠️ Diese Werte stehen NICHT in Vogelalbum, und das ist Absicht: Sie kennt nur das Gerät selbst. Über die Datenbank zu gehen hieße, sie erst zu speichern — dann wären sie alt. 🔑 **Zwei Ports, weil zwei Gerätearten.** Der ESP32 beantwortet ``/status`` auf einem eigenen Diagnose-Port (Streamport + 1): Sein Webserver hat einen einzigen Arbeiter, und der Bildstrom gibt ihn nie zurück. Der Pi hat das Problem nicht und antwortet auf dem Streamport. Deshalb wird nicht geraten, sondern **probiert und gemerkt**: erst der Diagnose-Port, dann der Streamport. Was antwortet, gilt. """ if not geraet_url: return None teile = urllib.parse.urlsplit(geraet_url) if not teile.hostname: return None port = teile.port or 80 kandidaten = _statusport.get(geraet_url) or None versuche = ([kandidaten] if kandidaten else [f"{teile.scheme}://{teile.hostname}:{port + 1}/status", f"{teile.scheme}://{teile.hostname}:{port}/status"]) for adresse in versuche: try: with urllib.request.urlopen(adresse, timeout=5) as antwort: daten = json.load(antwort) except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError, ValueError): continue if isinstance(daten, dict): _statusport[geraet_url] = adresse return daten # ⚠️ Merker loeschen: Vielleicht war das Geraet nur kurz weg, vielleicht # wurde es getauscht. Beim naechsten Mal wieder beide probieren. _statusport.pop(geraet_url, None) return None def bild_holen(bild_url: str | None) -> bytes | None: """Das Einzelbild vom DETECTOR — siehe Modulkopf, niemals von der Kamera.""" if not bild_url: return None try: with urllib.request.urlopen(bild_url, timeout=ZEITGRENZE) as antwort: if antwort.status != 200: return None return antwort.read() except (urllib.error.URLError, OSError, TimeoutError): return None def live_adresse(kamera: dict) -> str: """Woher das Livebild dieser Kamera kommt. 🔑 **Die oeffentliche Instanz darf kein Livebild kennen, Home Assistant schon.** Auf der Instanz im Netz steht ``VOGELALBUM_LIVE_ERLAUBT=0``, damit ein Bild aus dem eigenen Garten nicht nach aussen geht. Dann liefert ``/api/zustand/`` aber ``bild_url: null`` -- und die Bruecke bekam bis zum 11.10.2026 ebenfalls nichts, obwohl sie **im Heimnetz** laeuft und den Verteiler direkt erreicht. In Home Assistant stand das Livebild seit dem Umzug nach Coolify (03.10.2026) still. ⚠️ **Ein Schalter, zwei Zwecke -- das war der Fehler.** Derselbe Riegel sperrte das Bild nach draussen *und* im eigenen Haus aus. Deshalb trennt die Bruecke das jetzt: Steht ``VOGELALBUM_LIVE_`` in ihrer Umgebung, nimmt sie diese Adresse und fragt die Web-App gar nicht erst. VOGELALBUM_LIVE_VOGELHAUS=http://erkennung.local:8131/bild.jpg VOGELALBUM_LIVE_TRAENKE=http://erkennung.local:8132/bild.jpg Ohne Eintrag bleibt alles wie bisher: ``bild_url`` aus der Schnittstelle. """ kennung = kamera.get("kennung") or "" # Kennungen duerfen Zeichen tragen, die in einem Variablennamen nichts zu # suchen haben ("vogelhaus-2"). Alles andere wird zum Unterstrich. name = "".join(z if z.isalnum() else "_" for z in kennung).upper() eigene = umgebung(f"LIVE_{name}", "") if name else "" adresse = eigene or kamera.get("bild_url") or "" if not adresse: return "" # Dieselbe Umrechnung wie in ``Camera.live_bild_url``: ``/live`` ist der # endlose MJPEG-Strom, ``/bild.jpg`` das Einzelbild. Wer die Stromadresse # eintraegt, soll nicht zehn Sekunden lang einen Film herunterladen. adresse = adresse.rstrip("/") if adresse.endswith("/live"): adresse = adresse.removesuffix("/live") + "/bild.jpg" return adresse # --- Home Assistant beschreiben -------------------------------------- def geraet_kamera(kennung: str, name: str) -> dict: return { "identifiers": [f"{BASIS}_{INSTANZ}_{kennung}"], "name": f"Vogelalbum {name}", "manufacturer": "Vogelalbum", "model": "Vogelkamera", } def geraet_sammlung() -> dict: return { "identifiers": [f"{BASIS}_{INSTANZ}"], "name": "Vogelalbum", "manufacturer": "Vogelalbum", "model": "Sammlung", } def _thema(art: str, kennung: str) -> str: return f"{DISCOVERY}/{art}/{BASIS}_{INSTANZ}_{kennung}/config" def entwurf(art: str, kennung: str, name: str, geraet: dict, zustandsthema: str, wert: str, **rest) -> tuple[str, dict]: """Eine Discovery-Nachricht. ``kennung`` ist der Vertrag — siehe Modulkopf. ``name`` darf sich jederzeit ändern. """ inhalt = { "name": name, "unique_id": f"{BASIS}_{INSTANZ}_{kennung}", "state_topic": zustandsthema, "value_template": wert, "availability_topic": f"{BASIS}/{INSTANZ}/erreichbar", "payload_available": "online", "payload_not_available": "offline", "device": geraet, } inhalt.update(rest) return _thema(art, kennung), inhalt def entwuerfe(zustand: dict) -> list[tuple[str, dict]]: """Alle Entitäten, die Vogelalbum anbietet. 🔑 Vieles kommt **standardmäßig abgeschaltet** heraus (``enabled_by_default: false``). Die Linie ist *„möglichst viel, Nutzer kann ja entscheiden was ihm taugt"* — und genau dafür ist der Schalter da. Wer alles sehen will, schaltet an; alle anderen haben kein zugemülltes Dashboard. """ sammlung = f"{BASIS}/{INSTANZ}/sammlung" liste: list[tuple[str, dict]] = [] g = geraet_sammlung() # --- Die Sammlung --- liste.append(entwurf( "sensor", "letzte_art", "Letzte Art", g, sammlung, "{{ value_json.letzte_sichtung.art | default('—') }}", icon="mdi:bird")) liste.append(entwurf( "sensor", "arten_gesammelt", "Arten gesammelt", g, sammlung, "{{ value_json.sammlung.arten_gesammelt }}", icon="mdi:book-open-variant", state_class="total")) liste.append(entwurf( "sensor", "sichtungen_heute", "Sichtungen heute", g, sammlung, "{{ value_json.sammlung.sichtungen_heute }}", icon="mdi:counter", state_class="total")) liste.append(entwurf( "sensor", "sichtungen_gesamt", "Sichtungen gesamt", g, sammlung, "{{ value_json.sammlung.sichtungen_gesamt }}", icon="mdi:counter", state_class="total_increasing", enabled_by_default=False)) liste.append(entwurf( "sensor", "stufe", "Stufe", g, sammlung, "{{ value_json.sammlung.stufe }}", icon="mdi:star-circle")) liste.append(entwurf( "sensor", "rang", "Rang", g, sammlung, "{{ value_json.sammlung.rang }}", icon="mdi:medal", enabled_by_default=False)) liste.append(entwurf( "sensor", "punkte", "Punkte", g, sammlung, "{{ value_json.sammlung.punkte }}", icon="mdi:numeric", enabled_by_default=False)) liste.append(entwurf( "sensor", "abzeichen", "Abzeichen", g, sammlung, "{{ value_json.sammlung.abzeichen }}", icon="mdi:seal", enabled_by_default=False)) # ⚠️ Abzeichen NICHT einzeln: Das wären Dutzende Entitäten, die sich # fast nie ändern. Das zuletzt verdiente genügt fürs Wandtablet. liste.append(entwurf( "sensor", "letztes_abzeichen", "Letztes Abzeichen", g, sammlung, "{{ value_json.letztes_abzeichen.name | default('—') }}", icon="mdi:seal-variant", enabled_by_default=False)) # Die letzte Sichtung mit allem Drum und Dran -- als Attribute, damit # eine Automation sie ohne zehn weitere Entitaeten auslesen kann. liste.append(entwurf( "sensor", "letzte_sichtung", "Letzte Sichtung", g, sammlung, "{{ value_json.letzte_sichtung.zeitpunkt | default('') }}", device_class="timestamp", json_attributes_topic=sammlung, json_attributes_template="{{ value_json.letzte_sichtung | tojson }}")) # Das Foto zur letzten Sichtung. # # 🔑 Als eigene Entität und nicht als Adresse im Attribut: So muss Home # Assistant die Web-App gar nicht erreichen können. Das Bild kommt über # denselben Weg wie alles andere. liste.append(( _thema("image", "letztes_bild"), { "name": "Letzte Sichtung (Bild)", "unique_id": f"{BASIS}_{INSTANZ}_letztes_bild", "image_topic": f"{sammlung}/bild", "image_encoding": "b64", "availability_topic": f"{BASIS}/{INSTANZ}/erreichbar", "payload_available": "online", "payload_not_available": "offline", "device": g, }, )) # --- Je Kamera --- for kamera in zustand.get("kameras", []): k = kamera["kennung"] thema = f"{BASIS}/{INSTANZ}/kamera/{k}" gk = geraet_kamera(k, kamera["name"]) liste.append(entwurf( "binary_sensor", f"kamera_{k}_online", "Erreichbar", gk, thema, "{{ 'ON' if value_json.online else 'OFF' }}", device_class="connectivity", payload_on="ON", payload_off="OFF")) liste.append(entwurf( "sensor", f"kamera_{k}_still", "Still seit", gk, thema, "{{ value_json.still_seit_min }}", unit_of_measurement="min", icon="mdi:timer-sand", enabled_by_default=False)) liste.append(entwurf( "sensor", f"kamera_{k}_letzte_sichtung", "Letzte Sichtung", gk, thema, "{{ value_json.letzte_sichtung | default('') }}", device_class="timestamp")) # Was nur das Gerät selbst weiß. Fehlt es (Gerät aus, kein # /status), bleiben die Werte leer statt falsch. liste.append(entwurf( "sensor", f"kamera_{k}_rssi", "Signalstärke", gk, thema, "{{ value_json.geraet.rssi | default('') }}", unit_of_measurement="dBm", device_class="signal_strength", state_class="measurement", entity_category="diagnostic")) liste.append(entwurf( "sensor", f"kamera_{k}_temperatur", "Chiptemperatur", gk, thema, "{{ value_json.geraet.temperatur_c | default('') }}", unit_of_measurement="°C", device_class="temperature", state_class="measurement", entity_category="diagnostic")) liste.append(entwurf( "sensor", f"kamera_{k}_fps", "Bildrate", gk, thema, "{{ value_json.geraet.fps | default('') }}", unit_of_measurement="B/s", icon="mdi:speedometer", state_class="measurement", entity_category="diagnostic", enabled_by_default=False)) liste.append(entwurf( "sensor", f"kamera_{k}_laufzeit", "Laufzeit", gk, thema, "{{ value_json.geraet.laufzeit_s | default('') }}", unit_of_measurement="s", device_class="duration", state_class="total_increasing", entity_category="diagnostic", enabled_by_default=False)) # ⚠️ Das Bild kommt vom DETECTOR. Siehe Modulkopf -- an der Kamera # selbst wuerde Home Assistant die Erkennung lahmlegen. if live_adresse(kamera): liste.append(( _thema("camera", f"kamera_{k}_bild"), { "name": "Livebild", "unique_id": f"{BASIS}_{INSTANZ}_kamera_{k}_bild", "topic": f"{thema}/bild", "image_encoding": "b64", "availability_topic": f"{BASIS}/{INSTANZ}/erreichbar", "payload_available": "online", "payload_not_available": "offline", "device": gk, }, )) return liste # --- Der Lauf -------------------------------------------------------- _weiter = True def _aufhoeren(signum, rahmen): global _weiter _weiter = False sagen("Ende angefordert.") def main() -> int: if not LESE_TOKEN: sagen("VOGELALBUM_LESE_TOKEN fehlt — ohne den bleibt die Tür zu.") return 2 signal.signal(signal.SIGTERM, _aufhoeren) signal.signal(signal.SIGINT, _aufhoeren) erreichbar = f"{BASIS}/{INSTANZ}/erreichbar" klient = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"{BASIS}-bruecke-{INSTANZ}") if MQTT_BENUTZER: klient.username_pw_set(MQTT_BENUTZER, MQTT_PASSWORT) # 🔑 Letzter Wille: Stirbt die Brücke, sagt der Broker Home Assistant # Bescheid. Ohne das stehen alle Entitäten auf ihrem letzten Wert und # sehen aus, als sei alles in Ordnung — das ist schlimmer als ein # offensichtlicher Ausfall. klient.will_set(erreichbar, "offline", retain=True) try: klient.connect(MQTT_HOST, MQTT_PORT, keepalive=60) except (OSError, TimeoutError) as fehler: sagen(f"Broker {MQTT_HOST}:{MQTT_PORT} nicht erreicht: {fehler}") return 1 klient.loop_start() sagen(f"Verbunden mit {MQTT_HOST}:{MQTT_PORT}, Instanz '{INSTANZ}'.") angemeldet = False letztes_bild_vom = None while _weiter: zustand = zustand_holen() if zustand is None: klient.publish(erreichbar, "offline", retain=True) time.sleep(TAKT) continue if not angemeldet: # Discovery einmal, und dauerhaft: Startet Home Assistant neu, # findet es die Entitäten ohne dass die Brücke etwas merkt. for thema, inhalt in entwuerfe(zustand): klient.publish(thema, json.dumps(inhalt), retain=True) sagen(f"{len(entwuerfe(zustand))} Entitäten angemeldet.") angemeldet = True klient.publish(erreichbar, "online", retain=True) klient.publish(f"{BASIS}/{INSTANZ}/sammlung", json.dumps(zustand), retain=True) # Das Foto zur letzten Sichtung — nur wenn es ein NEUES ist. # # ⚠️ Hier ist Behalten richtig (anders als beim Livebild): Das Bild # gehört zu einem Zeitpunkt in der Vergangenheit und bleibt gültig, # bis der nächste Vogel kommt. Startet Home Assistant neu, soll es # wieder da sein statt leer. # # Und nur bei Wechsel: Sonst lädt die Brücke alle zehn Sekunden # dasselbe Bild und schiebt es durch den Broker. letzte = zustand.get("letzte_sichtung") or {} zeitpunkt = letzte.get("zeitpunkt") if zeitpunkt and zeitpunkt != letztes_bild_vom: foto = bild_holen(letzte.get("bild")) if foto: klient.publish(f"{BASIS}/{INSTANZ}/sammlung/bild", base64.b64encode(foto), retain=True) letztes_bild_vom = zeitpunkt sagen(f"Bild zur Sichtung vom {zeitpunkt} veröffentlicht.") for kamera in zustand.get("kameras", []): k = kamera["kennung"] thema = f"{BASIS}/{INSTANZ}/kamera/{k}" # Die Werte, die nur das Gerät kennt, dazulegen. kamera = dict(kamera) kamera["geraet"] = geraetestatus_holen(kamera.get("geraet_url")) or {} klient.publish(thema, json.dumps(kamera), retain=True) # ⚠️ Das Bild NICHT behalten (retain=False): Ein aufbewahrtes # Bild zeigt nach einem Neustart der Brücke den Stand von # gestern und sieht aus wie ein Livebild. Genau diese # Verwechslung hat am 24.09.2026 eine Stunde gekostet. bild = bild_holen(live_adresse(kamera)) if bild: klient.publish(f"{thema}/bild", base64.b64encode(bild), retain=False) time.sleep(TAKT) klient.publish(erreichbar, "offline", retain=True) klient.loop_stop() klient.disconnect() return 0 if __name__ == "__main__": sys.exit(main())