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
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,128 @@
|
||||
"""Kameras anlegen, auflisten, Token erneuern.
|
||||
|
||||
manage.py camera add vogelhaus --name "Futterhaus Garten"
|
||||
manage.py camera list
|
||||
manage.py camera rotate vogelhaus
|
||||
manage.py camera disable vogelhaus
|
||||
|
||||
Der Token wird genau einmal ausgegeben — danach liegt nur noch sein Hash in
|
||||
der Datenbank. Wer ihn verliert, erzeugt mit ``rotate`` einen neuen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from dex.models import Camera
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Kameras verwalten."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
sub = parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
add = sub.add_parser("add", help="Neue Kamera anlegen")
|
||||
add.add_argument("slug")
|
||||
add.add_argument("--name", help="Anzeigename (Standard: aus dem Slug)")
|
||||
add.add_argument("--location", default="", help="Standort, z.B. 'Garten Nord'")
|
||||
# 🔑 Ein Handy ist eine Kamera, die DARF schweigen. Ohne diese Angabe
|
||||
# trägt es im Kamerafilter und im Störungswächter dauerhaft ein ⚠,
|
||||
# weil beide "seit Stunden nichts geliefert" als Defekt lesen — richtig
|
||||
# für ein Vogelhaus, falsch für ein Telefon in der Tasche. Bisher gab
|
||||
# es das Feld nur im Admin; wer eine Handy-Kamera auf der
|
||||
# Kommandozeile anlegt, hat es zwangsläufig falsch.
|
||||
add.add_argument(
|
||||
"--mobil",
|
||||
action="store_true",
|
||||
help="Handy o.ä.: liefert nur gelegentlich, gilt nie als gestört.",
|
||||
)
|
||||
|
||||
sub.add_parser("list", help="Alle Kameras anzeigen")
|
||||
|
||||
rot = sub.add_parser("rotate", help="Neuen Token erzeugen")
|
||||
rot.add_argument("slug")
|
||||
|
||||
dis = sub.add_parser("disable", help="Kamera stilllegen")
|
||||
dis.add_argument("slug")
|
||||
|
||||
ena = sub.add_parser("enable", help="Kamera wieder aktivieren")
|
||||
ena.add_argument("slug")
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
action = opts["action"]
|
||||
if action == "add":
|
||||
return self._add(opts)
|
||||
if action == "list":
|
||||
return self._list()
|
||||
return self._toggle(action, opts["slug"])
|
||||
|
||||
def _add(self, opts):
|
||||
slug = slugify(opts["slug"])
|
||||
if Camera.objects.filter(slug=slug).exists():
|
||||
raise CommandError(f"Kamera '{slug}' gibt es schon.")
|
||||
|
||||
token = Camera.new_token()
|
||||
camera = Camera(
|
||||
slug=slug,
|
||||
name=opts.get("name") or slug.replace("-", " ").title(),
|
||||
location=opts.get("location") or "",
|
||||
mobil=bool(opts.get("mobil")),
|
||||
)
|
||||
camera.set_token(token)
|
||||
camera.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"\nKamera '{camera.name}' angelegt.\n"))
|
||||
self.stdout.write("Token (wird nur dieses eine Mal angezeigt):\n")
|
||||
self.stdout.write(self.style.WARNING(f" {token}\n"))
|
||||
if camera.mobil:
|
||||
# Eine mobile Kamera hat keinen Erkennungs-Host — der Token geht
|
||||
# in die App. Der Hinweis unten wäre hier eine falsche Fährte.
|
||||
self.stdout.write("In der App unter 'Server verbinden' eintragen.\n")
|
||||
else:
|
||||
self.stdout.write(
|
||||
"Auf dem Erkennungs-Host in /etc/vogelalbum.env eintragen:\n"
|
||||
)
|
||||
self.stdout.write(f" VOGELALBUM_TOKEN={token}\n")
|
||||
|
||||
def _list(self):
|
||||
cameras = Camera.objects.all()
|
||||
if not cameras:
|
||||
self.stdout.write("Keine Kameras angelegt.")
|
||||
return
|
||||
|
||||
self.stdout.write(f"\n{'Slug':<18} {'Name':<24} {'Aktiv':<6} {'Zuletzt':<18} Sichtungen")
|
||||
self.stdout.write("-" * 82)
|
||||
for c in cameras:
|
||||
last = (
|
||||
timezone.localtime(c.last_seen).strftime("%d.%m. %H:%M:%S")
|
||||
if c.last_seen
|
||||
else "nie"
|
||||
)
|
||||
flag = "ja" if c.active else "nein"
|
||||
warn = " ⚠ hängt?" if c.active and c.is_stale else ""
|
||||
self.stdout.write(
|
||||
f"{c.slug:<18} {c.name:<24} {flag:<6} {last:<18} "
|
||||
f"{c.sightings.count()}{warn}"
|
||||
)
|
||||
|
||||
def _toggle(self, action: str, slug: str):
|
||||
camera = Camera.objects.filter(slug=slug).first()
|
||||
if camera is None:
|
||||
raise CommandError(f"Keine Kamera '{slug}'.")
|
||||
|
||||
if action == "rotate":
|
||||
token = Camera.new_token()
|
||||
camera.set_token(token)
|
||||
camera.save(update_fields=["token_hash"])
|
||||
self.stdout.write(self.style.SUCCESS(f"Neuer Token für '{camera.name}':"))
|
||||
self.stdout.write(self.style.WARNING(f" {token}"))
|
||||
self.stdout.write("Der alte Token ist ab sofort ungültig.")
|
||||
return
|
||||
|
||||
camera.active = action == "enable"
|
||||
camera.save(update_fields=["active"])
|
||||
state = "aktiviert" if camera.active else "stillgelegt"
|
||||
self.stdout.write(self.style.SUCCESS(f"'{camera.name}' {state}."))
|
||||
Reference in new issue
Block a user