#!/usr/bin/env python3 """Webcam-Relay: holt den MJPEG-Stream der Kamera einmal ab und stellt ihn per HTTP für beliebig viele Zuschauer bereit (hinter Zoraxy / Reverse Proxy). Endpunkte: / Übersicht aller Kameras (Web-Frontend, frontend.html) // Webseite mit Livebild einer Kamera /status.json Status aller Kameras (vom Frontend abgefragt) //stream.mjpg Livestream (multipart/x-mixed-replace) //snapshot.jpg aktuelles Einzelbild POST //timelapse Timelapse einer Kamera steuern, Body: {"enabled": true, "interval": 300} POST //setup ruft die in SETUP_ hinterlegte Kamera-URL auf //timelapse.json Liste der Timelapse-Bilder je Tag //timelapse// einzelnes Timelapse-Bild //timelapse//video?fps=10 Tagesvideo (MP4 mit ffmpeg, sonst MJPEG-AVI) /stream.mjpg Alias für die erste Kamera (Abwärtskompatibilität) /health Status aller Kameras Konfiguration über die Datei .env (neben relay.py) oder Umgebungsvariablen: CAMERA_= eine Zeile pro Kamera, z.B. CAMERA_cam1=http://... SETUP_= optional: Konfigurations-URL der Kamera (Button "Setup" im Frontend), z.B. http://IP/control?var=framesize&val=11 TIMELAPSE_INTERVAL= Standard-Intervall der Timelapse (default 300); ein/aus und Intervall stellt man pro Kamera im Frontend ein TIMELAPSE_DIR= Zielordner (default: timelapse neben relay.py) MOTION_COOLDOWN= Mindestabstand zwischen zwei Bewegungsbildern (default 10); die Erkennung selbst (braucht Pillow) schaltet man pro Kamera im Frontend ein TIMELAPSE_KEEP_DAYS= Tage aufbewahren (0 = unbegrenzt, default) PORT (default 8000) AUTH_USER / AUTH_PASS optional: Basic-Auth (nur sinnvoll mit HTTPS in Zoraxy) """ import base64 import hmac import io import json import os import re import shutil import struct import subprocess import tempfile import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import requests try: from PIL import Image except ImportError: # Bewegungserkennung ist optional Image = None def load_env(path): """Liest KEY=VALUE-Zeilen aus .env; echte Umgebungsvariablen haben Vorrang.""" try: with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) os.environ.setdefault(k.strip(), v.strip().strip("\"'")) except FileNotFoundError: pass load_env(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) CAMERAS = {k[7:].lower(): v for k, v in os.environ.items() if k.startswith("CAMERA_") and v} if not CAMERAS: raise SystemExit("Keine Kameras konfiguriert: CAMERA_= in .env eintragen") SETUPS = {k[6:].lower(): v for k, v in os.environ.items() if k.startswith("SETUP_") and v and k[6:].lower() in CAMERAS} PORT = int(os.environ.get("PORT", "8000")) AUTH_USER = os.environ.get("AUTH_USER", "") AUTH_PASS = os.environ.get("AUTH_PASS", "") BOUNDARY = "frame" BASE_DIR = os.path.dirname(os.path.abspath(__file__)) TL_INTERVAL = int(os.environ.get("TIMELAPSE_INTERVAL", "300")) TL_DIR = os.path.abspath(os.environ.get("TIMELAPSE_DIR") or os.path.join(BASE_DIR, "timelapse")) TL_KEEP_DAYS = int(os.environ.get("TIMELAPSE_KEEP_DAYS", "0")) DAY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") FILE_RE = re.compile(r"^\d{6}(_m)?\.jpg$") # _m = Bewegungsbild MOTION_COOLDOWN = int(os.environ.get("MOTION_COOLDOWN", "10")) MOTION_LEVELS = {"low": 0.04, "mid": 0.02, "high": 0.007} # Anteil geänderter Pixel FRONTEND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend.html") def page(root, current): """Frontend ausliefern; Links sind relativ, damit es auch unter einem Proxy-Prefix läuft.""" with open(FRONTEND, encoding="utf-8") as f: html = f.read() return (html.replace("__ROOT__", json.dumps(root)) .replace("__CURRENT__", json.dumps(current))).encode() class Hub: """Hält das neueste Frame; Zuschauer warten auf das jeweils nächste.""" def __init__(self): self.cond = threading.Condition() self.frame = None self.seq = 0 self.last = 0.0 def publish(self, jpg): with self.cond: self.frame, self.seq, self.last = jpg, self.seq + 1, time.time() self.cond.notify_all() def wait(self, seq, timeout=10): with self.cond: self.cond.wait_for(lambda: self.seq != seq, timeout) return self.frame, self.seq hubs = {name: Hub() for name in CAMERAS} def is_online(hub): return bool(hub.last) and time.time() - hub.last < 10 def pull_camera(name, url, hub): """Liest die Kamera dauerhaft; baut die Verbindung bei Fehlern neu auf.""" while True: try: with requests.get(url, stream=True, timeout=10) as r: r.raise_for_status() buf = b"" for chunk in r.iter_content(chunk_size=8192): buf += chunk while True: s = buf.find(b"\xff\xd8") if s < 0: buf = b"" break e = buf.find(b"\xff\xd9", s + 2) if e < 0: buf = buf[s:] break hub.publish(buf[s:e + 2]) buf = buf[e + 2:] except requests.RequestException as ex: print(f"{name}: Kamera nicht erreichbar: {ex} - neuer Versuch in 3s", flush=True) time.sleep(3) TL_STATE_FILE = os.path.join(TL_DIR, "state.json") TL_INTERVALS = (10, 30, 60, 300, 900, 1800, 3600) tl_lock = threading.Lock() def load_tl_state(): try: with open(TL_STATE_FILE, encoding="utf-8") as f: saved = json.load(f) except (OSError, ValueError): saved = {} return {n: {"enabled": bool(saved.get(n, {}).get("enabled")), "interval": int(saved.get(n, {}).get("interval", TL_INTERVAL)), "motion": bool(saved.get(n, {}).get("motion")) and Image is not None, "sensitivity": saved.get(n, {}).get("sensitivity") if saved.get(n, {}).get("sensitivity") in MOTION_LEVELS else "mid"} for n in CAMERAS} tl_state = load_tl_state() def save_tl_state(): try: os.makedirs(TL_DIR, exist_ok=True) with open(TL_STATE_FILE + ".tmp", "w", encoding="utf-8") as f: json.dump(tl_state, f) os.replace(TL_STATE_FILE + ".tmp", TL_STATE_FILE) except OSError as ex: print(f"Timelapse-Zustand nicht gespeichert: {ex}", flush=True) def save_frame(name, frame, suffix=""): """Legt das Bild als TL_DIR///.jpg ab.""" now = time.localtime() folder = os.path.join(TL_DIR, name, time.strftime("%Y-%m-%d", now)) try: os.makedirs(folder, exist_ok=True) tmp = os.path.join(folder, ".tmp" + suffix) with open(tmp, "wb") as f: f.write(frame) os.replace(tmp, os.path.join(folder, time.strftime("%H%M%S", now) + suffix + ".jpg")) if TL_KEEP_DAYS: prune(os.path.join(TL_DIR, name)) except OSError as ex: print(f"{name}: Bild nicht gespeichert: {ex}", flush=True) def timelapse(name, hub): """Speichert, solange für die Kamera aktiviert, im Intervall das aktuelle Bild.""" last_seq, last_t = 0, 0.0 while True: time.sleep(1) st = tl_state[name] if not st["enabled"] or time.time() - last_t < st["interval"]: continue frame, seq = hub.frame, hub.seq if frame is None or seq == last_seq or not is_online(hub): continue last_seq, last_t = seq, time.time() save_frame(name, frame) def thumb(frame): """Graustufen-Miniatur (80x60) für den Bildvergleich.""" im = Image.open(io.BytesIO(frame)) im.draft("L", (160, 120)) # JPEG gleich verkleinert dekodieren return list(im.convert("L").resize((80, 60)).tobytes()) def motion(name, hub): """Vergleicht jede Sekunde das Bild mit einem langsam nachgeführten Referenzbild und speichert bei genug geänderten Pixeln ein Bewegungsbild (HHMMSS_m.jpg).""" ref, last_seq, last_hit = None, 0, 0.0 while True: time.sleep(1) st = tl_state[name] if not st["motion"] or Image is None: ref = None continue frame, seq = hub.frame, hub.seq if frame is None or seq == last_seq or not is_online(hub): continue last_seq = seq try: cur = thumb(frame) except (OSError, ValueError): continue if ref is None or len(ref) != len(cur): ref = [float(v) for v in cur] continue changed = sum(1 for a, b in zip(ref, cur) if abs(a - b) > 25) ref = [a + (b - a) * 0.1 for a, b in zip(ref, cur)] if changed / len(cur) > MOTION_LEVELS[st["sensitivity"]] and time.time() - last_hit >= MOTION_COOLDOWN: last_hit = time.time() save_frame(name, frame, "_m") def prune(cam_dir): """Löscht Tagesordner, die älter als TL_KEEP_DAYS sind.""" limit = time.strftime("%Y-%m-%d", time.localtime(time.time() - TL_KEEP_DAYS * 86400)) for day in os.listdir(cam_dir): if DAY_RE.match(day) and day < limit: shutil.rmtree(os.path.join(cam_dir, day), ignore_errors=True) def jpeg_size(data): """Breite und Höhe aus dem SOF-Marker eines JPEG.""" i = 2 while i + 9 < len(data): if data[i] != 0xFF: i += 1 continue m = data[i + 1] if m in (0xC0, 0xC1, 0xC2): h, w = struct.unpack(">HH", data[i + 5:i + 9]) return w, h i += 2 + struct.unpack(">H", data[i + 2:i + 4])[0] raise ValueError("kein JPEG") def write_avi(path, files, fps): """Schreibt die JPEGs als MJPEG-AVI (ohne ffmpeg).""" with open(files[0], "rb") as f: w, h = jpeg_size(f.read()) sizes = [os.path.getsize(x) for x in files] n, big = len(files), max(sizes) pad = lambda v: v + (v & 1) movi = 4 + sum(8 + pad(v) for v in sizes) hdrl = (b"avih" + struct.pack(" 2: return self.send_bytes(404, "text/plain", b"Nicht gefunden") hub = hubs[name] what = parts[1] if len(parts) == 2 else "" if what == "": if not self.path.split("?")[0].endswith("/"): return self.send_bytes(301, "text/plain", b"", {"Location": f"{name}/"}) # relativ: Proxy-Prefix bleibt erhalten return self.send_bytes(200, "text/html; charset=utf-8", page("../", name)) if what == "snapshot.jpg": if hub.frame is None: return self.send_bytes(503, "text/plain", b"Noch kein Bild") return self.send_bytes(200, "image/jpeg", hub.frame) if what == "stream.mjpg": return self.stream(hub) self.send_bytes(404, "text/plain", b"Nicht gefunden") def do_POST(self): if not self.authorized(): return self.send_bytes(401, "text/plain", b"Login erforderlich", {"WWW-Authenticate": 'Basic realm="Webcam"'}) parts = [p for p in self.path.split("?")[0].split("/") if p] if len(parts) == 2 and parts[1] == "timelapse" and parts[0] in hubs: try: n = int(self.headers.get("Content-Length", 0)) req = json.loads(self.rfile.read(n) or b"{}") with tl_lock: st = tl_state[parts[0]] if "enabled" in req: st["enabled"] = bool(req["enabled"]) if "motion" in req: st["motion"] = bool(req["motion"]) and Image is not None if req.get("sensitivity") in MOTION_LEVELS: st["sensitivity"] = req["sensitivity"] if int(req.get("interval", st["interval"])) in TL_INTERVALS: st["interval"] = int(req.get("interval", st["interval"])) save_tl_state() except (ValueError, TypeError): return self.send_bytes(400, "text/plain", b"Ungueltige Anfrage") return self.send_bytes(200, "application/json", json.dumps(tl_state[parts[0]]).encode()) if len(parts) != 2 or parts[1] != "setup" or parts[0] not in SETUPS: return self.send_bytes(404, "text/plain", b"Nicht gefunden") try: r = requests.get(SETUPS[parts[0]], timeout=5) r.raise_for_status() except requests.RequestException as ex: print(f"{parts[0]}: Setup fehlgeschlagen: {ex}", flush=True) return self.send_bytes(502, "text/plain", b"Kamera nicht erreichbar") self.send_bytes(200, "text/plain", b"ok") def stream(self, hub): self.send_response(200) self.send_header("Content-Type", f"multipart/x-mixed-replace; boundary={BOUNDARY}") self.send_header("Cache-Control", "no-store") self.send_header("X-Accel-Buffering", "no") # Proxy soll nicht puffern self.send_header("Connection", "close") self.end_headers() self.close_connection = True seq = 0 try: while True: frame, seq_new = hub.wait(seq) if frame is None or seq_new == seq: continue seq = seq_new self.wfile.write( f"--{BOUNDARY}\r\nContent-Type: image/jpeg\r\n" f"Content-Length: {len(frame)}\r\n\r\n".encode() + frame + b"\r\n") self.wfile.flush() except (BrokenPipeError, ConnectionResetError, TimeoutError): pass class Server(ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True if __name__ == "__main__": for n, u in CAMERAS.items(): threading.Thread(target=pull_camera, args=(n, u, hubs[n]), daemon=True).start() threading.Thread(target=timelapse, args=(n, hubs[n]), daemon=True).start() threading.Thread(target=motion, args=(n, hubs[n]), daemon=True).start() print(f"Relay läuft auf Port {PORT}, Kameras: {', '.join(CAMERAS)}", flush=True) print(f"Timelapse-Ordner: {TL_DIR}", flush=True) if Image is None: print("Pillow fehlt - Bewegungserkennung nicht verfügbar (apt install python3-pil)", flush=True) Server(("0.0.0.0", PORT), Handler).serve_forever()