Setup-Button pro Kamera: Relay ruft SETUP_<name>-URL auf

This commit is contained in:
Joachim Hummel committed 2026-10-17 21:11:24 +02:00
1 parent 146ba6c399
commit 70b63cb215
4 files changed
+50 -7

No files matched your search

+3
View File
@@ -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
+1
View File
@@ -29,6 +29,7 @@ cp .env.example .env
| Variable | Beschreibung | Standard |
|---|---|---|
| `CAMERA_<name>` | Stream-URL einer Kamera, eine Zeile pro Kamera. Der Name wird Teil der URL (`/cam1/`). | – (mindestens eine nötig) |
| `SETUP_<name>` | 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 |
+25 -6
View File
@@ -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: '<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/>',
cog: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
full: '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>',
off: '<path d="M1 1l22 22M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v9.34m-7.72-2.06a4 4 0 1 1-5.56-5.56"/>'
};
@@ -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 = "<b>" + n + "</b> / " + data.cameras.length + " online";
+21 -1
View File
@@ -8,11 +8,14 @@ Endpunkte:
/status.json Status aller Kameras (vom Frontend abgefragt)
/<name>/stream.mjpg Livestream (multipart/x-mixed-replace)
/<name>/snapshot.jpg aktuelles Einzelbild
POST /<name>/setup ruft die in SETUP_<name> 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_<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
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_<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", "")
@@ -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}")