Timelapse pro Kamera (Ein/Aus, Intervall) im Relay, Galerie im Frontend
This commit is contained in:
1 parent
0b2837f0c3
commit
6b2cc45550
5 files changed
+261
-7
No files matched your search
@@ -8,7 +8,10 @@ Endpunkte:
|
||||
/status.json Status aller Kameras (vom Frontend abgefragt)
|
||||
/<name>/stream.mjpg Livestream (multipart/x-mixed-replace)
|
||||
/<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
|
||||
/<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)
|
||||
/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://...
|
||||
SETUP_<name>=<url> optional: Konfigurations-URL der Kamera (Button "Setup" im Frontend),
|
||||
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)
|
||||
AUTH_USER / AUTH_PASS optional: Basic-Auth (nur sinnvoll mit HTTPS in Zoraxy)
|
||||
"""
|
||||
@@ -23,6 +30,8 @@ import base64
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
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_PASS = os.environ.get("AUTH_PASS", "")
|
||||
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")
|
||||
|
||||
@@ -118,6 +133,82 @@ def pull_camera(name, url, hub):
|
||||
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):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
@@ -135,8 +226,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
for k, v in (extra or {}).items():
|
||||
extra = extra or {}
|
||||
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.end_headers()
|
||||
self.wfile.write(body)
|
||||
@@ -153,12 +246,24 @@ 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), "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()
|
||||
return self.send_bytes(200, "application/json", body)
|
||||
if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]:
|
||||
parts = [next(iter(CAMERAS))] + parts # Alias: erste Kamera
|
||||
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:
|
||||
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
||||
hub = hubs[name]
|
||||
@@ -180,6 +285,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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 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:
|
||||
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
||||
try:
|
||||
@@ -221,5 +340,7 @@ class Server(ThreadingHTTPServer):
|
||||
if __name__ == "__main__":
|
||||
for n, u in CAMERAS.items():
|
||||
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"Timelapse-Ordner: {TL_DIR}", flush=True)
|
||||
Server(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
Reference in new issue
Block a user