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
cc9e0786c2
189 files changed
+29792
No files matched your search
@@ -0,0 +1,160 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user