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

+128
View File
@@ -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}."))