Files
vogelalbum/web/dex/benachrichtigung.py
T
christian cc9e0786c2 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-14 18:55:23 +00:00

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}"