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:
christian committed 2026-10-15 14:30:02 +00:00
commit c11fe9ab04
189 files changed
+29806

No files matched your search

+515
View File
@@ -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())