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.
159 lines
6.0 KiB
Python
159 lines
6.0 KiB
Python
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)
|