#!/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://:8080/ Vorschauseite http://:8080/stream MJPEG -- nur ein Zuschauer, der zweite bekommt 503 http://:8080/snapshot ein einzelnes Bild http://:8080/status JSON: Bildrate, Funkstaerke, Temperatur http://:8080/temp Chiptemperatur als Text http://: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""" Vogelkamera

๐Ÿฆ Vogelkamera

Live Stream """ 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)