#!/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 /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) 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 json import os import re import shutil import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import requests 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}\.jpg$") 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))} 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 timelapse(name, hub): """Speichert, solange für die Kamera aktiviert, im Intervall das aktuelle Bild nach TL_DIR///.jpg.""" 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() 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") with open(tmp, "wb") as f: f.write(frame) os.replace(tmp, os.path.join(folder, time.strftime("%H%M%S", now) + ".jpg")) if TL_KEEP_DAYS: prune(os.path.join(TL_DIR, name)) except OSError as ex: print(f"{name}: Timelapse-Bild nicht gespeichert: {ex}", flush=True) 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 timelapse_index(name): """{"interval": sek, "days": {tag: [dateien]}} aus dem Dateisystem.""" days = {} cam_dir = os.path.join(TL_DIR, name) try: for day in sorted(os.listdir(cam_dir), reverse=True): if DAY_RE.match(day): days[day] = sorted(f for f in os.listdir(os.path.join(cam_dir, day)) if FILE_RE.match(f)) except FileNotFoundError: pass return {**tl_state[name], "days": days} class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, fmt, *args): pass def authorized(self): if not AUTH_USER: return True h = self.headers.get("Authorization", "") want = "Basic " + base64.b64encode(f"{AUTH_USER}:{AUTH_PASS}".encode()).decode() return hmac.compare_digest(h, want) def send_bytes(self, code, ctype, body, extra=None): self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) extra = extra or {} if "Cache-Control" not in extra: self.send_header("Cache-Control", "no-store") for k, v in extra.items(): self.send_header(k, v) self.end_headers() self.wfile.write(body) def do_GET(self): parts = [p for p in self.path.split("?")[0].split("/") if p] if parts == ["health"]: lines = [f"{n}: {'ok' if is_online(h) else 'keine Frames'}" for n, h in hubs.items()] ok = all(l.endswith(": ok") for l in lines) return self.send_bytes(200 if ok else 503, "text/plain", ("\n".join(lines) + "\n").encode()) if not self.authorized(): return self.send_bytes(401, "text/plain", b"Login erforderlich", {"WWW-Authenticate": 'Basic realm="Webcam"'}) if not parts: return self.send_bytes(200, "text/html; charset=utf-8", page("", "")) if parts == ["status.json"]: body = json.dumps({"cameras": [{"name": n, "online": is_online(h), "setup": n in SETUPS, "timelapse": tl_state[n]} for n, h in hubs.items()]}).encode() return self.send_bytes(200, "application/json", body) if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]: parts = [next(iter(CAMERAS))] + parts # Alias: erste Kamera name = parts[0] if name in hubs and len(parts) == 2 and parts[1] == "timelapse.json": return self.send_bytes(200, "application/json", json.dumps(timelapse_index(name)).encode()) if name in hubs and len(parts) == 4 and parts[1] == "timelapse": if not (DAY_RE.match(parts[2]) and FILE_RE.match(parts[3])): return self.send_bytes(404, "text/plain", b"Nicht gefunden") try: with open(os.path.join(TL_DIR, name, parts[2], parts[3]), "rb") as f: body = f.read() except FileNotFoundError: return self.send_bytes(404, "text/plain", b"Nicht gefunden") return self.send_bytes(200, "image/jpeg", body, {"Cache-Control": "max-age=86400"}) if name not in hubs or len(parts) > 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 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() print(f"Relay läuft auf Port {PORT}, Kameras: {', '.join(CAMERAS)}", flush=True) print(f"Timelapse-Ordner: {TL_DIR}", flush=True) Server(("0.0.0.0", PORT), Handler).serve_forever()