Setup-Button pro Kamera: Relay ruft SETUP_<name>-URL auf
This commit is contained in:
1 parent
146ba6c399
commit
70b63cb215
4 files changed
+50
-7
No files matched your search
@@ -4,6 +4,9 @@ CAMERA_cam1=http://IP:PORT/pfad
|
|||||||
CAMERA_cam2=http://IP:PORT/pfad
|
CAMERA_cam2=http://IP:PORT/pfad
|
||||||
CAMERA_cam3=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
|
PORT=8000
|
||||||
# Optional: Login (nur sinnvoll mit HTTPS in Zoraxy)
|
# Optional: Login (nur sinnvoll mit HTTPS in Zoraxy)
|
||||||
#AUTH_USER=joachim
|
#AUTH_USER=joachim
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ cp .env.example .env
|
|||||||
| Variable | Beschreibung | Standard |
|
| Variable | Beschreibung | Standard |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CAMERA_<name>` | Stream-URL einer Kamera, eine Zeile pro Kamera. Der Name wird Teil der URL (`/cam1/`). | – (mindestens eine nötig) |
|
| `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` |
|
| `PORT` | Port, auf dem der Relay lauscht | `8000` |
|
||||||
| `AUTH_USER` / `AUTH_PASS` | Optional: Basic-Auth. Nur sinnvoll mit HTTPS (z. B. in Zoraxy). | aus |
|
| `AUTH_USER` / `AUTH_PASS` | Optional: Basic-Auth. Nur sinnvoll mit HTTPS (z. B. in Zoraxy). | aus |
|
||||||
|
|
||||||
|
|||||||
+25
-6
@@ -44,6 +44,7 @@ main{padding:22px;max-width:1800px;margin:0 auto}
|
|||||||
.btn svg{width:15px;height:15px}
|
.btn svg{width:15px;height:15px}
|
||||||
.btn:hover{filter:brightness(1.15);box-shadow:0 4px 16px rgba(139,92,246,.4)}
|
.btn:hover{filter:brightness(1.15);box-shadow:0 4px 16px rgba(139,92,246,.4)}
|
||||||
.btn:active{transform:scale(.95)}
|
.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{background:#222a3a;box-shadow:none;color:var(--fg);border:1px solid var(--line)}
|
||||||
.btn.alt:hover{border-color:var(--accent);color:var(--accent)}
|
.btn.alt:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
.focus .tile.big .view{aspect-ratio:auto;height:min(75vh,56.25vw)}
|
.focus .tile.big .view{aspect-ratio:auto;height:min(75vh,56.25vw)}
|
||||||
@@ -81,6 +82,7 @@ const el = (tag, cls, text) => {
|
|||||||
};
|
};
|
||||||
const ICONS = {
|
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"/>',
|
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"/>',
|
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"/>'
|
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; };
|
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
|
// mode: "live" = MJPEG-Stream, "thumb" = Einzelbild im Intervall
|
||||||
function makeTile(name, mode) {
|
function makeTile(name, mode, hasSetup) {
|
||||||
const t = el("figure", "tile");
|
const t = el("figure", "tile");
|
||||||
const view = el("div", "view");
|
const view = el("div", "view");
|
||||||
const img = el("img");
|
const img = el("img");
|
||||||
@@ -109,6 +111,21 @@ function makeTile(name, mode) {
|
|||||||
a.click();
|
a.click();
|
||||||
});
|
});
|
||||||
const full = btn("Vollbild", "full", () => document.fullscreenElement ? document.exitFullscreen() : t.requestFullscreen(), "alt");
|
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);
|
actions.append(snap, full);
|
||||||
cap.append(el("span", "dot"), link, state, el("span", "sp"), actions);
|
cap.append(el("span", "dot"), link, state, el("span", "sp"), actions);
|
||||||
t.append(view, cap);
|
t.append(view, cap);
|
||||||
@@ -145,20 +162,22 @@ function setOnline(o, on) {
|
|||||||
o.online = 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;
|
order = names;
|
||||||
app.replaceChildren();
|
app.replaceChildren();
|
||||||
if (CURRENT && names.includes(CURRENT)) {
|
if (CURRENT && names.includes(CURRENT)) {
|
||||||
app.className = "focus";
|
app.className = "focus";
|
||||||
const big = makeTile(CURRENT, "live");
|
const big = makeTile(CURRENT, "live", setupOf(CURRENT));
|
||||||
big.classList.add("big");
|
big.classList.add("big");
|
||||||
app.append(big);
|
app.append(big);
|
||||||
const strip = el("div", "strip");
|
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);
|
if (strip.children.length) app.append(strip);
|
||||||
} else {
|
} else {
|
||||||
const grid = el("div", "grid");
|
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);
|
app.append(grid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +189,7 @@ async function poll() {
|
|||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
banner.style.display = "none";
|
banner.style.display = "none";
|
||||||
const names = data.cameras.map(c => c.name);
|
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));
|
data.cameras.forEach(c => setOnline(tiles[c.name], c.online));
|
||||||
const n = data.cameras.filter(c => c.online).length;
|
const n = data.cameras.filter(c => c.online).length;
|
||||||
document.getElementById("count").innerHTML = "<b>" + n + "</b> / " + data.cameras.length + " online";
|
document.getElementById("count").innerHTML = "<b>" + n + "</b> / " + data.cameras.length + " online";
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ Endpunkte:
|
|||||||
/status.json Status aller Kameras (vom Frontend abgefragt)
|
/status.json Status aller Kameras (vom Frontend abgefragt)
|
||||||
/<name>/stream.mjpg Livestream (multipart/x-mixed-replace)
|
/<name>/stream.mjpg Livestream (multipart/x-mixed-replace)
|
||||||
/<name>/snapshot.jpg aktuelles Einzelbild
|
/<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)
|
/stream.mjpg Alias für die erste Kamera (Abwärtskompatibilität)
|
||||||
/health Status aller Kameras
|
/health Status aller Kameras
|
||||||
|
|
||||||
Konfiguration über die Datei .env (neben relay.py) oder Umgebungsvariablen:
|
Konfiguration über die Datei .env (neben relay.py) oder Umgebungsvariablen:
|
||||||
CAMERA_<name>=<url> eine Zeile pro Kamera, z.B. CAMERA_cam1=http://...
|
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)
|
PORT (default 8000)
|
||||||
AUTH_USER / AUTH_PASS optional: Basic-Auth (nur sinnvoll mit HTTPS in Zoraxy)
|
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}
|
CAMERAS = {k[7:].lower(): v for k, v in os.environ.items() if k.startswith("CAMERA_") and v}
|
||||||
if not CAMERAS:
|
if not CAMERAS:
|
||||||
raise SystemExit("Keine Kameras konfiguriert: CAMERA_<name>=<url> in .env eintragen")
|
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"))
|
PORT = int(os.environ.get("PORT", "8000"))
|
||||||
AUTH_USER = os.environ.get("AUTH_USER", "")
|
AUTH_USER = os.environ.get("AUTH_USER", "")
|
||||||
AUTH_PASS = os.environ.get("AUTH_PASS", "")
|
AUTH_PASS = os.environ.get("AUTH_PASS", "")
|
||||||
@@ -148,7 +153,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not parts:
|
if not parts:
|
||||||
return self.send_bytes(200, "text/html; charset=utf-8", page("", ""))
|
return self.send_bytes(200, "text/html; charset=utf-8", page("", ""))
|
||||||
if parts == ["status.json"]:
|
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()
|
for n, h in hubs.items()]}).encode()
|
||||||
return self.send_bytes(200, "application/json", body)
|
return self.send_bytes(200, "application/json", body)
|
||||||
if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]:
|
if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]:
|
||||||
@@ -170,6 +175,21 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.stream(hub)
|
return self.stream(hub)
|
||||||
self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
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):
|
def stream(self, hub):
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", f"multipart/x-mixed-replace; boundary={BOUNDARY}")
|
self.send_header("Content-Type", f"multipart/x-mixed-replace; boundary={BOUNDARY}")
|
||||||
|
|||||||
Reference in new issue
Block a user