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-14 18:55:23 +00:00
commit cc9e0786c2
189 files changed
+29792

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
# Die Web-App auf Coolify stellen
Für den Umzug der Sammlung von einem Rechner im Heimnetz auf eine öffentlich
erreichbare Instanz. Der Detector zieht **nicht** mit um — er braucht
Sichtverbindung zur Kamera und bleibt zu Hause; er bekommt am Ende nur eine
neue Adresse.
Vorlage ist `docker-compose.yml` im Wurzelverzeichnis, als
**Docker-Compose-Anwendung aus dem Git-Repository**.
## Was dieses Setup von einem Ein-Container-Deploy unterscheidet
Zwei Dienste, und der Eingang ist **nicht** die Django-App:
```
Traefik ──▶ medien (nginx, Port 8080) ──┬──▶ /media/… direkt vom Volume
└──▶ alles andere ▶ web (Gunicorn, 8000)
```
🔑 **Die Domain muss auf `medien` zeigen, nicht auf `web`.** Django liefert
unter `DJANGO_DEBUG=0` keine Medien aus, und Coolify stellt keinen Webserver
davor, der das übernehmen könnte — Traefik routet auf einen Container und kennt
das Volume nicht. Zeigt die Domain auf `web`, läuft die Seite scheinbar
normal, aber **jedes Vogelfoto ist 404**: die Sammlung ist das Einzige, was
dieses Projekt ist, und sie wäre leer. Die ausführliche Begründung steht in
`nginx-media.conf`.
## 1. Anwendung anlegen
* Typ **Docker Compose**, Quelle das Git-Repository, Branch `main`,
Compose-Datei `docker-compose.yml`.
* Ist das Repository privat, muss Coolify über die Gitea-Anbindung
(GitHub-App-Äquivalent bzw. Deploy-Key) darauf zugreifen können — dasselbe
Verfahren wie bei den anderen Projekten auf derselben Instanz.
## 2. Domain auf den Dienst `medien`
In Coolify hat bei einer Compose-Anwendung **jeder Dienst sein eigenes
Domain-Feld**. Die Domain gehört an `medien` (Port 8080). `web` bekommt
**keine** Domain — der Dienst wird nur intern angesprochen.
Danach prüfen, dass der DNS-Eintrag der Domain auf die Coolify-Maschine zeigt.
## 3. Umgebungsvariablen
Pflicht:
| Variable | Wert |
|---|---|
| `DJANGO_ALLOWED_HOSTS` | die Domain, z. B. `vogelalbum.gaertner.onl` |
| `DJANGO_CSRF_TRUSTED_ORIGINS` | dieselbe Domain **mit** `https://` |
Optional, aber meist gewollt:
| Variable | Wert |
|---|---|
| `DJANGO_SECRET_KEY` | leer lassen ist in Ordnung — der Entrypoint erzeugt einen und legt ihn aufs Volume, er bleibt über Neustarts stabil |
| `VOGELALBUM_INGEST_TOKEN` | **leer lassen**, wenn jede Kamera im Admin registriert ist und ihren eigenen Token hat. Leer schaltet den Sammel-Token ab, und das ist für eine öffentliche Instanz das Richtige |
| `VOGELALBUM_LESE_TOKEN` | nur nötig, wenn Home Assistant `/api/zustand/` abfragt. **Leer heißt: der Endpunkt antwortet mit 401** — wer die Einbindung zu Hause schon hat und das hier vergisst, sucht den Fehler im Heimnetz |
Nicht anfassen, die stehen fest in `docker-compose.yml`:
`DJANGO_DEBUG=0`, `VOGELALBUM_LIVE_ERLAUBT=0`, `SQLITE_DIR=/data`,
`MEDIA_ROOT=/data/media`.
⚠️ **Lässt man Coolify ein Geheimnis erzeugen, darf es kein `$` enthalten.**
Docker Compose liest `$xxx` als Variable und zerstört den Wert dabei still.
Selbst erzeugen mit `openssl rand -base64 32`.
🔒 `VOGELALBUM_LIVE_ERLAUBT=0` ist der Grund, warum auf der öffentlichen Instanz
kein Livebild einschaltbar ist — auch nicht mit einem Fehlklick im Admin. Ein
Bild aus dem eigenen Garten soll nicht an einem Häkchen hängen.
## 4. Volume
Das Compose-File deklariert das benannte Volume `vogelalbum-data` und hängt es in
beide Dienste (`web` beschreibbar, `medien` nur lesend). Coolify übernimmt das;
es muss nichts von Hand angelegt werden.
⚠️ **Dort liegen Datenbank *und* Fotos.** `SQLITE_DIR` und `MEDIA_ROOT` zeigen
beide auf `/data`. Fehlt das Volume oder zeigt eine der beiden Variablen
woandershin, ist die Sammlung nach jedem Deploy weg.
## 5. Erster Start
Der Entrypoint erledigt `migrate`, `collectstatic`, `seed_dex` und holt
anschließend die Referenzfotos der Arten von Wikipedia.
⏱️ **Der erste Start dauert deshalb mehrere Minuten** — rund 150 Arten, und
Wikimedia drosselt, weshalb zwischen den Abrufen gewartet wird. Das ist kein
hängender Deploy. Jeder weitere Start überspringt alle Arten, die schon ein
Foto haben, und kostet keine einzige Anfrage mehr.
Danach steht eine leere Sammlung: Artenkatalog vollständig, keine Sichtungen.
## 6. Eine bestehende Sammlung mitnehmen
Wer von einer Heiminstanz umzieht, bringt Datenbank und Fotos mit. Beides liegt
beim Umzugspaket im selben Layout, in dem es auf dem Volume liegen muss:
```
db.sqlite3
media/sightings/…
media/crops/…
media/referenz/…
```
🔑 **Eine 1:1-Kopie, keine Neueinlieferung über die API.** Nur so kommen die
Kamera-Tokens, die Schwellwerte je Kamera, Handbestimmungen, Abzeichen und
Erstfang-Vermerke unverändert mit. Die Kameras zu Hause liefern danach mit
**ihrem bisherigen Token** weiter — es muss nichts neu registriert werden.
Auf der Heiminstanz ein konsistentes Paket bauen (`VACUUM INTO` kopiert die
Datenbank sauber, auch während die App läuft):
```sh
cd /pfad/zum/vogelalbum
python - <<'PY'
import sqlite3
sqlite3.connect('web/db.sqlite3').execute("VACUUM INTO '/tmp/db.sqlite3'")
PY
mkdir -p /tmp/umzug/media
mv /tmp/db.sqlite3 /tmp/umzug/
cp -r web/media/sightings web/media/crops web/media/referenz /tmp/umzug/media/
tar czf /tmp/vogelalbum-daten.tgz -C /tmp/umzug db.sqlite3 media
```
Das Paket auf die Coolify-Maschine legen, dann dort:
```sh
# Anwendung anhalten (in Coolify: Stop) — SQLite nicht im Betrieb tauschen.
VOL=$(docker volume ls -q | grep vogelalbum-data | head -1)
docker run --rm -v "$VOL":/data -v /root:/rein:ro alpine \
sh -c 'tar xzf /rein/vogelalbum-daten.tgz -C /data && chown -R 0:0 /data && ls -la /data'
# Anwendung wieder starten.
```
Der Container läuft als `root`, daher `chown -R 0:0`.
## 7. Nach dem Umzug prüfen
```sh
curl -sI https://DEINE-DOMAIN/ | head -1 # 200
curl -sI https://DEINE-DOMAIN/sichtungen/ | head -1
```
Und im Browser: **eine Sichtungskarte muss ihr Foto zeigen.** Ein 404 auf
`/media/…` heißt, die Domain hängt am falschen Dienst (Schritt 2).
## 8. Die Kameras zu Hause umstellen
Jeder Detector und jede weitere Anbindung zeigt noch auf die alte Adresse im
Heimnetz. Umzustellen ist:
* **jeder Detector**: `VOGELALBUM_API` auf `https://DEINE-DOMAIN` (in
`~/.vogelalbum.env` bzw. `--api`), danach neu starten. Laufen zwei Kameras,
gibt es zwei Detector-Prozesse — womöglich auf zwei verschiedenen Maschinen.
* **Home Assistant**, falls eingebunden: neue Adresse und `VOGELALBUM_LESE_TOKEN`.
Dass eine Kamera angekommen ist, sieht man im Admin an `letzter_kontakt` —
der Konfigurationsabruf setzt es, auch ohne einen einzigen Vogel.
## 9. Was bewusst nicht mit umzieht
* **Der Detector.** Er braucht die Modelle, Rechenzeit und das Kameranetz.
* **Das Livebild.** `VOGELALBUM_LIVE_ERLAUBT=0`, siehe oben.
* **Der Störungswächter** (`vogelalbum-stoerungen.service`). 🔑 Er darf nicht auf
derselben Maschine laufen wie das, was er überwacht, und nicht an derselben
Stromversorgung: beim Stromausfall am 30.09.2026 war er von 22:31 bis 06:19
stumm, weil er selbst tot war. Wer das Ausbleiben von Sichtungen bemerken
soll, muss von außen schauen.
+17
View File
@@ -0,0 +1,17 @@
# Der Medien-Eingang, mit seiner Konfiguration fest im Image.
#
# 🔑 Warum nicht einfach die .conf hineinmounten: Einzeldatei-Bind-Mounts sind
# auf Coolify brüchig. Schlägt ein Deploy fehl, legt Docker die fehlende
# Mount-Quelle als leeres VERZEICHNIS auf dem Host an — der nächste Versuch
# mountet dann ein Verzeichnis auf eine Datei und der Container kommt in eine
# Absturzschleife, die nach einem Konfigurationsfehler aussieht und keiner
# ist. Das hat auf derselben Coolify-Instanz schon einmal einen Abend
# gekostet; deshalb hier gebacken statt gemountet.
#
# Preis dafür: eine Änderung an nginx-media.conf braucht einen Rebuild, nicht
# nur einen Neustart. Die Datei ändert sich praktisch nie.
FROM nginx:1.27-alpine
COPY deploy/nginx-media.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
+86
View File
@@ -0,0 +1,86 @@
# Eingang der oeffentlichen Instanz: liefert die Medien selbst aus und reicht
# alles andere an Gunicorn weiter.
#
# 🔑 Warum es diesen Dienst ueberhaupt gibt: Django liefert unter
# DJANGO_DEBUG=0 keine Medien aus (siehe web/vogelalbum/urls.py). Im Betrieb soll
# das der Webserver tun -- nur stellt Coolify keinen: Traefik routet auf einen
# Container und kennt das Volume nicht. Ohne diesen Dienst antwortet die
# oeffentliche Instanz auf jedes /media/... mit 404, und jede Karte der
# Sammlung haette ein kaputtes Bild.
#
# ⚠️ Whitenoise waere hier die falsche Loesung, obwohl es im Projekt schon
# steckt: es liest seinen Bestand beim Start ein. Vogelfotos kommen aber
# laufend dazu -- jede neue Sichtung waere bis zum naechsten Neustart
# unsichtbar.
# 🔴 **Das Schema kommt von Traefik, nicht von uns.**
#
# Hier stand `proxy_set_header X-Forwarded-Proto $scheme;` — und `$scheme` ist
# das Schema, mit dem jemand DIESEN nginx anspricht. Der lauscht auf einfachem
# HTTP hinter Traefik, also stand dort immer `http`, und zwar überschreibend:
# Traefik hatte den Kopf schon richtig auf `https` gesetzt, wir haben ihn
# wieder kaputtgemacht.
#
# Folge: Django hielt sich trotz SECURE_PROXY_SSL_HEADER für unverschlüsselt
# und baute **jede** absolute Adresse mit `http://` — Vogelfotos, Abzeichen-
# Pins, Links in Mails. Im Browser unsichtbar, weil Traefik auf `https`
# umleitet. **Auf dem Telefon ein Ausfall:** iOS blockt einfaches HTTP, ohne
# der Umleitung zu folgen; das Album bleibt bilderlos.
#
# Die Abbildung nimmt den Kopf von vorne, wenn es einen gibt, und fällt sonst
# auf das eigene Schema zurück — damit funktioniert dieselbe Konfiguration
# auch ohne Traefik davor (`docker compose` zu Hause).
#
# ⚠️ `map` gehört auf die http-Ebene, nicht in den server-Block. Diese Datei
# liegt als conf.d/default.conf genau dort.
map $http_x_forwarded_proto $eingangs_schema {
default $http_x_forwarded_proto;
"" $scheme;
}
server {
listen 8080;
server_name _;
# ⚠️ nginx erlaubt von Haus aus nur 1 MB pro Anfrage. Der Detector liefert
# seine Bilder per POST ein; bei hoher Aufloesung ist ein Vogelfoto
# schnell groesser, und die Kamera bekaeme ein 413 zurueck, waehrend im
# Django-Protokoll nichts stuende.
client_max_body_size 20m;
# Die Medien direkt vom Volume. Dieselbe Platte, die der Web-Dienst unter
# MEDIA_ROOT=/data/media beschreibt.
location /media/ {
alias /data/media/;
access_log off;
expires 7d;
add_header Cache-Control "public";
# ⚠️ **Hier liegen fremde Dateien auf unserer Herkunft.** Die Medien
# kommen von Kameras und aus Uploads; sie werden zwar beim Annehmen
# neu geschrieben (web/dex/bilder.py), aber dieser Pfad liefert aus,
# was auf der Platte liegt — auch Altbestand von vorher.
#
# nosniff: Der Browser soll den Inhaltstyp nicht erraten. Ohne das
# kann eine Datei, die wie ein Bild heißt, als etwas anderes
# ausgeführt werden, und zwar same-origin mit dem Admin-Cookie.
# Content-Disposition: notfalls herunterladen statt anzeigen.
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "SAMEORIGIN";
try_files $uri =404;
}
location / {
proxy_pass http://web:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# ⚠️ Ohne diesen Kopf haelt Django sich fuer unverschluesselt und weist
# jedes Formular mit einem CSRF-Fehler ab -- der Login im Admin
# waere nicht benutzbar. Siehe die Abbildung oben: hier stand
# $scheme, und das war genau die Luege, die es zu vermeiden galt.
proxy_set_header X-Forwarded-Proto $eingangs_schema;
proxy_redirect off;
}
}
+60
View File
@@ -0,0 +1,60 @@
# Detector als Dienst auf dem dedizierten Erkennungs-Host.
#
# Wird von scripts/setup_detector_host.sh installiert. Von Hand:
# sudo cp deploy/vogelalbum-detector.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now vogelalbum-detector
#
# Zugangsdaten in /etc/vogelalbum.env (chmod 600, root:root):
# VOGELALBUM_SOURCE=http://vogelkamera.local:8080/stream
# VOGELALBUM_API=https://vogelalbum.gaertner.onl
# VOGELALBUM_TOKEN=<Token aus 'manage.py camera add'>
# VOGELALBUM_SAVE_TO=/var/lib/vogelalbum/rohdaten
#
# Für eine zweite Kamera: Datei als vogelalbum-detector@.service ablegen, %i im
# EnvironmentFile-Pfad verwenden und je Kamera eine /etc/vogelalbum-<name>.env
# anlegen. Dann läuft `systemctl enable --now vogelalbum-detector@nistkasten`.
[Unit]
Description=Vogelalbum Detector (Arterkennung am Vogelhaus)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=vogelalbum
Group=vogelalbum
WorkingDirectory=/opt/vogelalbum
EnvironmentFile=/etc/vogelalbum.env
Environment=PYTHONUNBUFFERED=1
# Die Schwellwerte stehen bewusst hier und nicht im Code: sie gehören zur
# Kamera, nicht zum Programm. 'gate detector' ignoriert Bewegung — nötig,
# weil unsere Kamera auf eine Hecke schaut, die im Wind steht.
# --nachtsicht ergaenzen, wenn die Kamera Infrarot hat. Ohne den Schalter
# pausiert die Analyse, sobald das Bild zu dunkel zum Erkennen ist.
ExecStart=/opt/vogelalbum/venv/bin/python -m detector watch \
--gate detector \
--classify-every 2 \
--cooldown 5 \
--min-bird-frames 1 \
--verbose
# Der Stream reißt nachts gern mal ab; einfach wieder hochkommen.
Restart=always
RestartSec=15
# Ein Bilderkenner braucht weder Schreibzugriff aufs System noch neue Rechte.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
ReadWritePaths=/var/lib/vogelalbum
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+70
View File
@@ -0,0 +1,70 @@
# Ein Detector je Kamera. Der Name hinter dem @ waehlt die Umgebungsdatei:
#
# systemctl enable --now vogelalbum-detector@futterhaus
# -> liest /etc/vogelalbum-futterhaus.env
#
# Genau so vorgeschlagen im Kopf von vogelalbum-detector.service, umgesetzt am
# 24.09.2026 beim Aufsetzen von LXC 112.
#
# Zugangsdaten je Kamera in /etc/vogelalbum-<name>.env (chmod 600, root:root):
# VOGELALBUM_SOURCE=http://vogelkamera.local:8080/stream
# VOGELALBUM_API=http://sammlung.local:8000
# VOGELALBUM_TOKEN=<Token aus dem Backend>
# VOGELALBUM_SAVE_TO=/var/lib/vogelalbum/<name>/rohdaten
# VOGELALBUM_LIVE_PORT=8131
# VOGELALBUM_KLASSIFIZIERE=2
#
# 🔑 Ein Dienst je Kamera und nicht einer fuer beide: Der Detector liest
# genau einen Bildstrom. Abwechselnd zu lesen hiesse, die Aufmerksamkeit je
# Kamera zu halbieren -- und an der Traenke ist ein Vogel womoeglich kuerzer
# da als am Futterhaus.
[Unit]
Description=Vogelalbum-Detector fuer Kamera %i
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=vogelalbum
Group=vogelalbum
WorkingDirectory=/opt/vogelalbum
EnvironmentFile=/etc/vogelalbum-%i.env
Environment=PYTHONUNBUFFERED=1
# Die Schwellwerte stehen bewusst hier und nicht im Code: sie gehören zur
# Kamera, nicht zum Programm. 'gate detector' ignoriert Bewegung — nötig,
# weil unsere Kamera auf eine Hecke schaut, die im Wind steht.
# --nachtsicht ergaenzen, wenn die Kamera Infrarot hat. Ohne den Schalter
# pausiert die Analyse, sobald das Bild zu dunkel zum Erkennen ist.
ExecStart=/opt/vogelalbum/venv/bin/python -m detector watch \
--gate detector \
--classify-every ${VOGELALBUM_KLASSIFIZIERE} \
--cooldown 5 \
--min-bird-frames 1 \
--verbose
# Der Stream reißt nachts gern mal ab; einfach wieder hochkommen.
Restart=always
RestartSec=15
# ⚠ Die Kameras lassen nur EINEN Zuschauer zu. Beim Neustart muss der alte
# Prozess seine Verbindung erst loslassen, sonst bekommt der neue nichts.
KillSignal=SIGTERM
TimeoutStopSec=20
# Ein Bilderkenner braucht weder Schreibzugriff aufs System noch neue Rechte.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
ReadWritePaths=/var/lib/vogelalbum
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
# Vorlage für die Zugangsdaten der Home-Assistant-Brücke.
#
# ⚠ Die echte Datei gehört NICHT ins Repo. Kopieren nach
# ~/.config/vogelalbum-ha.env und mit chmod 600 schützen.
VOGELALBUM_URL=http://127.0.0.1:8130
VOGELALBUM_LESE_TOKEN=
VOGELALBUM_INSTANZ=garten
VOGELALBUM_TAKT=10
MQTT_HOST=
MQTT_PORT=1883
MQTT_BENUTZER=
MQTT_PASSWORT=
+25
View File
@@ -0,0 +1,25 @@
# Brücke von Vogelalbum nach Home Assistant (MQTT-Discovery).
#
# ⚠ Läuft dort, wo die WEB-ANWENDUNG steht — sie holt sich den Zustand
# über deren Schnittstelle. Zieht die Weboberfläche um, zieht diese
# Einheit mit.
#
# Die Zugangsdaten stehen in ~/.config/vogelalbum-ha.env, nicht hier und
# nicht im Repo.
[Unit]
Description=Vogelalbum: Bruecke nach Home Assistant
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/vogelalbum-web
EnvironmentFile=%h/.config/vogelalbum-ha.env
ExecStart=/opt/vogelalbum-web/venv/bin/python homeassistant/bruecke.py
# Stirbt sie, sagt der letzte Wille Home Assistant Bescheid -- und systemd
# holt sie zurueck. Beides zusammen heisst: kein stummer Ausfall.
Restart=always
RestartSec=15
[Install]
WantedBy=default.target
+17
View File
@@ -0,0 +1,17 @@
# Prueft, ob sich die Kameras noch melden.
#
# ⚠ Laeuft dort, wo die WEB-ANWENDUNG steht -- nicht beim Detector. Der
# Befehl braucht die Datenbank, und ein abgestuerzter Detector kann
# seinen eigenen Ausfall ohnehin nicht melden. Wer das Ausbleiben
# bemerken soll, muss von aussen schauen.
#
# Zieht die Weboberflaeche nach Coolify um, zieht diese Einheit mit.
[Unit]
Description=Vogelalbum: melden, wenn eine Kamera schweigt
After=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/opt/vogelalbum-web
ExecStart=/opt/vogelalbum-web/venv/bin/python web/manage.py stoerungen_pruefen
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Stoerungswache alle zehn Minuten
[Timer]
# Zehn Minuten sind ein Kompromiss: Haeufiger meldet nichts frueher, weil
# die Stille-Grenze im Backend ohnehin bei 30 Minuten liegt. Seltener
# hiesse, einen Ausfall spaeter zu bemerken als noetig.
OnBootSec=5min
OnUnitActiveSec=10min
Persistent=true
[Install]
WantedBy=timers.target
+75
View File
@@ -0,0 +1,75 @@
#
# Fotos bestimmen, die über die App oder das Formular hochgeladen wurden.
#
# 🔑 **Der Dienst holt sich die Arbeit SELBST ab.** Er fragt die Web-App
# jede Minute nach offenen Fotos (GET /api/offen/) und meldet das Ergebnis
# zurück (POST /api/offen/<id>/). Er braucht deshalb weder eine erreichbare
# Adresse noch einen offenen Port — dieselbe Richtung wie bei der
# Fernkonfiguration, und aus demselben Grund: Der Erkennungs-Host steht im
# Heimnetz hinter einem Router, die Web-App nicht.
#
# ⚠️ **Ohne diesen Dienst bleibt JEDES App-Foto unbestimmt liegen.** Die App
# zeigt dann dauerhaft "wird bestimmt …", und zwar ohne Fehler — der Upload
# war ja erfolgreich. Das sieht nach einem Fehler in der App aus und liegt
# auf einer anderen Maschine.
#
# Gehört auf denselben Host wie die Kamera-Detektoren: Dort liegen die
# Modelle und das venv schon.
#
# cp deploy/vogelalbum-warteschlange.service /etc/systemd/system/
# # Zugangsdaten anlegen (chmod 600!):
# cat > /etc/vogelalbum-warteschlange.env <<ENV
# VOGELALBUM_API=https://<deine-instanz>
# VOGELALBUM_TOKEN=<Token einer Kamera -- zum Zurueckmelden>
# VOGELALBUM_LESE_TOKEN=<Lese-Token -- zum Abrufen der offenen Fotos>
# ENV
# chmod 600 /etc/vogelalbum-warteschlange.env
# systemctl enable --now vogelalbum-warteschlange
#
# ✅ **Einmal von Hand auslösen, bevor man sich darauf verlässt**: ein Foto
# hochladen und `journalctl -u vogelalbum-warteschlange -f` mitlesen. Ein
# Dienst, der sauber läuft und nichts tut, sieht im Status genauso aus wie
# einer, der arbeitet.
#
# 🔑 Zum Prüfen ein Bild OHNE Vogel nehmen. Dann durchläuft es die ganze
# Kette, aber es entsteht keine Sichtung — ein Testvogel in einer echten
# Sammlung muss hinterher wieder heraus.
[Unit]
Description=Vogelalbum: Fotos aus der App bestimmen
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=vogelalbum
Group=vogelalbum
WorkingDirectory=/opt/vogelalbum
EnvironmentFile=/etc/vogelalbum-warteschlange.env
Environment=PYTHONUNBUFFERED=1
# --alle 60: Häufiger bringt nichts. Ein gerade hochgeladenes Foto ist beim
# nächsten Durchgang dran, und die App wartet ohnehin mit wachsenden
# Abständen auf das Ergebnis.
ExecStart=/opt/vogelalbum/venv/bin/python -m detector warteschlange --alle 60
Restart=always
RestartSec=15
KillSignal=SIGTERM
TimeoutStopSec=20
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
ReadWritePaths=/var/lib/vogelalbum
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target