From ff3dc7ee2b7939b7a3eb388362b366c15a2ea169 Mon Sep 17 00:00:00 2001 From: Joachim Hummel Date: Sun, 18 Oct 2026 14:08:09 +0200 Subject: [PATCH] Bewegungserkennung pro Kamera (Pillow), Bewegungsbilder in der Timelapse-Galerie mit Filter --- .env.example | 2 + README.md | 9 +++-- frontend.html | 43 +++++++++++++++++---- relay.py | 103 ++++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 125 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index ace283f..a99c408 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,8 @@ CAMERA_cam3=http://IP:PORT/pfad # Zielordner (default: timelapse/ neben relay.py; eigener Pfad muss für den Dienstbenutzer beschreibbar sein) #TIMELAPSE_DIR=/pfad/zum/ordner #TIMELAPSE_KEEP_DAYS=30 +# Bewegungserkennung (je Kamera im Frontend, braucht Pillow): Mindestabstand zwischen zwei Bildern in Sekunden +#MOTION_COOLDOWN=10 PORT=8000 # Optional: Login (nur sinnvoll mit HTTPS in Zoraxy) diff --git a/README.md b/README.md index 939f908..4130ffb 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Zwei kleine Python-Tools für MJPEG-Kameras: - Python 3 - [`requests`](https://pypi.org/project/requests/) +- Optional: [`Pillow`](https://pypi.org/project/pillow/) für die Bewegungserkennung (`apt install python3-pil` oder `pip install pillow`) - Optional: `ffmpeg` für den Video-Download der Timelapse als MP4 (`apt install ffmpeg`); ohne ffmpeg liefert das Relay ein MJPEG-AVI - Optional für `webcam.py --show`: `opencv-python` und `numpy` @@ -55,6 +56,7 @@ cp .env.example .env | `SETUP_` | Optional: Konfigurations-URL der Kamera (z. B. `http://IP/control?var=framesize&val=11`). Das Relay ruft sie auf, wenn im Frontend „Setup“ geklickt wird. | – | | `TIMELAPSE_INTERVAL` | Optional: Standard-Intervall der Timelapse in Sekunden. Ein/aus und Intervall werden je Kamera im Frontend gesetzt und in `state.json` gespeichert. | `300` | | `TIMELAPSE_DIR` | Zielordner, Struktur `//.jpg`. | `timelapse/` neben `relay.py` | +| `MOTION_COOLDOWN` | Mindestabstand in Sekunden zwischen zwei Bewegungsbildern (Erkennung selbst je Kamera im Frontend, braucht Pillow). | `10` | | `TIMELAPSE_KEEP_DAYS` | Tage aufbewahren, ältere Tagesordner werden gelöscht (0 = unbegrenzt). | `0` | | `PORT` | Port, auf dem der Relay lauscht | `8000` | | `AUTH_USER` / `AUTH_PASS` | Optional: Basic-Auth. Nur sinnvoll mit HTTPS (z. B. in Zoraxy). | aus | @@ -109,10 +111,10 @@ journalctl -u webcam-relay -f | `/status.json` | Online-Status aller Kameras (wird vom Frontend alle 5 s abgefragt) | | `//stream.mjpg` | Livestream (`multipart/x-mixed-replace`) | | `//snapshot.jpg` | Aktuelles Einzelbild | -| `POST //timelapse` | Timelapse steuern, Body `{"enabled":true,"interval":300}` | +| `POST //timelapse` | Timelapse und Bewegungserkennung steuern, Body z. B. `{"enabled":true,"interval":300,"motion":true,"sensitivity":"mid"}` | | `//timelapse.json` | Gespeicherte Timelapse-Bilder je Tag | | `//timelapse//.jpg` | Einzelnes Timelapse-Bild | -| `//timelapse//video?fps=10` | Tagesvideo als Download (MP4 mit ffmpeg, sonst MJPEG-AVI); `fps` 1–60 | +| `//timelapse//video?fps=10` | Tagesvideo als Download (MP4 mit ffmpeg, sonst MJPEG-AVI); `fps` 1–60, mit `&kind=motion` aus den Bewegungsbildern | | `/stream.mjpg`, `/snapshot.jpg` | Alias für die erste Kamera (Abwärtskompatibilität) | | `/health` | Status aller Kameras (`200` wenn alle liefern, sonst `503`); ohne Authentifizierung erreichbar | @@ -122,7 +124,7 @@ Das Frontend liegt in `frontend.html` (reines HTML/CSS/JS, keine Abhängigkeiten - Dunkles Design, zwei Kameras nebeneinander (auf dem Handy untereinander); Klick auf den Namen öffnet die große Ansicht - Statuspunkt und Hinweis „Kamera offline“ pro Kamera, automatisches Neuverbinden, Zähler „X / Y online“ -- Buttons pro Kamera: Start, Stop, Reload, Foto (Download), Vollbild; „Timelapse“ schaltet die Aufnahme je Kamera ein/aus, „Galerie“ zeigt die Bilder (Tag wählen, anklicken, abspielen; Intervall einstellbar) und lädt ein Tagesvideo herunter (5/10/25 fps) +- Buttons pro Kamera: Start, Stop, Reload, Foto (Download), Vollbild; „Timelapse“ schaltet die Aufnahme je Kamera ein/aus, „Galerie“ zeigt die Bilder (Tag wählen, anklicken, abspielen; Intervall einstellbar) und lädt ein Tagesvideo herunter (5/10/25 fps); „Bewegung“ schaltet die Bewegungserkennung je Kamera ein/aus – Bewegungsbilder (orange markiert) erscheinen in derselben Galerie und lassen sich filtern - Toolbar: Alle starten / stoppen / neu laden - „Setup“ pro Kamera und „Setup alle“, sofern `SETUP_` konfiguriert ist; das Relay ruft die URL auf, der Browser muss die Kamera nicht erreichen - Streams werden getrennt, solange der Tab im Hintergrund ist @@ -134,6 +136,7 @@ Das Frontend liegt in `frontend.html` (reines HTML/CSS/JS, keine Abhängigkeiten - Bei Verbindungsfehlern wird nach 3 Sekunden automatisch neu verbunden. - Alle Zuschauer bekommen jeweils das neueste Frame – die Kamera wird unabhängig von der Zuschauerzahl nur einmal belastet. - Der Header `X-Accel-Buffering: no` verhindert Pufferung durch den Proxy. +- Bewegungserkennung: Jede Sekunde wird das Frame auf 80×60 Graustufen verkleinert und mit einem langsam nachgeführten Referenzbild verglichen. Ändern sich mehr als 4 % / 2 % / 0,7 % der Pixel (Empfindlichkeit niedrig / mittel / hoch), wird das Bild als `HHMMSS_m.jpg` neben den Timelapse-Bildern gespeichert (danach `MOTION_COOLDOWN` Sekunden Pause). Das Nachführen des Referenzbilds fängt Tageslichtwechsel ab. ## Stream-Tool (`webcam.py`) diff --git a/frontend.html b/frontend.html index 140ec84..e7be407 100644 --- a/frontend.html +++ b/frontend.html @@ -78,6 +78,7 @@ main{padding:22px;max-width:1800px;margin:0 auto} #tl .big .when{position:absolute;left:12px;bottom:10px;background:rgba(0,0,0,.6);padding:2px 10px;border-radius:999px;font-size:13px;font-variant-numeric:tabular-nums} #tl .thumbs{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px} #tl .thumbs img{height:72px;aspect-ratio:16/9;object-fit:cover;border-radius:6px;border:2px solid transparent;cursor:pointer;flex:none;background:#000} +#tl .thumbs img.mot{border-color:#f59e0b} #tl .thumbs img.sel{border-color:var(--accent)} #tl .empty{color:var(--muted);margin:auto} @media (max-width:700px){.grid{grid-template-columns:1fr}} @@ -125,6 +126,7 @@ const ICONS = { reload: '', full: '', down: '', + motion: '', rec: '', film: '', off: '' @@ -180,7 +182,7 @@ function makeTile(name, mode, hasSetup) { }, "alt"); actions.append(setup); } - const o = { name, mode, t, img, state, ovText, rec: null, tl: { enabled: false, interval: 300 }, stopped: false, online: null, timer: null, retry: null }; + const o = { name, mode, t, img, state, ovText, rec: null, mot: null, tl: { enabled: false, interval: 300 }, stopped: false, online: null, timer: null, retry: null }; if (mode === "live") { actions.append( btn("Start", "play", () => startTile(o), "ok"), @@ -188,9 +190,11 @@ function makeTile(name, mode, hasSetup) { btn("Reload", "reload", () => reloadTile(o), "alt")); } const rec = btn("Timelapse aus", "rec", () => setTimelapse(name, { enabled: !tiles[name].tl.enabled }), "alt"); + const mot = btn("Bewegung aus", "motion", () => setTimelapse(name, { motion: !tiles[name].tl.motion }), "alt"); const gallery = btn("Galerie", "film", () => openTimelapse(name), "alt"); o.rec = rec; - actions.append(snap, rec, gallery, full); + o.mot = mot; + actions.append(snap, rec, mot, gallery, full); cap.append(el("span", "dot"), link, state, el("span", "sp"), actions); t.append(view, cap); @@ -219,12 +223,21 @@ async function openTimelapse(name) { TL_INTERVALS.forEach(v => ivSel.append(new Option("alle " + fmtInterval(v), v))); ivSel.value = tiles[name].tl.interval; ivSel.onchange = () => setTimelapse(name, { interval: +ivSel.value }); + const sensSel = el("select"); + sensSel.title = "Empfindlichkeit der Bewegungserkennung"; + [["low", "Bewegung: niedrig"], ["mid", "Bewegung: mittel"], ["high", "Bewegung: hoch"]].forEach(([v, t]) => sensSel.append(new Option(t, v))); + sensSel.value = tiles[name].tl.sensitivity; + sensSel.onchange = () => setTimelapse(name, { sensitivity: sensSel.value }); + sensSel.hidden = !tiles[name].motionOk; + const kindSel = el("select"); + kindSel.title = "Anzeige filtern"; + [["all", "Alle Bilder"], ["lapse", "Nur Zeitraffer"], ["motion", "Nur Bewegung"]].forEach(([v, t]) => kindSel.append(new Option(t, v))); const fpsSel = el("select"); fpsSel.title = "Bilder pro Sekunde im Video"; [5, 10, 25].forEach(v => fpsSel.append(new Option(v + " fps", v))); fpsSel.value = 10; const video = btn("Video", "down", () => {}, "alt"); - bar.append(el("h2", "", "Timelapse " + name), daySel, play, ivSel, fpsSel, video, el("span", "sp"), close); + bar.append(el("h2", "", "Timelapse " + name), daySel, kindSel, play, ivSel, sensSel, fpsSel, video, el("span", "sp"), close); bar.querySelector(".sp").style.flex = "1"; const body = el("div", "body"); tl.replaceChildren(bar, body); @@ -240,7 +253,7 @@ async function openTimelapse(name) { } const days = Object.keys(data.days).filter(d => data.days[d].length); if (!days.length) { - body.append(el("div", "empty", "Noch keine Bilder – Timelapse mit dem Button in der Kachel starten")); + body.append(el("div", "empty", "Noch keine Bilder – Timelapse oder Bewegungserkennung in der Kachel starten")); play.hidden = daySel.hidden = true; return; } @@ -255,7 +268,7 @@ async function openTimelapse(name) { idx = (i + files.length) % files.length; const d = daySel.value, f = files[idx]; bigImg.src = url(d, f); - when.textContent = d + " " + f.slice(0, 2) + ":" + f.slice(2, 4) + ":" + f.slice(4, 6); + when.textContent = d + " " + f.slice(0, 2) + ":" + f.slice(2, 4) + ":" + f.slice(4, 6) + (f.includes("_m") ? " · Bewegung" : ""); thumbs.querySelectorAll("img").forEach((im, k) => im.classList.toggle("sel", k === idx)); const sel = thumbs.children[idx]; if (sel) sel.scrollIntoView({ block: "nearest", inline: "center" }); @@ -263,9 +276,15 @@ async function openTimelapse(name) { const stop = () => { clearInterval(tlPlay); play.querySelector("span").textContent = "Abspielen"; }; const loadDay = () => { stop(); - files = data.days[daySel.value]; + files = data.days[daySel.value].filter(f => kindSel.value === "all" || (kindSel.value === "motion") === f.includes("_m")); + if (!files.length) { + thumbs.replaceChildren(); + bigImg.removeAttribute("src"); + when.textContent = "Keine Bilder dieser Art an diesem Tag"; + return; + } thumbs.replaceChildren(...files.map((f, i) => { - const im = el("img"); + const im = el("img", f.includes("_m") ? "mot" : ""); im.loading = "lazy"; im.src = url(daySel.value, f); im.onclick = () => { stop(); show(i); }; @@ -274,12 +293,13 @@ async function openTimelapse(name) { show(files.length - 1); }; daySel.onchange = loadDay; + kindSel.onchange = loadDay; video.onclick = async () => { const label = video.querySelector("span"); video.disabled = true; label.textContent = "erstelle …"; try { - const r = await fetch(url(daySel.value, "video") + "?fps=" + fpsSel.value); + const r = await fetch(url(daySel.value, "video") + "?fps=" + fpsSel.value + (kindSel.value === "motion" ? "&kind=motion" : "")); if (!r.ok) throw new Error(r.status); const a = el("a"); a.href = URL.createObjectURL(await r.blob()); @@ -294,6 +314,7 @@ async function openTimelapse(name) { video.disabled = false; }; play.onclick = () => { + if (!files.length) return; if (tlPlay) { const was = play.querySelector("span").textContent === "Pause"; stop(); if (was) return; } play.querySelector("span").textContent = "Pause"; if (idx === files.length - 1) show(0); @@ -311,6 +332,11 @@ function renderTimelapse(o) { o.rec.classList.toggle("alt", !on); o.rec.title = on ? "Timelapse läuft (alle " + fmtInterval(o.tl.interval) + ") – klicken zum Stoppen" : "Timelapse starten"; o.rec.querySelector("span").textContent = on ? "Timelapse " + fmtInterval(o.tl.interval) : "Timelapse aus"; + o.mot.hidden = !o.motionOk; + o.mot.classList.toggle("rec", !!o.tl.motion); + o.mot.classList.toggle("alt", !o.tl.motion); + o.mot.title = o.tl.motion ? "Bewegungserkennung läuft – klicken zum Stoppen" : "Bewegungserkennung starten"; + o.mot.querySelector("span").textContent = o.tl.motion ? "Bewegung an" : "Bewegung aus"; } async function setTimelapse(name, change) { @@ -456,6 +482,7 @@ async function poll() { data.cameras.forEach(c => { setOnline(tiles[c.name], c.online); tiles[c.name].tl = c.timelapse; + tiles[c.name].motionOk = c.motion_ok; renderTimelapse(tiles[c.name]); }); const n = data.cameras.filter(c => c.online).length; diff --git a/relay.py b/relay.py index 9316b45..eab317c 100644 --- a/relay.py +++ b/relay.py @@ -23,12 +23,15 @@ Konfiguration über die Datei .env (neben relay.py) oder Umgebungsvariablen: 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 @@ -42,6 +45,11 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import requests +try: + from PIL import Image +except ImportError: # Bewegungserkennung ist optional + Image = None + def load_env(path): @@ -72,7 +80,9 @@ 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$") +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") @@ -149,7 +159,10 @@ def load_tl_state(): 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} + "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() @@ -165,9 +178,24 @@ def save_tl_state(): 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 - nach TL_DIR///.jpg.""" + """Speichert, solange für die Kamera aktiviert, im Intervall das aktuelle Bild.""" last_seq, last_t = 0, 0.0 while True: time.sleep(1) @@ -178,18 +206,42 @@ def timelapse(name, hub): 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)) + 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: - 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) + 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): @@ -245,11 +297,12 @@ def write_avi(path, files, fps): video_lock = threading.Lock() -def make_video(name, day, fps): +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)) + 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: @@ -259,7 +312,7 @@ def make_video(name, day, fps): 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, "*.jpg"), + "-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) @@ -324,7 +377,8 @@ class Handler(BaseHTTPRequestHandler): 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]} + "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"]: @@ -338,7 +392,7 @@ class Handler(BaseHTTPRequestHandler): 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) + 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") @@ -349,7 +403,7 @@ class Handler(BaseHTTPRequestHandler): 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]}.{ext}"') + 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) @@ -396,6 +450,10 @@ class Handler(BaseHTTPRequestHandler): 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() @@ -444,6 +502,9 @@ 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()