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,153 @@
|
||||
"""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"])
|
||||
Reference in new issue
Block a user