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

123 lines
4.4 KiB
Python

"""Einstellungen aus der Web-App holen.
Bis hierher stand die Konfiguration des Detectors auf seiner Kommandozeile —
Stream-Adresse, Schwellen, Lebensräume. Wer die Kamera umstellen wollte,
brauchte eine Shell auf dem Rechner, auf dem der Detector läuft. Für ein
Werkzeug, das andere Leute benutzen sollen, ist das die falsche Stelle.
Jetzt fragt der Detector die Web-App: ``GET /api/config/`` mit seinem Token,
und bekommt zurück, was für diese Kamera gilt — samt Artenliste, denn welche
Arten überhaupt zählen, ist eine inhaltliche Entscheidung und gehört in den
Admin, nicht in eine Python-Datei.
Zwei Eigenschaften sind wichtiger als die Funktion selbst:
* **Die Kommandozeile bleibt gültig.** Was dort steht, gilt weiter, solange
die Web-App nichts anderes sagt. Ein Detector muss auch ohne erreichbare
Web-App starten können.
* **Ein Ausfall ändert nichts.** Antwortet die Web-App nicht, läuft die
Erkennung mit dem letzten bekannten Stand weiter, statt stehenzubleiben
oder auf Vorgaben zurückzufallen.
"""
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from .species_de import Species
def arten_aus_json(rohe: list[dict]) -> list[Species]:
"""Die Artenliste der Web-App in Katalogeinträge übersetzen.
Der wissenschaftliche Name ist der Schlüssel ins Label-File des Modells;
alles andere ist Beschriftung. Einträge ohne wissenschaftlichen Namen sind
wertlos und fliegen raus, statt später leise nichts zu treffen.
"""
arten: list[Species] = []
for a in rohe:
sci = (a.get("scientific") or "").strip()
if not sci:
continue
arten.append(
Species(
scientific=sci,
german=a.get("german") or sci,
english=a.get("english") or "",
rarity=a.get("rarity") or "haeufig",
fact="",
size_cm="",
lebensraum=a.get("lebensraum") or "garten",
)
)
return arten
class Fernkonfig:
"""Holt die Konfiguration und sagt, wann es sich lohnt, wieder zu fragen."""
def __init__(self, api: str, token: str, intervall: float = 300.0, timeout: int = 10):
self.url = api.rstrip("/") + "/api/config/"
self.token = token
self.intervall = intervall
self.timeout = timeout
self.letzte: dict | None = None
self._letzter_versuch: float | None = None
self._fehler_gemeldet = False
# -- Abruf ------------------------------------------------------------
def faellig(self, jetzt: float) -> bool:
return (
self._letzter_versuch is None
or jetzt - self._letzter_versuch >= self.intervall
)
def hole(self, jetzt: float | None = None) -> dict | None:
"""Einmal fragen. ``None`` heißt: nicht erreichbar oder unbrauchbar.
Der Aufrufer behandelt ``None`` als "nichts ändert sich" — nicht als
Fehler, der den Betrieb anhält.
"""
self._letzter_versuch = time.monotonic() if jetzt is None else jetzt
req = urllib.request.Request(
self.url,
headers={
"Authorization": f"Bearer {self.token}",
"User-Agent": "vogelalbum-detector/1.0",
},
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
daten = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
# 401 ist kein Netzproblem, sondern ein falscher Token — das soll
# man sehen, aber es darf die Erkennung nicht anhalten.
self._melde(f"Konfiguration abgelehnt (HTTP {exc.code})")
return None
except (OSError, ValueError) as exc:
self._melde(f"Konfiguration nicht erreichbar ({exc})")
return None
if not isinstance(daten, dict):
self._melde("Konfiguration unlesbar (kein Objekt)")
return None
if self._fehler_gemeldet:
print("[konfig] Web-App wieder erreichbar")
self._fehler_gemeldet = False
self.letzte = daten
return daten
def _melde(self, text: str) -> None:
"""Einmal warnen, nicht in jeder Runde — sonst ersäuft das Log."""
if not self._fehler_gemeldet:
print(f"[konfig] {text} — weiter mit dem bisherigen Stand")
self._fehler_gemeldet = True