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.
161 lines
6.0 KiB
Python
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()
|