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.
129 lines
4.9 KiB
Python
129 lines
4.9 KiB
Python
"""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}."))
|