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

+160
View File
@@ -0,0 +1,160 @@
# Die Kameraseite
Vogelalbum braucht von der Kamera nur eines: einen Stream, den der Detector
lesen kann. Alles andere passiert woanders.
**Der Pi erkennt nichts.** Ein Pi Zero W schafft das Streamen, aber keine
Inferenz — die beiden Modelle brauchen rund 30 ms auf einem N100 und ein
Vielfaches davon auf einem Zero. Deshalb streamt der Pi, und der Detector
läuft auf einer Maschine mit Rechenleistung im selben Netz.
## Was du brauchst
* Raspberry Pi mit Kameraanschluss — ein **Zero W** reicht und ist der
sparsamste Weg. Ein Pi 4/5 kann zusätzlich den Detector mitlaufen lassen.
* Ein Kameramodul. Getestet mit dem **OV5647** (das ältere 5-MP-Modul).
* Raspberry Pi OS (Bookworm oder neuer), WLAN eingerichtet.
## Einrichten
```bash
sudo cp vogelkamera.py /opt/vogelkamera_server.py
sudo cp vogelkamera.service /etc/systemd/system/
sudo systemctl enable --now vogelkamera
journalctl -u vogelkamera -f
```
🔑 **Kein zusätzliches Paket.** Der Server ruft `rpicam-vid` auf, das bei
Raspberry Pi OS ohnehin dabei ist, und liest dessen MJPEG-Ausgabe mit. Die
Arbeit passiert im C-Programm; Python schiebt nur Bytes weiter. Auf einem
Zero W ist das der Unterschied zwischen läuft und läuft nicht.
⚠️ **Bis zum 25.09.2026 stand hier `python3-picamera2`** — für eine Fassung,
die eleganter aussah und **nie auf einem Pi gestartet worden war**. Auf dem
Gerät am Vogelhaus lief die ganze Zeit etwas anderes, und picamera2 war dort
nicht einmal installiert. Auf den Punkt gebracht: *im Repo lag Code, der nie
getestet wurde.*
Seitdem steht in diesem Ordner, was tatsächlich läuft — und zwar seit dem
28.08.2026 ununterbrochen.
Danach:
| Adresse | wozu |
|---|---|
| `http://<pi>:8080/stream` | der MJPEG-Stream — das kommt in `VOGELALBUM_SOURCE` |
| `http://<pi>:8080/` | Vorschauseite im Browser |
| `http://<pi>:8080/status` | Bildrate, Funkstärke, Temperatur, Laufzeit als JSON |
| `http://<pi>:8080/temp` | CPU-Temperatur |
| `http://<pi>:8080/gesund` | 200, sobald Bilder ankommen |
Die drei unteren fassen den Stream **nicht** an — das ist der richtige Weg,
eine laufende Kamera zu prüfen. `/status` liefert dieselben Felder wie die
ESP32-Kameraseite ([`../esp32cam/`](../esp32cam/README.md)), ein Werkzeug muss
also nicht wissen, was am anderen Ende hängt.
Hängt die Kamera über Kopf: `--vflip` und `--hflip` stehen im Skript bereits
im Aufruf von `rpicam-vid` (`capture_loop`). Beide zusammen ergeben die
180°-Drehung, einzeln spiegeln sie.
## Drei Dinge, die man einmal falsch macht
### Nur ein Zuschauer gleichzeitig
Ein Pi Zero W liefert über WLAN rund **2 Mbit/s**. Ein zweiter Client teilt
sich diese Strecke — und der Detector fällt in Timeouts, ohne dass irgendwo
ein Fehler auftaucht. Die Bildrate halbiert sich einfach.
Das ist bei der Entwicklung zweimal passiert: einmal durch ein `curl` auf den
Stream, mit dem eine Störung *diagnostiziert* werden sollte, und einmal, weil
die Vorschauseite im Browser offen stand. Gemessen: 0,9 statt 1,9 Bilder pro
Sekunde, solange ein zweites Fenster mitlas.
Deshalb weist `vogelkamera.py` einen zweiten Zuschauer mit **503** ab, statt
beide schlecht zu bedienen. Wer selbst zuschauen will, hält kurz den Detector
an — oder nimmt `/snapshot`, das kostet nur ein Bild.
⚠️ **Bis zum 09.10.2026 stand das hier und stimmte nicht.** Die Fassung, die
auf dem Gerät läuft, hat den zweiten Zuschauer nur **gezählt** und beide
bedient; abgewiesen hat ihn nur die ESP32-Kameraseite. Aufgefallen ist es erst
beim Nachziehen der Tests, die seit dem 25.09. auf die alte Fassung zeigten.
Seitdem tut der Code, was hier steht.
Ein Stream, der **15 Sekunden kein Bild** liefert, wird beendet. Das klingt
nebensächlich, ist aber die Bedingung dafür, dass die Absage überhaupt
funktionieren darf: Ohne neues Bild schreibt der Server nichts und merkt
deshalb nicht, dass der Zuschauer schon weg ist — der eine Platz bliebe für
immer besetzt und die Kamera wäre durch ihre eigene Schutzsperre dauerhaft
stumm. Der Detector verbindet von selbst neu.
### Die Temperatur im Blick behalten
Ein Vogelhaus in der Sonne ist ein geschlossener Kasten. Gemessen an einem
Septembernachmittag: **72 °C** auf einem Pi Zero W. Ab etwa 80 °C drosselt der
Pi von selbst, und die Bildrate fällt — ohne Fehlermeldung.
```bash
curl http://<pi>:8080/temp
```
Diese Abfrage kostet den Stream nichts, im Gegensatz zu einem Blick auf das
Bild. **`ping` taugt hier nicht als Gesundheitsprüfung:** ICMP fällt bei
gesättigtem WLAN als Erstes aus, während TCP weiterläuft — eine Entwarnung per
`ping` ist Glück, keine Messung.
Das vollständige Bild gibt `/status`, ebenfalls ohne den Stream anzufassen:
```bash
curl -s http://<pi>:8080/status
{"kamera":"bereit","bilder":1751409,"fps":3.84,"letztes_bild_s":0.2,
"fehler":"0x0","laufzeit_s":401238,"zuschauer":true,"rssi":-58,
"temperatur_c":55.1,"speicher_frei":218103808}
```
| Feld | Gesund | Woran es sonst liegt |
|---|---|---|
| `fps` | stabil über Stunden | fällt → zweiter Zuschauer, Funkstrecke, Hitze |
| `rssi` | über −70 dBm | darunter näher ran oder Antenne ausrichten |
| `temperatur_c` | unter 70 °C | geschlossener Kasten in der Sonne drosselt |
| `letztes_bild_s` | unter 1 s | steht → Kamera liefert nicht mehr |
| `zuschauer` | `true` im Normalbetrieb | `false`, obwohl der Detector läuft → er kommt nicht durch |
`bilder` und `fps` zählen, was die **Kamera liefert**, nicht was ein Zuschauer
abholt. Ein `fps` über null bei `zuschauer: false` heißt also: die Kamera
arbeitet, es holt nur niemand ab.
Wer den Pi in einen geschlossenen Kasten baut: Schattenseite wählen, Öffnung
für Luft lassen, und die Kamera nicht direkt neben den SoC setzen.
### Die Bildrate hängt am Licht, nicht an der CPU
Über WLAN ist die Strecke bandbreitenbegrenzt. Ein helles, detailreiches Bild
ergibt größere JPEGs und damit **weniger** Bilder pro Sekunde. Gemessen über
neun Tage am selben Aufbau: nachts 6,2 fps (fast schwarzes Bild), tagsüber
3,7–4,2 fps.
Reicht die Bildrate nicht, ist `--qualitaet` der Hebel — nicht die Auflösung.
Die Auflösung braucht man, damit der Vogel groß genug im Bild ist.
## Wie groß muss der Vogel im Bild sein?
Das ist die Frage, an der ein Aufbau scheitert, und sie lässt sich vorher
beantworten — ohne auf einen echten Vogel zu warten:
```bash
python -m detector karte --source http://<pi>:8080/stream --ziel karte.jpg
```
Der Detector montiert ein Vogelfoto über ein Raster von Positionen in aktuelle
Bilder deiner Kamera und zeigt, wo er anspringt. Details im
[Kamera-Kapitel](../docs/kamera.md#hängt-die-kamera-richtig-detector-karte).
Die zwei Regeln, die dabei herausgekommen sind:
* Ein Vogel braucht **5–8 % der Bildfläche**. Vor ruhigem Hintergrund reichen
5 %, vor einem Baum im Wind sind es 8 %.
* Die **Landefläche gehört in die untere Bildmitte, nicht an den unteren
Bildrand.** Dort unten findet der Detektor auch einen großen Vogel nicht
zuverlässig. Eine Kamera, die flach über das Futterbrett hinwegschaut, legt
jeden Gast genau in diese tote Zone.
+436
View File
@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""MJPEG-Server fuer die Kameraseite. Laeuft auf dem Pi Zero W am Vogelhaus.
Der Pi streamt nur -- erkannt wird auf einer Maschine mit Rechenleistung.
Ein Zero W schafft das Streamen, aber keine Inferenz.
http://<pi>:8080/ Vorschauseite
http://<pi>:8080/stream MJPEG -- nur ein Zuschauer, der zweite bekommt 503
http://<pi>:8080/snapshot ein einzelnes Bild
http://<pi>:8080/status JSON: Bildrate, Funkstaerke, Temperatur
http://<pi>:8080/temp Chiptemperatur als Text
http://<pi>:8080/gesund 200 oder 503
🔑 **Die Felder von /status sind dieselben wie bei der ESP32-Kameraseite**
(esp32cam/). Damit kann ein Werkzeug beide abfragen, ohne zu wissen, was
am anderen Ende haengt -- die Bruecke nach Home Assistant tut genau das.
## Warum rpicam-vid und nicht picamera2
⚠️ **Weil es laeuft.** Diese Fassung streamt seit dem 28.08.2026
ununterbrochen am Vogelhaus. Im Repo lag bis zum 25.09.2026 eine
picamera2-Fassung, die eleganter aussah und **nie auf einem Pi gestartet
worden war** -- picamera2 ist auf dem Geraet nicht einmal installiert.
Der Finger in der Wunde: *im Repo lag Code, der nie getestet wurde.*
Zwei Wahrheiten, und die ungetestete stand im Repo. Seitdem steht hier, was auf dem Geraet laeuft.
rpicam-vid ist ausserdem das leichtere Verfahren: Die Arbeit passiert im
C-Programm, Python schiebt nur Bytes weiter. Auf einem Zero W zaehlt das.
## Englisch im Code
Der Code kam so vom Geraet und wurde bewusst NICHT uebersetzt. Eine
laufende Kamera umbenennt man nicht, um sie huebscher zu machen -- jede
Zeile, die man anfasst, kann sie stilllegen. Neue Teile sind auf Deutsch
kommentiert.
"""
import json
import subprocess
import threading
import time
import sys
import os
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
# Configuration
WIDTH = 1280
HEIGHT = 720
# Aufnahmerate. Bis zum 25.09.2026 stand hier 15.
#
# ⚠️ **Die 15 waren ein Wunsch, kein Zustand.** Gemessen am Zaehler, der
# an der QUELLE sitzt: 3,79 Bilder/s vorher, 3,77 nachher. Der Pi hat die
# 15 nie erreicht.
#
# 🔑 Und der Grund ist nicht das WLAN, sondern der Chip selbst: Ein
# Zero W kodiert 1280x720 MJPEG nicht schneller. Er laeuft am Anschlag,
# egal was man ihm sagt -- rpicam-vid stand bei 60 % CPU, der Server bei
# 30 %, Lastmittel 3,8 auf EINEM Kern.
#
# Die ehrliche Zahl hier zu haben ist trotzdem besser als eine
# ausgedachte: Sie sagt jedem, was das Geraet wirklich tut. Gebracht hat
# die Aenderung zwei Grad (46,5 -> 44,4 °C), mehr nicht.
#
# 📌 Der echte Hebel waere die Aufloesung, nicht die Bildrate.
FPS = 5
QUALITY = 80
PORT = 8080
RPICAM_VID = "/usr/bin/rpicam-vid"
# Nach so vielen Sekunden ohne neues Bild wird ein Stream beendet. Siehe die
# Begruendung bei /stream -- der Wert haelt den Zuschauerplatz frei, wenn die
# Aufnahme haengt. Grosszuegig gewaehlt: bei 5 fps sind 15 s sehr viel.
STREAM_LEERLAUF = 15.0
def read_cpu_temp():
"""Read CPU temperature from thermal zone."""
try:
with open("/sys/class/thermal/thermal_zone0/temp") as f:
return int(f.read().strip()) / 1000.0
except Exception:
return None
# --- Werte fuer /status ------------------------------------------------
#
# 🔑 Alles hier liest aus /proc oder /sys. Kein Aufruf fremder Programme,
# kein zusaetzliches Paket -- auf einem Zero W ist jeder Unterprozess
# teuer, und dieser Server soll vor allem eines: nicht im Weg stehen.
START_ZEIT = time.time()
bilder_gesamt = 0 # vom capture_loop hochgezaehlt
zuschauer = 0 # wie viele gerade am Stream haengen
_zaehler_sperre = threading.Lock()
# 🔑 **Nur ein Zuschauer gleichzeitig.** Ein Zero W liefert ueber WLAN rund
# 2 Mbit/s; ein zweiter Client teilt sich dieselbe Strecke, und der Detector
# faellt in Timeouts, ohne dass irgendwo ein Fehler steht -- die Bildrate
# halbiert sich einfach. Lieber eine klare Absage als zwei ruckelnde Bilder.
#
# ⚠️ Das stand von Anfang an in pi/README.md, im Code aber nicht: die
# rpicam-vid-Fassung vom Geraet hat den zweiten Zuschauer nur GEZAEHLT.
# Die ESP32-Kameraseite weist ihn ab, der Pi tat es nicht.
#
# Nicht blockierend genommen: ein zweiter Zuschauer soll eine Absage
# bekommen und nicht in einer Warteschlange haengen.
zuschauer_platz = threading.Semaphore(1)
def funkstaerke():
"""Empfangspegel in dBm, oder None.
/proc/net/wireless statt iwconfig: kein Unterprozess, und das Format
ist seit Jahrzehnten unveraendert. Die dritte Zahl ist der Pegel.
"""
try:
with open("/proc/net/wireless") as f:
for zeile in f.read().splitlines()[2:]:
teile = zeile.split()
if len(teile) >= 4:
return int(float(teile[3].rstrip(".")))
except Exception:
pass
return None
def speicher_frei():
"""Freier Arbeitsspeicher in Bytes, oder None."""
try:
with open("/proc/meminfo") as f:
for zeile in f:
if zeile.startswith("MemAvailable:"):
return int(zeile.split()[1]) * 1024
except Exception:
pass
return None
def status_json():
"""Dieselben Feldnamen wie die ESP32-Kameraseite. Siehe Modulkopf."""
frame, ts = frame_buffer.get()
jetzt = time.time()
laufzeit = jetzt - START_ZEIT
with _zaehler_sperre:
bilder = bilder_gesamt
schaut = zuschauer
return {
# 'bereit', sobald ueberhaupt ein Bild angekommen ist. Ein Server
# ohne Bild soll das sagen, statt still 503 zu liefern -- sonst
# steht man im Garten und raet.
"kamera": "bereit" if frame else "fehlt",
"fehler": "0x0" if frame else "0x1",
"laufzeit_s": int(laufzeit),
"bilder": bilder,
# Mittel ueber die gesamte Laufzeit -- kein gleitender Durchschnitt.
# Fuer 'laeuft es noch' genuegt das, und es kostet nichts.
"fps": round(bilder / laufzeit, 2) if laufzeit > 0 else 0.0,
"letztes_bild_s": round(jetzt - ts, 1) if ts else -1.0,
"zuschauer": schaut > 0,
"rssi": funkstaerke(),
"temperatur_c": read_cpu_temp(),
"speicher_frei": speicher_frei(),
}
class FrameBuffer:
"""Thread-safe circular frame buffer. Keeps only the latest frame."""
def __init__(self):
self._lock = threading.Lock()
self._frame = None
self._timestamp = 0
def update(self, frame_bytes):
with self._lock:
self._frame = frame_bytes
self._timestamp = time.time()
def get(self):
with self._lock:
return self._frame, self._timestamp
frame_buffer = FrameBuffer()
def capture_loop():
"""Continuously capture MJPEG frames from rpicam-vid."""
cmd = [
RPICAM_VID,
"-t", "0", # infinite
"--codec", "mjpeg",
"--width", str(WIDTH),
"--height", str(HEIGHT),
"--framerate", str(FPS),
"--quality", str(QUALITY),
"--vflip",
"--hflip",
"--nopreview",
"-o", "-", # stdout
]
print(f"[capture] Starting: {' '.join(cmd)}", flush=True)
while True:
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0
)
# Read MJPEG stream - frames are delimited by JPEG markers
buf = b""
while True:
chunk = proc.stdout.read(4096)
if not chunk:
break
buf += chunk
# Find JPEG frame boundaries (FFD8 = SOI, FFD9 = EOI)
while True:
soi = buf.find(b"\xff\xd8")
if soi == -1:
buf = b""
break
eoi = buf.find(b"\xff\xd9", soi + 2)
if eoi == -1:
# Incomplete frame, keep rest
buf = buf[soi:]
break
# Complete frame
frame = buf[soi:eoi + 2]
buf = buf[eoi + 2:]
frame_buffer.update(frame)
# Zaehlwerk fuer /status. Gemessen wird, was die
# Kamera LIEFERT -- nicht, was ein Zuschauer abholt.
# Damit sagt /status auch dann etwas, wenn niemand
# zuschaut.
global bilder_gesamt
with _zaehler_sperre:
bilder_gesamt += 1
proc.wait()
print(f"[capture] rpicam-vid exited with code {proc.returncode}, restarting...", flush=True)
except Exception as e:
print(f"[capture] Error: {e}, restarting...", flush=True)
time.sleep(2) # Wait before restart
class MJPEGHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/" or self.path == "/index.html":
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vogelkamera</title>
<style>
body {{ margin:0; background:#1a1a2e; color:#fff; font-family:system-ui,sans-serif; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:100vh; }}
h1 {{ font-size:1.5em; margin:0.5em 0; }}
img {{ max-width:100%; max-height:80vh; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.5); }}
.footer {{ margin-top:1em; font-size:0.8em; opacity:0.6; }}
</style>
</head>
<body>
<h1>🐦 Vogelkamera</h1>
<img src="/stream" alt="Live Stream">
<div class="footer">Pi Zero W · OV5647 · {WIDTH}x{HEIGHT} · <span id="temp">--°C</span></div>
<script>
function updateTemp() {{
fetch('/temp').then(r => r.text()).then(t => {{
document.getElementById('temp').textContent = t;
}}).catch(() => {{}});
}}
updateTemp();
setInterval(updateTemp, 5000);
</script>
</body>
</html>"""
self.wfile.write(html.encode())
elif self.path == "/temp":
temp = read_cpu_temp()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
if temp is not None:
self.wfile.write(f"{temp:.1f}°C".encode())
else:
self.wfile.write(b"N/A")
elif self.path == "/status":
# ⚠ Fasst den Stream NICHT an. Das ist der Punkt: eine
# laufende Kamera pruefen, ohne sie zu stoeren. 'ping' taugt
# dafuer nicht -- ICMP faellt bei gesaettigtem WLAN als
# Erstes aus, waehrend TCP weiterlaeuft.
koerper = json.dumps(status_json()).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-cache")
self.send_header("Content-Length", str(len(koerper)))
self.end_headers()
self.wfile.write(koerper)
elif self.path in ("/gesund", "/health"):
frame, ts = frame_buffer.get()
# Aelter als zehn Sekunden heisst: die Aufnahme haengt,
# auch wenn der Server noch antwortet.
gesund = bool(frame) and (time.time() - ts) < 10
self.send_response(200 if gesund else 503)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok\n" if gesund else b"kein aktuelles Bild\n")
elif self.path == "/stream":
if not zuschauer_platz.acquire(blocking=False):
absage = (
"Es ist bereits ein Zuschauer verbunden.\n"
"Diese Kamera liefert nur einen Stream gleichzeitig -- ein zweiter\n"
"wuerde die Funkstrecke saettigen und beide Bilder ruckeln lassen.\n"
).encode("utf-8")
self.send_response(503)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(absage)))
self.end_headers()
self.wfile.write(absage)
return
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.send_header("Cache-Control", "no-cache, private")
self.send_header("Pragma", "no-cache")
self.end_headers()
global zuschauer
with _zaehler_sperre:
zuschauer += 1
last_ts = 0
leerlauf_seit = time.time()
try:
while True:
frame, ts = frame_buffer.get()
if frame and ts != last_ts:
last_ts = ts
leerlauf_seit = time.time()
self.wfile.write(b"--frame\r\n")
self.wfile.write(f"Content-Type: image/jpeg\r\nContent-Length: {len(frame)}\r\n\r\n".encode())
self.wfile.write(frame)
self.wfile.write(b"\r\n")
self.wfile.flush()
elif time.time() - leerlauf_seit > STREAM_LEERLAUF:
# 🔑 **Der Platz darf nicht an einer stehenden Kamera
# haengen bleiben.** Ohne neues Bild wird nichts
# geschrieben -- also merkt der Server auch nicht, dass
# der Zuschauer schon weg ist, und haelt den einen
# Platz fuer immer. Dann holt rpicam-vid sich zwar nach
# zwei Sekunden die Kamera zurueck, aber der Detector
# bekommt bei jedem Versuch 503 und die Kamera ist
# dauerhaft stumm -- ausgerechnet durch die Sperre, die
# sie schuetzen soll.
#
# Ein Stream, der 15 s nichts liefert, ist ohnehin
# nichts wert; der Detector verbindet von selbst neu.
break
# ⚠ Haeufiger nachsehen als aufgenommen wird: Sonst
# wartet der Zuschauer im schlechtesten Fall ein ganzes
# Bild lang, obwohl schon eines bereitliegt. Kostet
# nichts -- ohne neues Bild wird nichts gesendet.
time.sleep(0.5 / FPS)
except (BrokenPipeError, ConnectionResetError):
pass # Client disconnected
finally:
# ⚠ finally, nicht nach dem except: Auch ein Abbruch von
# aussen muss den Zaehler wieder senken, sonst steht dort
# nach ein paar Tagen eine Zahl, die nichts mehr bedeutet.
#
# Dasselbe gilt fuer den Platz: Bleibt er haengen, ist die
# Kamera nach einem geschlossenen Browserfenster dauerhaft
# dicht. Das Trennen merkt der Server erst beim naechsten
# Schreibversuch -- und genau der laeuft hier in den except.
with _zaehler_sperre:
zuschauer -= 1
zuschauer_platz.release()
elif self.path == "/snapshot":
frame, _ = frame_buffer.get()
if frame:
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Content-Length", str(len(frame)))
self.end_headers()
self.wfile.write(frame)
else:
self.send_response(503)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress logs
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
if __name__ == "__main__":
# Start capture thread
capture_thread = threading.Thread(target=capture_loop, daemon=True)
capture_thread.start()
# Wait for first frame
print("[server] Waiting for first frame...", flush=True)
for i in range(30):
frame, _ = frame_buffer.get()
if frame:
print(f"[server] First frame received ({len(frame)} bytes)", flush=True)
break
time.sleep(1)
else:
print("[server] WARNING: No frame received within 30s, starting anyway", flush=True)
# Start HTTP server
server = ThreadedHTTPServer(("0.0.0.0", PORT), MJPEGHandler)
print(f"[server] MJPEG stream on http://0.0.0.0:{PORT}/", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
print("[server] Shutting down", flush=True)
+26
View File
@@ -0,0 +1,26 @@
# Kameraseite als Dienst auf dem Raspberry Pi.
#
# sudo cp vogelkamera.py /opt/vogelkamera_server.py
# sudo cp vogelkamera.service /etc/systemd/system/
# sudo systemctl enable --now vogelkamera
# journalctl -u vogelkamera -f
#
# ⚠ Diese Datei ist die Abschrift dessen, was am Vogelhaus laeuft -- nicht
# ein Entwurf. Wer sie aendert, aendert ein Geraet im Garten.
#
# Der Benutzer muss in der Gruppe 'video' sein; bei Raspberry Pi OS ist der
# angelegte Erstbenutzer das bereits.
[Unit]
Description=Vogelkamera MJPEG Stream Server
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/vogelkamera_server.py
Restart=always
RestartSec=5
User=admin
[Install]
WantedBy=multi-user.target