From 06253e3fef791e077df89dd6be3167b47c667e47 Mon Sep 17 00:00:00 2001 From: Christian Mueller Date: Fri, 24 Apr 2026 12:55:06 +0200 Subject: [PATCH] feat: backup system for portal DB and WLED configs Portal DB: - Download current DB button - Manual backup creation - Auto-backup daily at 02:00 (keeps last 14) - Download/delete individual backups WLED Config: - Backup all devices at once - Backup individual device - Download config as JSON - Restore config to any device - Delete old backups New sidebar menu item 'Backup' with dedicated page. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + app.py | 179 +++++++++++++++++++++++++++++++++++- templates/backup.html | 208 ++++++++++++++++++++++++++++++++++++++++++ templates/base.html | 5 + wled_client.py | 24 +++++ 5 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 templates/backup.html diff --git a/.gitignore b/.gitignore index 213ae35..99ef791 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ busylight.db config.cfg config.dev.cfg *.egg-info/ +backups/ dist/ build/ .vscode/ diff --git a/app.py b/app.py index 8b88bd1..547fff2 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,16 @@ #!/usr/bin/env python3 """Busylight WLED Controller - Web Application.""" +import json import logging +import os +import shutil import sys from contextlib import asynccontextmanager +from datetime import datetime -from fastapi import FastAPI, Request, Form -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi import FastAPI, Request, Form, UploadFile, File +from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from apscheduler.schedulers.background import BackgroundScheduler @@ -35,11 +39,16 @@ async def lifespan(application: FastAPI): except Exception as e: logger.error("Graph-Client konnte nicht initialisiert werden: %s", e) + # Backup-Verzeichnis erstellen + os.makedirs(BACKUP_DIR, exist_ok=True) + os.makedirs(WLED_BACKUP_DIR, exist_ok=True) + # Start background polling interval = int(database.get_setting("poll_interval", "15")) bg_scheduler.add_job(scheduler.poll_status, "interval", seconds=interval, id="poll") bg_scheduler.add_job(scheduler.check_devices, "interval", seconds=240, id="device_check") - # Initial device check nach 5 Sekunden (nicht blockierend) + bg_scheduler.add_job(auto_backup_db, "cron", hour=2, minute=0, id="auto_backup") + # Initial device check (nicht blockierend) bg_scheduler.add_job(scheduler.check_devices, "date", id="device_check_init") bg_scheduler.start() logger.info("Polling gestartet (Intervall: %ds)", interval) @@ -49,6 +58,25 @@ async def lifespan(application: FastAPI): bg_scheduler.shutdown() +BACKUP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backups") +WLED_BACKUP_DIR = os.path.join(BACKUP_DIR, "wled") + + +def auto_backup_db(): + """Create daily DB backup. Keeps last 14 backups.""" + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + dst = os.path.join(BACKUP_DIR, f"busylight_{ts}.db") + shutil.copy2(database.DB_PATH, dst) + # Keep only last 14 + backups = sorted( + [f for f in os.listdir(BACKUP_DIR) if f.startswith("busylight_") and f.endswith(".db")], + reverse=True, + ) + for old in backups[14:]: + os.remove(os.path.join(BACKUP_DIR, old)) + logger.info("Auto-Backup erstellt: %s", dst) + + app = FastAPI(title="Busylight Manager", lifespan=lifespan) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") @@ -473,6 +501,151 @@ async def trigger_poll(): return RedirectResponse("/", status_code=303) +# ── Backup ───────────────────────────────────────────────────────────────── + +@app.get("/backup", response_class=HTMLResponse) +async def backup_page(request: Request): + # Portal backups + db_backups = [] + if os.path.exists(BACKUP_DIR): + for f in sorted(os.listdir(BACKUP_DIR), reverse=True): + if f.startswith("busylight_") and f.endswith(".db"): + path = os.path.join(BACKUP_DIR, f) + size = os.path.getsize(path) / 1024 + db_backups.append({"name": f, "size": f"{size:.0f} KB"}) + + # WLED config backups + wled_backups = [] + if os.path.exists(WLED_BACKUP_DIR): + for f in sorted(os.listdir(WLED_BACKUP_DIR), reverse=True): + if f.endswith(".json"): + path = os.path.join(WLED_BACKUP_DIR, f) + size = os.path.getsize(path) / 1024 + wled_backups.append({"name": f, "size": f"{size:.1f} KB"}) + + conn = database.get_db() + devices = conn.execute("SELECT id, hostname, name FROM wled_devices ORDER BY name, hostname").fetchall() + conn.close() + + return templates.TemplateResponse(*_ctx(request, "backup", + db_backups=db_backups, wled_backups=wled_backups, devices=devices)) + + +@app.get("/api/backup/db/download") +async def download_db(): + """Download current database.""" + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + return FileResponse( + database.DB_PATH, + media_type="application/octet-stream", + filename=f"busylight_{ts}.db", + ) + + +@app.post("/api/backup/db/create") +async def create_db_backup(): + """Create a manual DB backup.""" + auto_backup_db() + return RedirectResponse("/backup?db_created=1", status_code=303) + + +@app.get("/api/backup/db/{filename}") +async def download_db_backup(filename: str): + """Download a specific DB backup.""" + path = os.path.join(BACKUP_DIR, filename) + if not os.path.exists(path) or ".." in filename: + return RedirectResponse("/backup", status_code=303) + return FileResponse(path, media_type="application/octet-stream", filename=filename) + + +@app.post("/api/backup/db/{filename}/delete") +async def delete_db_backup(filename: str): + path = os.path.join(BACKUP_DIR, filename) + if os.path.exists(path) and ".." not in filename: + os.remove(path) + return RedirectResponse("/backup", status_code=303) + + +@app.post("/api/backup/wled/all") +async def backup_all_wled(): + """Backup config of all WLED devices.""" + from wled_client import get_wled_config + conn = database.get_db() + devices = conn.execute("SELECT id, hostname, name FROM wled_devices").fetchall() + conn.close() + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + count = 0 + for dev in devices: + config = get_wled_config(dev["hostname"]) + if config: + safe_name = dev["hostname"].replace(".", "_") + path = os.path.join(WLED_BACKUP_DIR, f"{safe_name}_{ts}.json") + with open(path, "w") as f: + json.dump(config, f, indent=2) + count += 1 + + return RedirectResponse(f"/backup?wled_backed_up={count}", status_code=303) + + +@app.post("/api/backup/wled/{device_id}") +async def backup_single_wled(device_id: int): + """Backup config of a single WLED device.""" + from wled_client import get_wled_config + conn = database.get_db() + dev = conn.execute("SELECT hostname FROM wled_devices WHERE id=?", (device_id,)).fetchone() + conn.close() + if not dev: + return RedirectResponse("/backup", status_code=303) + + config = get_wled_config(dev["hostname"]) + if config: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_name = dev["hostname"].replace(".", "_") + path = os.path.join(WLED_BACKUP_DIR, f"{safe_name}_{ts}.json") + with open(path, "w") as f: + json.dump(config, f, indent=2) + + return RedirectResponse("/backup?wled_backed_up=1", status_code=303) + + +@app.get("/api/backup/wled/download/{filename}") +async def download_wled_backup(filename: str): + path = os.path.join(WLED_BACKUP_DIR, filename) + if not os.path.exists(path) or ".." in filename: + return RedirectResponse("/backup", status_code=303) + return FileResponse(path, media_type="application/json", filename=filename) + + +@app.post("/api/backup/wled/delete/{filename}") +async def delete_wled_backup(filename: str): + path = os.path.join(WLED_BACKUP_DIR, filename) + if os.path.exists(path) and ".." not in filename: + os.remove(path) + return RedirectResponse("/backup", status_code=303) + + +@app.post("/api/backup/wled/restore/{filename}") +async def restore_wled_backup(filename: str, device_id: int = Form(...)): + """Restore a WLED config backup to a device.""" + from wled_client import restore_wled_config + path = os.path.join(WLED_BACKUP_DIR, filename) + if not os.path.exists(path) or ".." in filename: + return RedirectResponse("/backup", status_code=303) + + conn = database.get_db() + dev = conn.execute("SELECT hostname FROM wled_devices WHERE id=?", (device_id,)).fetchone() + conn.close() + if not dev: + return RedirectResponse("/backup", status_code=303) + + with open(path) as f: + config = json.load(f) + + success, msg = restore_wled_config(dev["hostname"], config) + return RedirectResponse(f"/backup?restored={'1' if success else '0'}", status_code=303) + + # ── CLI Auth Command ─────────────────────────────────────────────────────── def cli_auth(): diff --git a/templates/backup.html b/templates/backup.html new file mode 100644 index 0000000..ce3178a --- /dev/null +++ b/templates/backup.html @@ -0,0 +1,208 @@ +{% extends "base.html" %} +{% block title %}Backup - Busylight{% endblock %} + +{% block content %} +

Backup

+ +{% if request.query_params.get('db_created') %} +
+ Portal-Backup erstellt. + +
+{% endif %} + +{% if request.query_params.get('wled_backed_up') %} +
+ {{ request.query_params.get('wled_backed_up') }} Tuerschild-Config(s) gesichert. + +
+{% endif %} + +{% if request.query_params.get('restored') == '1' %} +
+ WLED-Config erfolgreich wiederhergestellt. + +
+{% elif request.query_params.get('restored') == '0' %} +
+ WLED-Config konnte nicht wiederhergestellt werden. + +
+{% endif %} + +
+ +
+
+
+
Portal-Datenbank
+
+ + Aktuelle DB + +
+ +
+
+
+
+
+ + + + + + + + + + {% for b in db_backups %} + + + + + + {% endfor %} + {% if not db_backups %} + + + + {% endif %} + +
DateiGroesseAktionen
{{ b.name }}{{ b.size }} + + + +
+ +
+
Noch keine Backups vorhanden
+
+
+ +
+
+ + +
+
+
+
Tuerschild-Konfigurationen
+
+
+ +
+
+
+
+ +
+
+
+ + +
+ +
+
+ +
+ + + + + + + + + + {% for b in wled_backups %} + + + + + + + + {% endfor %} + {% if not wled_backups %} + + + + {% endif %} + +
DateiGroesseAktionen
{{ b.name }}{{ b.size }} + + + + +
+ +
+
Noch keine Config-Backups vorhanden
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 1791c54..304b600 100644 --- a/templates/base.html +++ b/templates/base.html @@ -42,6 +42,11 @@ Farb-Regeln +