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.
130 lines
4.5 KiB
Python
130 lines
4.5 KiB
Python
"""Meldungen über ntfy — Sichtungen und Störungen.
|
|
|
|
Was hier bewusst NICHT passiert: die Anwendung aufhalten. Eine Meldung ist
|
|
eine Nebensache; wenn der ntfy-Server nicht antwortet, darf das weder die
|
|
Antwort an den Detector verzögern noch einen Fehler auslösen. Eine verpasste
|
|
Meldung ist ärgerlich, eine verlorene Sichtung wäre schlimmer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from .models import Benachrichtigung
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
ZEITGRENZE = 8 # Sekunden
|
|
|
|
|
|
def _senden(ziel: str, titel: str, text: str, prioritaet: str, symbole: str):
|
|
"""Schickt die Meldung. Läuft im eigenen Faden, siehe _im_hintergrund."""
|
|
try:
|
|
anfrage = urllib.request.Request(
|
|
ziel,
|
|
data=text.encode("utf-8"),
|
|
headers={
|
|
"Title": titel.encode("utf-8"),
|
|
"Priority": prioritaet,
|
|
"Tags": symbole,
|
|
},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(anfrage, timeout=ZEITGRENZE):
|
|
pass
|
|
except (urllib.error.URLError, OSError, ValueError) as fehler:
|
|
# Nur protokollieren. Wer eine Meldung nicht zustellen kann, soll
|
|
# deshalb nicht den Betrieb anhalten.
|
|
log.warning("ntfy nicht erreicht: %s", fehler)
|
|
|
|
|
|
def _im_hintergrund(*args):
|
|
threading.Thread(target=_senden, args=args, daemon=True).start()
|
|
|
|
|
|
def melde_sichtung(sighting, ist_neue_art: bool) -> None:
|
|
"""Meldet eine Sichtung, je nach Einstellung im Backend."""
|
|
e = Benachrichtigung.laden()
|
|
if not e.aktiv or not e.ziel:
|
|
return
|
|
|
|
if e.sichtungen == Benachrichtigung.Sichtungen.AUS:
|
|
return
|
|
if e.sichtungen == Benachrichtigung.Sichtungen.NEUE and not ist_neue_art:
|
|
return
|
|
|
|
art = sighting.species.german if sighting.species else "Unbestimmt"
|
|
kamera = sighting.camera.name if sighting.camera else "unbekannte Kamera"
|
|
sicherheit = round(sighting.confidence * 100)
|
|
|
|
if ist_neue_art:
|
|
titel = f"Neue Art: {art}"
|
|
text = f"{kamera} · {sicherheit} % · zum ersten Mal gesehen"
|
|
symbole = "sparkles"
|
|
prioritaet = "default"
|
|
else:
|
|
titel = art
|
|
text = f"{kamera} · {sicherheit} %"
|
|
symbole = "bird"
|
|
# Leise: Wer alle Sichtungen meldet, will nicht bei jedem Spatz
|
|
# ein Summen am Handy.
|
|
prioritaet = "low"
|
|
|
|
_im_hintergrund(e.ziel, titel, text, prioritaet, symbole)
|
|
|
|
|
|
def melde_stoerung(titel: str, text: str) -> None:
|
|
"""Meldet eine Störung — laut, weil sie Handeln erfordert.
|
|
|
|
🔑 **Synchron, im Gegensatz zu den Sichtungen.** Und das ist keine
|
|
Unachtsamkeit, sondern der Grund, warum diese Meldung am 24.09.2026
|
|
nie ankam:
|
|
|
|
Störungen meldet ausschließlich der Befehl ``stoerungen_pruefen``, und
|
|
der ist ein Einmal-Lauf. Ein Hintergrundfaden ist dort ein
|
|
Daemon-Faden — der stirbt, sobald der Prozess endet. Der Befehl war
|
|
mit seiner Arbeit fertig, bevor die Meldung draußen war, und hat sie
|
|
damit mitgenommen.
|
|
|
|
⚠️ Bei ``melde_sichtung`` ist der Hintergrundfaden richtig: Die läuft
|
|
in einer Web-Anfrage, die weiterlebt, und darf den Detector nicht
|
|
warten lassen. Hier gibt es nichts, was wartet -- das Senden IST die
|
|
Arbeit.
|
|
"""
|
|
e = Benachrichtigung.laden()
|
|
if not e.aktiv or not e.stoerungen or not e.ziel:
|
|
return
|
|
_senden(e.ziel, titel, text, "high", "rotating_light")
|
|
|
|
|
|
def melde_probe() -> tuple[bool, str]:
|
|
"""Sofort und synchron — für den Knopf im Backend.
|
|
|
|
Hier wird ausnahmsweise gewartet: Wer auf 'Probe senden' drückt, will
|
|
wissen, ob es geklappt hat. Eine Meldung, die still im Hintergrund
|
|
scheitert, wäre genau das Gegenteil einer Probe.
|
|
"""
|
|
e = Benachrichtigung.laden()
|
|
if not e.ziel:
|
|
return False, "Kein Thema eingetragen."
|
|
try:
|
|
anfrage = urllib.request.Request(
|
|
e.ziel,
|
|
data="Wenn du das liest, funktioniert es.".encode("utf-8"),
|
|
headers={
|
|
"Title": "Vogelalbum Probe".encode("utf-8"),
|
|
"Priority": "default",
|
|
"Tags": "white_check_mark",
|
|
},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(anfrage, timeout=ZEITGRENZE) as antwort:
|
|
return True, f"Gesendet (HTTP {antwort.status})."
|
|
except urllib.error.HTTPError as fehler:
|
|
return False, f"Server antwortet mit {fehler.code}."
|
|
except (urllib.error.URLError, OSError, ValueError) as fehler:
|
|
return False, f"Nicht erreicht: {fehler}"
|