diff --git a/.env.example b/.env.example index ba31a4e..199abcd 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ CAMERA_cam1=http://IP:PORT/pfad CAMERA_cam2=http://IP:PORT/pfad CAMERA_cam3=http://IP:PORT/pfad +# Optional: Konfigurations-URL pro Kamera (Button "Setup" im Frontend), z.B. ESP32-CAM Auflösung +#SETUP_cam1=http://IP/control?var=framesize&val=11 + PORT=8000 # Optional: Login (nur sinnvoll mit HTTPS in Zoraxy) #AUTH_USER=joachim diff --git a/README.md b/README.md index cc1d68f..540888c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ cp .env.example .env | Variable | Beschreibung | Standard | |---|---|---| | `CAMERA_` | Stream-URL einer Kamera, eine Zeile pro Kamera. Der Name wird Teil der URL (`/cam1/`). | – (mindestens eine nötig) | +| `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. | – | | `PORT` | Port, auf dem der Relay lauscht | `8000` | | `AUTH_USER` / `AUTH_PASS` | Optional: Basic-Auth. Nur sinnvoll mit HTTPS (z. B. in Zoraxy). | aus | diff --git a/frontend.html b/frontend.html index 2f3b771..cb10bd0 100644 --- a/frontend.html +++ b/frontend.html @@ -44,6 +44,7 @@ main{padding:22px;max-width:1800px;margin:0 auto} .btn svg{width:15px;height:15px} .btn:hover{filter:brightness(1.15);box-shadow:0 4px 16px rgba(139,92,246,.4)} .btn:active{transform:scale(.95)} +.btn:disabled{opacity:.7;cursor:default} .btn.alt{background:#222a3a;box-shadow:none;color:var(--fg);border:1px solid var(--line)} .btn.alt:hover{border-color:var(--accent);color:var(--accent)} .focus .tile.big .view{aspect-ratio:auto;height:min(75vh,56.25vw)} @@ -81,6 +82,7 @@ const el = (tag, cls, text) => { }; const ICONS = { cam: '', + cog: '', full: '', off: '' }; @@ -88,7 +90,7 @@ const icon = k => { const s = document.createElementNS("http://www.w3.org/2000/s const btn = (label, ic, fn, cls) => { const b = el("button", "btn" + (cls ? " " + cls : "")); b.title = label; b.append(icon(ic), el("span", "", label)); b.onclick = fn; return b; }; // mode: "live" = MJPEG-Stream, "thumb" = Einzelbild im Intervall -function makeTile(name, mode) { +function makeTile(name, mode, hasSetup) { const t = el("figure", "tile"); const view = el("div", "view"); const img = el("img"); @@ -109,6 +111,21 @@ function makeTile(name, mode) { a.click(); }); const full = btn("Vollbild", "full", () => document.fullscreenElement ? document.exitFullscreen() : t.requestFullscreen(), "alt"); + if (hasSetup) { + const setup = btn("Setup", "cog", async () => { + setup.disabled = true; + const label = setup.querySelector("span"); + try { + const r = await fetch(ROOT + name + "/setup", { method: "POST" }); + if (!r.ok) throw new Error(r.status); + label.textContent = "Gesendet ✓"; + } catch (e) { + label.textContent = "Fehler ✗"; + } + setTimeout(() => { label.textContent = "Setup"; setup.disabled = false; }, 2500); + }, "alt"); + actions.append(setup); + } actions.append(snap, full); cap.append(el("span", "dot"), link, state, el("span", "sp"), actions); t.append(view, cap); @@ -145,20 +162,22 @@ function setOnline(o, on) { o.online = on; } -function build(names) { +function build(cams) { + const names = cams.map(c => c.name); + const setupOf = n => cams.find(c => c.name === n).setup; order = names; app.replaceChildren(); if (CURRENT && names.includes(CURRENT)) { app.className = "focus"; - const big = makeTile(CURRENT, "live"); + const big = makeTile(CURRENT, "live", setupOf(CURRENT)); big.classList.add("big"); app.append(big); const strip = el("div", "strip"); - names.filter(n => n !== CURRENT).forEach(n => strip.append(makeTile(n, "thumb"))); + names.filter(n => n !== CURRENT).forEach(n => strip.append(makeTile(n, "thumb", false))); if (strip.children.length) app.append(strip); } else { const grid = el("div", "grid"); - names.forEach(n => grid.append(makeTile(n, "live"))); + names.forEach(n => grid.append(makeTile(n, "live", setupOf(n)))); app.append(grid); } } @@ -170,7 +189,7 @@ async function poll() { const data = await r.json(); banner.style.display = "none"; const names = data.cameras.map(c => c.name); - if (names.join() !== order.join()) build(names); + if (names.join() !== order.join()) build(data.cameras); data.cameras.forEach(c => setOnline(tiles[c.name], c.online)); const n = data.cameras.filter(c => c.online).length; document.getElementById("count").innerHTML = "" + n + " / " + data.cameras.length + " online"; diff --git a/relay.py b/relay.py index c2fa08a..d863c78 100644 --- a/relay.py +++ b/relay.py @@ -8,11 +8,14 @@ Endpunkte: /status.json Status aller Kameras (vom Frontend abgefragt) //stream.mjpg Livestream (multipart/x-mixed-replace) //snapshot.jpg aktuelles Einzelbild + POST //setup ruft die in SETUP_ hinterlegte Kamera-URL auf /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 PORT (default 8000) AUTH_USER / AUTH_PASS optional: Basic-Auth (nur sinnvoll mit HTTPS in Zoraxy) """ @@ -45,6 +48,8 @@ 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", "") @@ -148,7 +153,7 @@ class Handler(BaseHTTPRequestHandler): 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)} + body = json.dumps({"cameras": [{"name": n, "online": is_online(h), "setup": n in SETUPS} for n, h in hubs.items()]}).encode() return self.send_bytes(200, "application/json", body) if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]: @@ -170,6 +175,21 @@ class Handler(BaseHTTPRequestHandler): 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 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}")