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:
christian committed 2026-10-15 14:30:02 +00:00
commit c11fe9ab04
189 files changed
+29806

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
from django.conf import settings
from django.contrib import admin, messages
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from . import benachrichtigung as bn
from .models import (Badge, Benachrichtigung, Camera, DexEntry, EarnedBadge,
Sighting, Species)
@admin.register(Camera)
class CameraAdmin(admin.ModelAdmin):
list_display = (
"name", "slug", "location", "active", "last_seen", "health", "live",
)
list_filter = ("active", "live_enabled")
# Der Token existiert hier nicht im Klartext — nur sein Hash. Neuen Token
# gibt es über 'manage.py camera rotate <slug>'.
readonly_fields = ("token_hash", "created_at", "last_seen",
"letzter_kontakt")
prepopulated_fields = {"slug": ("name",)}
fieldsets = (
(None, {"fields": ("name", "slug", "location", "active")}),
("Erkennung", {
"fields": ("stream_url", "habitats", "detect_threshold",
"min_bird_score"),
"description": _(
"Der Detector holt sich diese Werte selbst — er fragt "
"<code>/api/config/</code> mit seinem Token und übernimmt "
"Änderungen im laufenden Betrieb, ohne Neustart. Eine "
"Stream-Adresse, die auf seiner Kommandozeile steht, behält "
"aber Vorrang."
),
}),
("Live-Bild", {
"fields": ("live_url", "live_enabled", "live_bildrate"),
"description": _(
"Das Livebild kommt vom Live-Verteiler des Detectors, nicht "
"von der Kamera selbst — die liefert nur einen Stream, und den "
"braucht die Erkennung. Starten mit "
"<code>python -m detector watch … --live-port 8131</code>."
),
}),
("Technisches", {
"fields": ("token_hash", "created_at", "last_seen",
"letzter_kontakt"),
"classes": ("collapse",),
}),
)
@admin.display(description="Zustand")
def health(self, obj: Camera) -> str:
if not obj.active:
return "stillgelegt"
return "meldet sich nicht" if obj.is_stale else "ok"
@admin.display(description="Live", boolean=False)
def live(self, obj: Camera) -> str:
"""Warum das Livebild (nicht) erscheint — geraten wird hier nicht."""
if not settings.LIVE_ALLOWED:
return _("gesperrt (VOGELALBUM_LIVE_ERLAUBT=0)")
if not obj.live_enabled:
return _("aus")
if not obj.live_url:
return _("an, aber keine Adresse")
if not obj.active:
return _("an, aber Kamera stillgelegt")
return _("an")
@admin.register(Species)
class SpeciesAdmin(admin.ModelAdmin):
list_display = ("german", "scientific", "rarity", "xp", "size_cm")
list_filter = ("rarity",)
search_fields = ("german", "scientific", "english")
prepopulated_fields = {"slug": ("german",)}
@admin.register(Sighting)
class SightingAdmin(admin.ModelAdmin):
list_display = (
"started_at", "camera", "species", "confirmed_species", "confidence_pct", "thumb",
)
list_filter = ("camera", "species", "is_first_of_species")
date_hierarchy = "started_at"
readonly_fields = ("preview", "votes", "xp_awarded", "is_first_of_species")
# Handkorrektur ist der häufigste Grund, hier reinzuschauen — deshalb direkt
# aus der Liste heraus editierbar.
list_editable = ("confirmed_species",)
@admin.display(description="Sicherheit", ordering="confidence")
def confidence_pct(self, obj: Sighting) -> str:
return f"{obj.confidence:.0%}"
@admin.display(description="Bild")
def thumb(self, obj: Sighting):
if not obj.crop:
return "—"
return format_html('<img src="{}" style="height:48px;border-radius:4px">', obj.crop.url)
@admin.display(description="Vorschau")
def preview(self, obj: Sighting):
if not obj.image:
return "—"
return format_html('<img src="{}" style="max-width:520px">', obj.image.url)
@admin.register(DexEntry)
class DexEntryAdmin(admin.ModelAdmin):
list_display = ("species", "count", "first_seen", "last_seen")
readonly_fields = ("species", "first_seen", "last_seen", "count", "best_sighting")
@admin.register(Badge)
class BadgeAdmin(admin.ModelAdmin):
list_display = ("emoji", "name", "code", "xp", "sort")
@admin.register(EarnedBadge)
class EarnedBadgeAdmin(admin.ModelAdmin):
list_display = ("badge", "earned_at")
@admin.register(Benachrichtigung)
class BenachrichtigungAdmin(admin.ModelAdmin):
"""Ein einziger Datensatz — deshalb kein Hinzufügen und kein Löschen."""
fieldsets = (
(None, {"fields": ("aktiv",)}),
("Wohin", {"fields": ("server", "thema")}),
("Was", {"fields": ("sichtungen", "stoerungen", "stille_minuten")}),
)
actions = ["probe_senden"]
def has_add_permission(self, request):
# Der Datensatz wird beim ersten Aufruf angelegt, siehe changelist_view.
return False
def has_delete_permission(self, request, obj=None):
return False
def changelist_view(self, request, extra_context=None):
# Sicherstellen, dass es den einen Eintrag gibt — sonst steht hier
# eine leere Liste und niemand weiss, wo man anfaengt.
Benachrichtigung.laden()
return super().changelist_view(request, extra_context)
@admin.action(description="Probemeldung senden")
def probe_senden(self, request, queryset):
"""⚠ Wartet bewusst auf die Antwort. Eine Probe, die still im
Hintergrund scheitert, taugt nichts — man will ja gerade wissen,
ob die Einstellungen stimmen."""
geklappt, text = bn.melde_probe()
if geklappt:
self.message_user(request, f"Probemeldung: {text}", messages.SUCCESS)
else:
self.message_user(request, f"Probemeldung fehlgeschlagen: {text}",
messages.ERROR)