Files
webcam-server/relay.py
T

511 lines
22 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
/<name>/timelapse/<tag>/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_<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)
MOTION_COOLDOWN=<sek> Mindestabstand zwischen zwei Bewegungsbildern (default 10); die Erkennung
selbst (braucht Pillow) schaltet man pro Kamera im Frontend ein
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 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_<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}(_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/<name>/<tag>/<HHMMSS><suffix>.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("<I14I", 56, 1000000 // fps, big * fps, 0, 0x10, n, 0, 1, big, w, h, 0, 0, 0, 0)
+ b"LIST" + struct.pack("<I", 4 + 64 + 48) + b"strl"
+ b"strh" + struct.pack("<I4s4sIHHIIIIIIIIhhhh", 56, b"vids", b"MJPG", 0, 0, 0, 0, 1, fps, 0, n, big, 0xFFFFFFFF, 0, 0, 0, w, h)
+ b"strf" + struct.pack("<IIiiHH4sIIIII", 40, 40, w, h, 1, 24, b"MJPG", w * h * 3, 0, 0, 0, 0))
riff = 4 + 8 + 4 + len(hdrl) + 8 + movi + 8 + 16 * n
with open(path, "wb") as out:
out.write(b"RIFF" + struct.pack("<I", riff) + b"AVI ")
out.write(b"LIST" + struct.pack("<I", 4 + len(hdrl)) + b"hdrl" + hdrl)
out.write(b"LIST" + struct.pack("<I", movi) + b"movi")
idx, off = [], 4
for x, size in zip(files, sizes):
with open(x, "rb") as f:
data = f.read()
out.write(b"00dc" + struct.pack("<I", len(data)) + data + (b"\0" if len(data) & 1 else b""))
idx.append(b"00dc" + struct.pack("<III", 0x10, off, len(data)))
off += 8 + pad(len(data))
out.write(b"idx1" + struct.pack("<I", 16 * n) + b"".join(idx))
video_lock = threading.Lock()
def make_video(name, day, fps, kind="lapse"):
"""Erzeugt das Tagesvideo; liefert (Pfad, Dateiendung) oder None, wenn es keine Bilder gibt."""
folder = os.path.join(TL_DIR, name, day)
try:
files = sorted(os.path.join(folder, f) for f in os.listdir(folder)
if FILE_RE.match(f) and f.endswith("_m.jpg") == (kind == "motion"))
except FileNotFoundError:
files = []
if not files:
return None
with video_lock:
if shutil.which("ffmpeg"):
fd, path = tempfile.mkstemp(suffix=".mp4")
os.close(fd)
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(fps),
"-pattern_type", "glob", "-i", os.path.join(folder, "*_m.jpg" if kind == "motion" else "??????.jpg"),
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", path],
capture_output=True, timeout=600)
if r.returncode == 0:
return path, "mp4"
print(f"{name}: ffmpeg fehlgeschlagen: {r.stderr.decode(errors='replace')[:300]}", flush=True)
os.unlink(path)
fd, path = tempfile.mkstemp(suffix=".avi")
os.close(fd)
write_avi(path, files, fps)
return path, "avi"
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],
"motion_ok": Image is not None}
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:2] == ["timelapse"] and parts[3] == "video":
if not DAY_RE.match(parts[2]):
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
q = dict(kv.split("=", 1) for kv in self.path.partition("?")[2].split("&") if "=" in kv)
fps = int(q["fps"]) if q.get("fps", "").isdigit() and 1 <= int(q["fps"]) <= 60 else 10
try:
res = make_video(name, parts[2], fps, "motion" if q.get("kind") == "motion" else "lapse")
except (OSError, ValueError, subprocess.SubprocessError) as ex:
print(f"{name}: Video fehlgeschlagen: {ex}", flush=True)
return self.send_bytes(500, "text/plain", b"Video konnte nicht erstellt werden")
if res is None:
return self.send_bytes(404, "text/plain", b"Keine Bilder")
path, ext = res
try:
self.send_response(200)
self.send_header("Content-Type", "video/mp4" if ext == "mp4" else "video/x-msvideo")
self.send_header("Content-Length", str(os.path.getsize(path)))
self.send_header("Content-Disposition", f'attachment; filename="{name}-{parts[2]}{"-bewegung" if q.get("kind") == "motion" else ""}.{ext}"')
self.end_headers()
with open(path, "rb") as f:
shutil.copyfileobj(f, self.wfile)
except (BrokenPipeError, ConnectionResetError):
pass
finally:
os.unlink(path)
return
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 "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()