Timelapse pro Kamera (Ein/Aus, Intervall) im Relay, Galerie im Frontend

This commit is contained in:
Joachim Hummel committed 2026-10-18 13:34:33 +02:00
1 parent 0b2837f0c3
commit 6b2cc45550
5 files changed
+261 -7

No files matched your search

+6
View File
@@ -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
View File
@@ -1,2 +1,3 @@
.env .env
__pycache__/ __pycache__/
timelapse/
+7 -1
View File
@@ -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
View File
@@ -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;
+124 -3
View File
@@ -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)))
self.send_header("Cache-Control", "no-store") extra = extra or {}
for k, v in (extra or {}).items(): if "Cache-Control" not in extra:
self.send_header("Cache-Control", "no-store")
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()