Files
christian c11fe9ab04 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.
2026-10-15 14:30:02 +00:00

161 lines
6.0 KiB
Python

"""Woher die Bruecke das Livebild holt.
./venv/bin/python -m unittest discover tests
🔑 Diese Datei gibt es wegen eines Ausfalls, der acht Tage lang im Betrieb
stand, ohne dass irgendwo ein Fehler auftauchte: Seit dem Umzug nach Coolify
(03.10.2026) trug die oeffentliche Instanz ``VOGELALBUM_LIVE_ERLAUBT=0`` --
richtig so, ein Bild aus dem eigenen Garten gehoert nicht nach draussen.
Damit lieferte ``/api/zustand/`` aber ``bild_url: null``, und die Bruecke
veroeffentlichte kein Livebild mehr. In Home Assistant blieb das letzte Bild
stehen und **sah aus wie ein Livebild**.
⚠️ Der Riegel war gegen die Oeffentlichkeit gedacht und sperrte das eigene
Haus mit aus -- obwohl die Bruecke im Heimnetz laeuft und den Verteiler
direkt erreicht. Ein Schalter, zwei Zwecke.
Gegengeprueft: Ohne ``live_adresse`` sind die Faelle "Vorgabe schlaegt API"
und "Vorgabe trotz bild_url null" beide rot.
"""
from __future__ import annotations
import os
import unittest
from homeassistant import bruecke
class LiveAdresseTests(unittest.TestCase):
SCHLUESSEL = (
"VOGELALBUM_LIVE_VOGELHAUS",
"VOGELALBUM_LIVE_VOGELHAUS_2",
)
def setUp(self):
vorher = {k: os.environ.get(k) for k in self.SCHLUESSEL}
for k in self.SCHLUESSEL:
os.environ.pop(k, None)
def zuruecksetzen():
for k, wert in vorher.items():
if wert is None:
os.environ.pop(k, None)
else:
os.environ[k] = wert
self.addCleanup(zuruecksetzen)
@staticmethod
def kamera(kennung="vogelhaus", bild_url=None):
return {"kennung": kennung, "bild_url": bild_url}
# --- ohne eigene Vorgabe: alles bleibt wie bisher ----------------
def test_ohne_alles_leer(self):
self.assertEqual(bruecke.live_adresse(self.kamera()), "")
def test_bild_url_aus_der_schnittstelle(self):
kamera = self.kamera(bild_url="http://erkennung.local:8131/bild.jpg")
self.assertEqual(bruecke.live_adresse(kamera),
"http://erkennung.local:8131/bild.jpg")
def test_stromadresse_wird_zum_einzelbild(self):
"""``/live`` ist der endlose MJPEG-Strom -- den darf sie nicht lesen."""
kamera = self.kamera(bild_url="http://erkennung.local:8131/live")
self.assertEqual(bruecke.live_adresse(kamera),
"http://erkennung.local:8131/bild.jpg")
def test_schraegstrich_am_ende_stoert_nicht(self):
kamera = self.kamera(bild_url="http://erkennung.local:8131/live/")
self.assertEqual(bruecke.live_adresse(kamera),
"http://erkennung.local:8131/bild.jpg")
# --- mit eigener Vorgabe: der eigentliche Zweck ------------------
def test_vorgabe_auch_wenn_die_schnittstelle_nichts_liefert(self):
"""Der Ausfall vom 03.-11.10.2026: LIVE_ERLAUBT=0, also bild_url null."""
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = \
"http://erkennung.local:8131/bild.jpg"
self.assertEqual(bruecke.live_adresse(self.kamera()),
"http://erkennung.local:8131/bild.jpg")
def test_vorgabe_schlaegt_die_schnittstelle(self):
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = "http://127.0.0.1:8131/bild.jpg"
kamera = self.kamera(bild_url="http://erkennung.local:8131/bild.jpg")
self.assertEqual(bruecke.live_adresse(kamera),
"http://127.0.0.1:8131/bild.jpg")
def test_vorgabe_als_stromadresse(self):
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = "http://erkennung.local:8131/live"
self.assertEqual(bruecke.live_adresse(self.kamera()),
"http://erkennung.local:8131/bild.jpg")
# --- Randfaelle --------------------------------------------------
def test_kennung_mit_sonderzeichen(self):
""""vogelhaus-2" wird zu VOGELALBUM_LIVE_VOGELHAUS_2."""
os.environ["VOGELALBUM_LIVE_VOGELHAUS_2"] = \
"http://erkennung.local:8133/bild.jpg"
kamera = self.kamera(kennung="vogelhaus-2")
self.assertEqual(bruecke.live_adresse(kamera),
"http://erkennung.local:8133/bild.jpg")
def test_ohne_kennung_keine_vorgabe(self):
"""Ohne Kennung gibt es keinen Variablennamen -- aber auch keinen Absturz."""
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = "http://127.0.0.1:8131/bild.jpg"
kamera = {"bild_url": "http://erkennung.local:8131/bild.jpg"}
self.assertEqual(bruecke.live_adresse(kamera),
"http://erkennung.local:8131/bild.jpg")
class EntwuerfeTests(unittest.TestCase):
"""Die Kamera-Entitaet darf nur entstehen, wenn es ein Bild dazu gibt.
⚠️ Sonst legt Home Assistant eine Kamera an, die nie etwas zeigt -- und
eine leere Kachel ist schlimmer als keine.
"""
def setUp(self):
self.vorher = os.environ.get("VOGELALBUM_LIVE_VOGELHAUS")
os.environ.pop("VOGELALBUM_LIVE_VOGELHAUS", None)
def zuruecksetzen():
if self.vorher is None:
os.environ.pop("VOGELALBUM_LIVE_VOGELHAUS", None)
else:
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = self.vorher
self.addCleanup(zuruecksetzen)
@staticmethod
def zustand():
return {
"kameras": [{
"name": "Futterhaus Garten",
"kennung": "vogelhaus",
"aktiv": True,
"online": True,
"bild_url": None,
}],
"sammlung": {},
}
def _hat_livebild(self):
return any(
inhalt.get("name") == "Livebild"
for _, inhalt in bruecke.entwuerfe(self.zustand())
)
def test_ohne_adresse_keine_kamera_entitaet(self):
self.assertFalse(self._hat_livebild())
def test_mit_vorgabe_entsteht_die_kamera_entitaet(self):
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = \
"http://erkennung.local:8131/bild.jpg"
self.assertTrue(self._hat_livebild())
if __name__ == "__main__":
unittest.main()