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.
154 lines
7.3 KiB
Python
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"])
|