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:
commit
cc9e0786c2
189 files changed
+29792
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user