Files
webcam-server/relay.py
T

347 lines
14 KiB
Python

#!/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)
/<name>/ Webseite mit Livebild einer Kamera
/status.json Status aller Kameras (vom Frontend abgefragt)
/<name>/stream.mjpg Livestream (multipart/x-mixed-replace)
/<name>/snapshot.jpg aktuelles Einzelbild
POST /<name>/timelapse Timelapse einer Kamera steuern, Body: {"enabled": true, "interval": 300}
POST /<name>/setup ruft die in SETUP_<name> hinterlegte Kamera-URL auf
/<name>/timelapse.json Liste der Timelapse-Bilder je Tag
/<name>/timelapse/<tag>/<datei> 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_<name>=<url> eine Zeile pro Kamera, z.B. CAMERA_cam1=http://...
SETUP_<name>=<url> optional: Konfigurations-URL der Kamera (Button "Setup" im Frontend),
z.B. http://IP/control?var=framesize&val=11
TIMELAPSE_INTERVAL=<sek> Standard-Intervall der Timelapse (default 300); ein/aus und Intervall
stellt man pro Kamera im Frontend ein
TIMELAPSE_DIR=<pfad> Zielordner (default: timelapse neben relay.py)
TIMELAPSE_KEEP_DAYS=<n> 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_<name>=<url> 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/<name>/<tag>/<HHMMSS>.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()