Vogelalbum 1.0.0
Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine Sammlung, die sich von allein fuellt. Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert — ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten, gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte, Stufen und 15 Abzeichen. Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto, keine Telemetrie; die Bilder verlassen das eigene Netz nicht. Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und braucht deshalb weder eine erreichbare Adresse noch einen offenen Port — er laeuft hinter einem gewoehnlichen Router. Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs. AGPL-3.0.
This commit is contained in:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,515 @@
|
||||
#!/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_<KENNUNG>``, 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_<name>.
|
||||
|
||||
⚠️ 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_<KENNUNG>`` 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())
|
||||
Reference in new issue
Block a user