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
cc9e0786c2
189 files changed
+29792
No files matched your search
+160
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user