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_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
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in new issue
Block a user