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

154 lines
7.3 KiB
Python

"""Prüft, ob die Kameras noch melden — und schlägt Alarm, wenn nicht.
🔑 Warum das hier liegt und nicht im Detector: Ein abgestürzter Prozess kann
seinen eigenen Tod nicht melden. Wer das Ausbleiben von Meldungen bemerken
soll, muss von aussen schauen. Diese Seite hier läuft ohnehin.
Gedacht als regelmässiger Aufruf (systemd-Zeitgeber oder cron):
*/10 * * * * .../manage.py stoerungen_pruefen
⚠ Es wird pro Kamera nur EINMAL gemeldet, nicht bei jedem Durchlauf. Eine
Störung, die alle zehn Minuten aufs Handy summt, schaltet man nach einer
Stunde ab — und dann merkt man die nächste nicht mehr.
🔑 **Der Merker steht in der Datenbank, nicht im Prozess.** Hier stand
einmal ein Modul-Set `_gemeldet`. In einem Einmal-Lauf ist das wirkungslos:
jeder Aufruf ist ein frischer Prozess, das Set beim Start wieder leer. Die
Zusage eine Zeile weiter oben war damit acht Monate lang unwahr, und am
01.10.2026 hat es sich gezeigt -- nach dem Stromausfall summten beide
Kameras im Zehnminutentakt.
"""
from django.core.management.base import BaseCommand
from django.utils import timezone
from dex import benachrichtigung as bn
from dex.models import Benachrichtigung, Camera, Sighting
class Command(BaseCommand):
help = "Meldet Kameras, die sich länger nicht gemeldet haben"
def add_arguments(self, parser):
parser.add_argument(
"--trocken", action="store_true",
help="Nur anzeigen, nichts senden",
)
def handle(self, *args, **optionen):
einstellung = Benachrichtigung.laden()
grenze = timezone.now() - timezone.timedelta(
minutes=einstellung.stille_minuten)
# ⚠️ Mobile Geräte bleiben außen vor. Die Prüfung misst, ob sich ein
# Gerät länger als erlaubt nicht gemeldet hat — das ergibt nur bei
# Dauerbetrieb einen Sinn. Ein Handy meldet sich, wenn jemand
# spazieren geht, und nach dem ersten ruhigen Wochenende stünde
# sonst „Kamera still: Handy" auf dem Telefon. Genau so entstehen
# Fehlalarme, die man abschaltet — und dann bemerkt man den echten
# Ausfall nicht mehr.
for kamera in Camera.objects.filter(active=True, mobil=False):
# ⚠ Gemessen wird das LEBENSZEICHEN, nicht die letzte Sichtung.
# An last_seen zu messen waere der naheliegende Fehler: An
# einem ruhigen Nachmittag kommt stundenlang kein Vogel, und
# es gaebe jeden Tag Fehlalarm. (Genau so hatte ich es zuerst
# gebaut -- der Trockenlauf meldete prompt "still seit 824
# min" fuer eine tadellos laufende Kamera.)
if kamera.letzter_kontakt is None:
# Noch nie gemeldet. Das ist keine Stoerung, sondern eine
# Kamera, die gerade erst eingerichtet wurde.
self.stdout.write(f"{kamera.name}: noch kein Lebenszeichen")
continue
still_seit = timezone.now() - kamera.letzter_kontakt
minuten = int(still_seit.total_seconds() // 60)
if kamera.letzter_kontakt < grenze:
if kamera.stoerung_gemeldet_seit is not None:
self.stdout.write(f"{kamera.name}: still ({minuten} min), "
"schon gemeldet")
continue
self.stdout.write(self.style.ERROR(
f"{kamera.name}: STILL seit {minuten} min"))
if not optionen["trocken"]:
bn.melde_stoerung(
f"Kamera still: {kamera.name}",
f"Seit {minuten} Minuten kein Lebenszeichen. "
f"Läuft die Erkennung noch?")
# ⚠ Erst nach dem Senden vermerken. melde_stoerung
# sendet synchron und schluckt Fehler nur im Log --
# wer vorher vermerkt, verliert bei einem Aussetzer
# des Meldewegs die Stoerung still und fuer immer.
kamera.stoerung_gemeldet_seit = timezone.now()
kamera.save(update_fields=["stoerung_gemeldet_seit"])
else:
if kamera.stoerung_gemeldet_seit is not None:
# Wieder da — das gehört auch gemeldet, sonst weiss
# niemand, ob das Problem noch besteht.
self.stdout.write(self.style.SUCCESS(
f"{kamera.name}: wieder da"))
if not optionen["trocken"]:
bn.melde_stoerung(
f"Kamera wieder da: {kamera.name}",
"Sie meldet sich wieder.")
kamera.stoerung_gemeldet_seit = None
kamera.save(update_fields=["stoerung_gemeldet_seit"])
else:
self.stdout.write(f"{kamera.name}: ok ({minuten} min her)")
self._wartende_fotos(einstellung, optionen["trocken"])
def _wartende_fotos(self, einstellung, trocken: bool) -> None:
"""Meldet, wenn hochgeladene Fotos zu lange auf den Detector warten.
🔑 **Das ist die Stillstandsprüfung für den Detector selbst.** Die
Kameraprüfung oben merkt, wenn ein Gerät nichts mehr liefert — sie
merkt aber nicht, wenn der Detector steht, denn der meldet sich bei
niemandem an. Liegengebliebene Fotos sind das einzige sichtbare
Zeichen dafür, und sie sagen es unmissverständlich: Jemand hat
hochgeladen, und seitdem ist nichts passiert.
📌 Verworfen wird nichts. Das Foto vom Spaziergang gibt es kein
zweites Mal, und stilles Löschen wäre der schlechteste Umgang damit.
"""
wartend = Sighting.objects.wartend()
aeltestes = wartend.first()
if aeltestes is None:
if einstellung.fotos_gemeldet_seit is not None:
self.stdout.write(self.style.SUCCESS("Fotos: alle bearbeitet"))
if not trocken:
bn.melde_stoerung(
"Fotos wieder bearbeitet",
"Die liegengebliebenen Fotos sind bestimmt.")
einstellung.fotos_gemeldet_seit = None
einstellung.save(update_fields=["fotos_gemeldet_seit"])
return
minuten = int(
(timezone.now() - aeltestes.started_at).total_seconds() // 60)
anzahl = wartend.count()
if minuten < einstellung.stille_minuten:
self.stdout.write(
f"{anzahl} Foto(s) warten, ältestes {minuten} min — ok")
return
if einstellung.fotos_gemeldet_seit is not None:
self.stdout.write(
f"{anzahl} Foto(s) warten ({minuten} min), schon gemeldet")
return
self.stdout.write(self.style.ERROR(
f"{anzahl} Foto(s) warten auf Bestimmung, "
f"ältestes seit {minuten} min"))
if not trocken:
bn.melde_stoerung(
"Fotos warten auf Bestimmung",
f"{anzahl} hochgeladene Foto(s) liegen seit {minuten} Minuten "
f"unbearbeitet. Läuft der Detector noch?")
# Erst nach dem Senden vermerken — siehe oben bei den Kameras.
einstellung.fotos_gemeldet_seit = timezone.now()
einstellung.save(update_fields=["fotos_gemeldet_seit"])