Timelapse pro Kamera (Ein/Aus, Intervall) im Relay, Galerie im Frontend
This commit is contained in:
1 parent
0b2837f0c3
commit
6b2cc45550
5 files changed
+260
-6
No files matched your search
@@ -7,6 +7,12 @@ CAMERA_cam3=http://IP:PORT/pfad
|
|||||||
# Optional: Konfigurations-URL pro Kamera (Button "Setup" im Frontend), z.B. ESP32-CAM Auflösung
|
# Optional: Konfigurations-URL pro Kamera (Button "Setup" im Frontend), z.B. ESP32-CAM Auflösung
|
||||||
#SETUP_cam2=http://IP/control?var=framesize&val=11
|
#SETUP_cam2=http://IP/control?var=framesize&val=11
|
||||||
|
|
||||||
|
# Optional: Timelapse. Ein/aus und Intervall je Kamera im Frontend; hier nur Standard-Intervall (Sek.), Ordner, Aufbewahrung
|
||||||
|
#TIMELAPSE_INTERVAL=300
|
||||||
|
# Zielordner (default: timelapse/ neben relay.py; eigener Pfad muss für den Dienstbenutzer beschreibbar sein)
|
||||||
|
#TIMELAPSE_DIR=/pfad/zum/ordner
|
||||||
|
#TIMELAPSE_KEEP_DAYS=30
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
.env
|
.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
timelapse/
|
||||||
@@ -52,6 +52,9 @@ cp .env.example .env
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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. | – |
|
| `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. | – |
|
||||||
|
| `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 `<name>/<JJJJ-MM-TT>/<HHMMSS>.jpg`. | `timelapse/` neben `relay.py` |
|
||||||
|
| `TIMELAPSE_KEEP_DAYS` | Tage aufbewahren, ältere Tagesordner werden gelöscht (0 = unbegrenzt). | `0` |
|
||||||
| `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 |
|
||||||
|
|
||||||
@@ -105,6 +108,9 @@ journalctl -u webcam-relay -f
|
|||||||
| `/status.json` | Online-Status aller Kameras (wird vom Frontend alle 5 s abgefragt) |
|
| `/status.json` | Online-Status aller Kameras (wird vom Frontend alle 5 s 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>/timelapse` | Timelapse steuern, Body `{"enabled":true,"interval":300}` |
|
||||||
|
| `/<name>/timelapse.json` | Gespeicherte Timelapse-Bilder je Tag |
|
||||||
|
| `/<name>/timelapse/<tag>/<datei>.jpg` | Einzelnes Timelapse-Bild |
|
||||||
| `/stream.mjpg`, `/snapshot.jpg` | Alias für die erste Kamera (Abwärtskompatibilität) |
|
| `/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 |
|
| `/health` | Status aller Kameras (`200` wenn alle liefern, sonst `503`); ohne Authentifizierung erreichbar |
|
||||||
|
|
||||||
@@ -114,7 +120,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
|
- 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“
|
- Statuspunkt und Hinweis „Kamera offline“ pro Kamera, automatisches Neuverbinden, Zähler „X / Y online“
|
||||||
- Buttons pro Kamera: Start, Stop, Reload, Foto (Download), Vollbild
|
- 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)
|
||||||
- Toolbar: Alle starten / stoppen / neu laden
|
- Toolbar: Alle starten / stoppen / neu laden
|
||||||
- „Setup“ pro Kamera und „Setup alle“, sofern `SETUP_<name>` konfiguriert ist; das Relay ruft die URL auf, der Browser muss die Kamera nicht erreichen
|
- „Setup“ pro Kamera und „Setup alle“, sofern `SETUP_<name>` konfiguriert ist; das Relay ruft die URL auf, der Browser muss die Kamera nicht erreichen
|
||||||
- Streams werden getrennt, solange der Tab im Hintergrund ist
|
- Streams werden getrennt, solange der Tab im Hintergrund ist
|
||||||
|
|||||||
+123
-3
@@ -55,6 +55,7 @@ main{padding:22px;max-width:1800px;margin:0 auto}
|
|||||||
.btn.danger:hover{box-shadow:0 4px 16px rgba(239,68,68,.45)}
|
.btn.danger:hover{box-shadow:0 4px 16px rgba(239,68,68,.45)}
|
||||||
.btn.ok{background:linear-gradient(135deg,#22c55e,#15803d);box-shadow:0 2px 10px rgba(34,197,94,.25)}
|
.btn.ok{background:linear-gradient(135deg,#22c55e,#15803d);box-shadow:0 2px 10px rgba(34,197,94,.25)}
|
||||||
.btn.ok:hover{box-shadow:0 4px 16px rgba(34,197,94,.45)}
|
.btn.ok:hover{box-shadow:0 4px 16px rgba(34,197,94,.45)}
|
||||||
|
.btn.rec{background:linear-gradient(135deg,#ef4444,#b91c1c);color:#fff;border:0}
|
||||||
.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)}
|
||||||
.grid .cap{cursor:grab}
|
.grid .cap{cursor:grab}
|
||||||
@@ -67,6 +68,18 @@ main{padding:22px;max-width:1800px;margin:0 auto}
|
|||||||
.strip .btn{padding:5px 9px}
|
.strip .btn{padding:5px 9px}
|
||||||
.tile:fullscreen{border:0;border-radius:0;display:flex;flex-direction:column;background:#000}
|
.tile:fullscreen{border:0;border-radius:0;display:flex;flex-direction:column;background:#000}
|
||||||
.tile:fullscreen .view{flex:1;aspect-ratio:auto}
|
.tile:fullscreen .view{flex:1;aspect-ratio:auto}
|
||||||
|
#tl{position:fixed;inset:0;z-index:20;background:rgba(5,7,11,.92);display:flex;flex-direction:column;padding:16px;gap:12px}
|
||||||
|
#tl .bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
|
||||||
|
#tl .bar h2{margin:0;font-size:16px}
|
||||||
|
#tl select{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:9px;padding:6px 10px;font:inherit}
|
||||||
|
#tl .body{flex:1;min-height:0;display:flex;flex-direction:column;gap:12px}
|
||||||
|
#tl .big{flex:1;min-height:0;background:#000;border-radius:var(--r);display:flex;align-items:center;justify-content:center;position:relative}
|
||||||
|
#tl .big img{max-width:100%;max-height:100%;object-fit:contain}
|
||||||
|
#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.sel{border-color:var(--accent)}
|
||||||
|
#tl .empty{color:var(--muted);margin:auto}
|
||||||
@media (max-width:700px){.grid{grid-template-columns:1fr}}
|
@media (max-width:700px){.grid{grid-template-columns:1fr}}
|
||||||
@media (max-width:600px){header{padding:12px 14px}main{padding:12px}.btn span{display:none}.btn{padding:7px 10px}}
|
@media (max-width:600px){header{padding:12px 14px}main{padding:12px}.btn span{display:none}.btn{padding:7px 10px}}
|
||||||
</style>
|
</style>
|
||||||
@@ -87,6 +100,7 @@ main{padding:22px;max-width:1800px;margin:0 auto}
|
|||||||
<button class="btn alt" id="setupAll" hidden><span>Setup alle</span></button>
|
<button class="btn alt" id="setupAll" hidden><span>Setup alle</span></button>
|
||||||
</section>
|
</section>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<div id="tl" hidden></div>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const ROOT = __ROOT__, CURRENT = __CURRENT__;
|
const ROOT = __ROOT__, CURRENT = __CURRENT__;
|
||||||
@@ -110,6 +124,8 @@ const ICONS = {
|
|||||||
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
|
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
|
||||||
reload: '<path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/>',
|
reload: '<path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/>',
|
||||||
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"/>',
|
||||||
|
rec: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="currentColor"/>',
|
||||||
|
film: '<rect x="2" y="3" width="20" height="18" rx="2"/><path d="M7 3v18M17 3v18M2 8h5M2 16h5M17 8h5M17 16h5"/>',
|
||||||
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"/>'
|
||||||
};
|
};
|
||||||
const icon = k => { const s = document.createElementNS("http://www.w3.org/2000/svg", "svg"); s.setAttribute("viewBox", "0 0 24 24"); s.setAttribute("fill", "none"); s.setAttribute("stroke", "currentColor"); s.setAttribute("stroke-width", "2"); s.setAttribute("stroke-linecap", "round"); s.setAttribute("stroke-linejoin", "round"); s.innerHTML = ICONS[k]; return s; };
|
const icon = k => { const s = document.createElementNS("http://www.w3.org/2000/svg", "svg"); s.setAttribute("viewBox", "0 0 24 24"); s.setAttribute("fill", "none"); s.setAttribute("stroke", "currentColor"); s.setAttribute("stroke-width", "2"); s.setAttribute("stroke-linecap", "round"); s.setAttribute("stroke-linejoin", "round"); s.innerHTML = ICONS[k]; return s; };
|
||||||
@@ -163,14 +179,16 @@ function makeTile(name, mode, hasSetup) {
|
|||||||
}, "alt");
|
}, "alt");
|
||||||
actions.append(setup);
|
actions.append(setup);
|
||||||
}
|
}
|
||||||
const o = { name, mode, t, img, state, ovText, stopped: false, online: null, timer: null, retry: null };
|
const o = { name, mode, t, img, state, ovText, rec, tl: { enabled: false, interval: 300 }, stopped: false, online: null, timer: null, retry: null };
|
||||||
if (mode === "live") {
|
if (mode === "live") {
|
||||||
actions.append(
|
actions.append(
|
||||||
btn("Start", "play", () => startTile(o), "ok"),
|
btn("Start", "play", () => startTile(o), "ok"),
|
||||||
btn("Stop", "stop", () => stopTile(o), "danger"),
|
btn("Stop", "stop", () => stopTile(o), "danger"),
|
||||||
btn("Reload", "reload", () => reloadTile(o), "alt"));
|
btn("Reload", "reload", () => reloadTile(o), "alt"));
|
||||||
}
|
}
|
||||||
actions.append(snap, full);
|
const rec = btn("Timelapse aus", "rec", () => setTimelapse(name, { enabled: !tiles[name].tl.enabled }), "alt");
|
||||||
|
const gallery = btn("Galerie", "film", () => openTimelapse(name), "alt");
|
||||||
|
actions.append(snap, rec, gallery, 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);
|
||||||
|
|
||||||
@@ -183,6 +201,104 @@ function makeTile(name, mode, hasSetup) {
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timelapse-Galerie: Tag wählen, Bilder durchklicken oder abspielen
|
||||||
|
const tl = document.getElementById("tl");
|
||||||
|
let tlPlay = null;
|
||||||
|
function closeTimelapse() { clearInterval(tlPlay); tl.hidden = true; tl.replaceChildren(); }
|
||||||
|
document.addEventListener("keydown", e => { if (e.key === "Escape" && !tl.hidden) closeTimelapse(); });
|
||||||
|
|
||||||
|
async function openTimelapse(name) {
|
||||||
|
const bar = el("div", "bar");
|
||||||
|
const close = btn("Schließen", "stop", closeTimelapse, "alt");
|
||||||
|
const daySel = el("select");
|
||||||
|
const play = btn("Abspielen", "play", () => {}, "ok");
|
||||||
|
const ivSel = el("select");
|
||||||
|
ivSel.title = "Aufnahme-Intervall";
|
||||||
|
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 });
|
||||||
|
bar.append(el("h2", "", "Timelapse " + name), daySel, play, ivSel, el("span", "sp"), close);
|
||||||
|
bar.querySelector(".sp").style.flex = "1";
|
||||||
|
const body = el("div", "body");
|
||||||
|
tl.replaceChildren(bar, body);
|
||||||
|
tl.hidden = false;
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const r = await fetch(ROOT + name + "/timelapse.json", { cache: "no-store" });
|
||||||
|
if (!r.ok) throw new Error(r.status);
|
||||||
|
data = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
body.append(el("div", "empty", "Timelapse konnte nicht geladen werden"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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"));
|
||||||
|
play.hidden = daySel.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
days.forEach(d => daySel.append(new Option(d + " (" + data.days[d].length + ")", d)));
|
||||||
|
const big = el("div", "big"), bigImg = el("img"), when = el("span", "when");
|
||||||
|
big.append(bigImg, when);
|
||||||
|
const thumbs = el("div", "thumbs");
|
||||||
|
body.append(big, thumbs);
|
||||||
|
let files = [], idx = 0;
|
||||||
|
const url = (d, f) => ROOT + name + "/timelapse/" + d + "/" + f;
|
||||||
|
const show = i => {
|
||||||
|
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);
|
||||||
|
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" });
|
||||||
|
};
|
||||||
|
const stop = () => { clearInterval(tlPlay); play.querySelector("span").textContent = "Abspielen"; };
|
||||||
|
const loadDay = () => {
|
||||||
|
stop();
|
||||||
|
files = data.days[daySel.value];
|
||||||
|
thumbs.replaceChildren(...files.map((f, i) => {
|
||||||
|
const im = el("img");
|
||||||
|
im.loading = "lazy";
|
||||||
|
im.src = url(daySel.value, f);
|
||||||
|
im.onclick = () => { stop(); show(i); };
|
||||||
|
return im;
|
||||||
|
}));
|
||||||
|
show(files.length - 1);
|
||||||
|
};
|
||||||
|
daySel.onchange = loadDay;
|
||||||
|
play.onclick = () => {
|
||||||
|
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);
|
||||||
|
tlPlay = setInterval(() => { if (idx === files.length - 1) stop(); else show(idx + 1); }, 200);
|
||||||
|
};
|
||||||
|
loadDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtInterval = s => s >= 3600 ? s / 3600 + " h" : s >= 60 ? s / 60 + " min" : s + " s";
|
||||||
|
const TL_INTERVALS = [10, 30, 60, 300, 900, 1800, 3600];
|
||||||
|
|
||||||
|
function renderTimelapse(o) {
|
||||||
|
const on = o.tl.enabled;
|
||||||
|
o.rec.classList.toggle("rec", on);
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setTimelapse(name, change) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(ROOT + name + "/timelapse", { method: "POST", body: JSON.stringify(change) });
|
||||||
|
if (!r.ok) throw new Error(r.status);
|
||||||
|
tiles[name].tl = await r.json();
|
||||||
|
renderTimelapse(tiles[name]);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runSetup(name) {
|
async function runSetup(name) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(ROOT + name + "/setup", { method: "POST" });
|
const r = await fetch(ROOT + name + "/setup", { method: "POST" });
|
||||||
@@ -311,7 +427,11 @@ async function poll() {
|
|||||||
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(data.cameras);
|
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);
|
||||||
|
tiles[c.name].tl = c.timelapse;
|
||||||
|
renderTimelapse(tiles[c.name]);
|
||||||
|
});
|
||||||
const n = data.cameras.filter(c => c.online).length;
|
const n = data.cameras.filter(c => c.online).length;
|
||||||
setupNames = data.cameras.filter(c => c.setup).map(c => c.name);
|
setupNames = data.cameras.filter(c => c.setup).map(c => c.name);
|
||||||
setupAllBtn.hidden = !setupNames.length;
|
setupAllBtn.hidden = !setupNames.length;
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ 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>/timelapse Timelapse einer Kamera steuern, Body: {"enabled": true, "interval": 300}
|
||||||
POST /<name>/setup ruft die in SETUP_<name> hinterlegte Kamera-URL auf
|
POST /<name>/setup ruft die in SETUP_<name> hinterlegte Kamera-URL auf
|
||||||
|
/<name>/timelapse.json Liste der Timelapse-Bilder je Tag
|
||||||
|
/<name>/timelapse/<tag>/<datei> einzelnes Timelapse-Bild
|
||||||
/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
|
||||||
|
|
||||||
@@ -16,6 +19,10 @@ 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),
|
SETUP_<name>=<url> optional: Konfigurations-URL der Kamera (Button "Setup" im Frontend),
|
||||||
z.B. http://IP/control?var=framesize&val=11
|
z.B. http://IP/control?var=framesize&val=11
|
||||||
|
TIMELAPSE_INTERVAL=<sek> Standard-Intervall der Timelapse (default 300); ein/aus und Intervall
|
||||||
|
stellt man pro Kamera im Frontend ein
|
||||||
|
TIMELAPSE_DIR=<pfad> Zielordner (default: timelapse neben relay.py)
|
||||||
|
TIMELAPSE_KEEP_DAYS=<n> Tage aufbewahren (0 = unbegrenzt, default)
|
||||||
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)
|
||||||
"""
|
"""
|
||||||
@@ -23,6 +30,8 @@ import base64
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
@@ -54,6 +63,12 @@ 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", "")
|
||||||
BOUNDARY = "frame"
|
BOUNDARY = "frame"
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
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$")
|
||||||
|
|
||||||
FRONTEND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend.html")
|
FRONTEND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend.html")
|
||||||
|
|
||||||
@@ -118,6 +133,82 @@ def pull_camera(name, url, hub):
|
|||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
|
TL_STATE_FILE = os.path.join(TL_DIR, "state.json")
|
||||||
|
TL_INTERVALS = (10, 30, 60, 300, 900, 1800, 3600)
|
||||||
|
tl_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def load_tl_state():
|
||||||
|
try:
|
||||||
|
with open(TL_STATE_FILE, encoding="utf-8") as f:
|
||||||
|
saved = json.load(f)
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
tl_state = load_tl_state()
|
||||||
|
|
||||||
|
|
||||||
|
def save_tl_state():
|
||||||
|
try:
|
||||||
|
os.makedirs(TL_DIR, exist_ok=True)
|
||||||
|
with open(TL_STATE_FILE + ".tmp", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(tl_state, f)
|
||||||
|
os.replace(TL_STATE_FILE + ".tmp", TL_STATE_FILE)
|
||||||
|
except OSError as ex:
|
||||||
|
print(f"Timelapse-Zustand nicht gespeichert: {ex}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def timelapse(name, hub):
|
||||||
|
"""Speichert, solange für die Kamera aktiviert, im Intervall das aktuelle Bild
|
||||||
|
nach TL_DIR/<name>/<tag>/<HHMMSS>.jpg."""
|
||||||
|
last_seq, last_t = 0, 0.0
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
st = tl_state[name]
|
||||||
|
if not st["enabled"] or time.time() - last_t < st["interval"]:
|
||||||
|
continue
|
||||||
|
frame, seq = hub.frame, hub.seq
|
||||||
|
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))
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def prune(cam_dir):
|
||||||
|
"""Löscht Tagesordner, die älter als TL_KEEP_DAYS sind."""
|
||||||
|
limit = time.strftime("%Y-%m-%d", time.localtime(time.time() - TL_KEEP_DAYS * 86400))
|
||||||
|
for day in os.listdir(cam_dir):
|
||||||
|
if DAY_RE.match(day) and day < limit:
|
||||||
|
shutil.rmtree(os.path.join(cam_dir, day), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def timelapse_index(name):
|
||||||
|
"""{"interval": sek, "days": {tag: [dateien]}} aus dem Dateisystem."""
|
||||||
|
days = {}
|
||||||
|
cam_dir = os.path.join(TL_DIR, name)
|
||||||
|
try:
|
||||||
|
for day in sorted(os.listdir(cam_dir), reverse=True):
|
||||||
|
if DAY_RE.match(day):
|
||||||
|
days[day] = sorted(f for f in os.listdir(os.path.join(cam_dir, day)) if FILE_RE.match(f))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return {**tl_state[name], "days": days}
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
@@ -135,8 +226,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
self.send_header("Content-Type", ctype)
|
self.send_header("Content-Type", ctype)
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
extra = extra or {}
|
||||||
|
if "Cache-Control" not in extra:
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
for k, v in (extra or {}).items():
|
for k, v in extra.items():
|
||||||
self.send_header(k, v)
|
self.send_header(k, v)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
@@ -153,12 +246,24 @@ 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), "setup": n in SETUPS}
|
body = json.dumps({"cameras": [{"name": n, "online": is_online(h), "setup": n in SETUPS,
|
||||||
|
"timelapse": tl_state[n]}
|
||||||
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"]:
|
||||||
parts = [next(iter(CAMERAS))] + parts # Alias: erste Kamera
|
parts = [next(iter(CAMERAS))] + parts # Alias: erste Kamera
|
||||||
name = parts[0]
|
name = parts[0]
|
||||||
|
if name in hubs and len(parts) == 2 and parts[1] == "timelapse.json":
|
||||||
|
return self.send_bytes(200, "application/json", json.dumps(timelapse_index(name)).encode())
|
||||||
|
if name in hubs and len(parts) == 4 and parts[1] == "timelapse":
|
||||||
|
if not (DAY_RE.match(parts[2]) and FILE_RE.match(parts[3])):
|
||||||
|
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
||||||
|
try:
|
||||||
|
with open(os.path.join(TL_DIR, name, parts[2], parts[3]), "rb") as f:
|
||||||
|
body = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
||||||
|
return self.send_bytes(200, "image/jpeg", body, {"Cache-Control": "max-age=86400"})
|
||||||
if name not in hubs or len(parts) > 2:
|
if name not in hubs or len(parts) > 2:
|
||||||
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
||||||
hub = hubs[name]
|
hub = hubs[name]
|
||||||
@@ -180,6 +285,20 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.send_bytes(401, "text/plain", b"Login erforderlich",
|
return self.send_bytes(401, "text/plain", b"Login erforderlich",
|
||||||
{"WWW-Authenticate": 'Basic realm="Webcam"'})
|
{"WWW-Authenticate": 'Basic realm="Webcam"'})
|
||||||
parts = [p for p in self.path.split("?")[0].split("/") if p]
|
parts = [p for p in self.path.split("?")[0].split("/") if p]
|
||||||
|
if len(parts) == 2 and parts[1] == "timelapse" and parts[0] in hubs:
|
||||||
|
try:
|
||||||
|
n = int(self.headers.get("Content-Length", 0))
|
||||||
|
req = json.loads(self.rfile.read(n) or b"{}")
|
||||||
|
with tl_lock:
|
||||||
|
st = tl_state[parts[0]]
|
||||||
|
if "enabled" in req:
|
||||||
|
st["enabled"] = bool(req["enabled"])
|
||||||
|
if int(req.get("interval", st["interval"])) in TL_INTERVALS:
|
||||||
|
st["interval"] = int(req.get("interval", st["interval"]))
|
||||||
|
save_tl_state()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return self.send_bytes(400, "text/plain", b"Ungueltige Anfrage")
|
||||||
|
return self.send_bytes(200, "application/json", json.dumps(tl_state[parts[0]]).encode())
|
||||||
if len(parts) != 2 or parts[1] != "setup" or parts[0] not in SETUPS:
|
if len(parts) != 2 or parts[1] != "setup" or parts[0] not in SETUPS:
|
||||||
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
||||||
try:
|
try:
|
||||||
@@ -221,5 +340,7 @@ class Server(ThreadingHTTPServer):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for n, u in CAMERAS.items():
|
for n, u in CAMERAS.items():
|
||||||
threading.Thread(target=pull_camera, args=(n, u, hubs[n]), daemon=True).start()
|
threading.Thread(target=pull_camera, args=(n, u, hubs[n]), daemon=True).start()
|
||||||
|
threading.Thread(target=timelapse, args=(n, hubs[n]), daemon=True).start()
|
||||||
print(f"Relay läuft auf Port {PORT}, Kameras: {', '.join(CAMERAS)}", flush=True)
|
print(f"Relay läuft auf Port {PORT}, Kameras: {', '.join(CAMERAS)}", flush=True)
|
||||||
|
print(f"Timelapse-Ordner: {TL_DIR}", flush=True)
|
||||||
Server(("0.0.0.0", PORT), Handler).serve_forever()
|
Server(("0.0.0.0", PORT), Handler).serve_forever()
|
||||||
Reference in new issue
Block a user