#!/usr/bin/env python3 """Busylight WLED Controller - Web Application.""" import hashlib import json import logging import os import secrets import shutil import sys from contextlib import asynccontextmanager from datetime import datetime from fastapi import FastAPI, Request, Form, UploadFile, File, BackgroundTasks from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from apscheduler.schedulers.background import BackgroundScheduler import database import scheduler from graph_client import GraphAPI logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) logger = logging.getLogger(__name__) bg_scheduler = BackgroundScheduler() @asynccontextmanager async def lifespan(application: FastAPI): database.init_db() # Init Graph client try: scheduler.graph = GraphAPI() except Exception as e: logger.error("Graph-Client konnte nicht initialisiert werden: %s", e) # Verzeichnisse erstellen os.makedirs(BACKUP_DIR, exist_ok=True) os.makedirs(WLED_BACKUP_DIR, exist_ok=True) os.makedirs(FIRMWARE_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") bg_scheduler.add_job(auto_backup_db, "cron", hour=2, minute=0, id="auto_backup") bg_scheduler.add_job(auto_backup_wled, "cron", day=1, hour=2, minute=5, id="auto_backup_wled") # 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) yield 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") FIRMWARE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "firmware") 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) def auto_backup_wled(): """Backup all WLED configs. Keeps last 14 per device.""" from wled_client import get_wled_config conn = database.get_db() devices = conn.execute("SELECT id, hostname 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 # Keep only last 14 per device prefix = safe_name + "_" device_backups = sorted( [f for f in os.listdir(WLED_BACKUP_DIR) if f.startswith(prefix)], reverse=True, ) for old in device_backups[14:]: os.remove(os.path.join(WLED_BACKUP_DIR, old)) logger.info("WLED Auto-Backup: %d Geraete gesichert", count) app = FastAPI(title="Busylight Manager", lifespan=lifespan) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") SESSION_TOKEN = secrets.token_hex(32) # Regenerated on restart def _hash_password(pw): return hashlib.sha256(pw.encode()).hexdigest() def _is_logged_in(request): return request.cookies.get("session") == SESSION_TOKEN def _require_login(request): """Redirect to login if not authenticated. Returns RedirectResponse or None.""" if not _is_logged_in(request): # Check if a password is set admin_pw = database.get_setting("admin_password", "") if admin_pw: return RedirectResponse(f"/login?next={request.url.path}", status_code=303) return None def _ctx(request, page, **kwargs): """Build template context with common variables.""" authenticated = scheduler.graph is not None and scheduler.graph.is_authenticated() logged_in = _is_logged_in(request) return request, f"{page}.html", {"page": page, "authenticated": authenticated, "logged_in": logged_in, **kwargs} # ── Login ────────────────────────────────────────────────────────────────── @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request): next_url = request.query_params.get("next", "/") return templates.TemplateResponse(request, "login.html", { "page": "login", "next": next_url, "error": request.query_params.get("error"), }) @app.post("/login") async def login_submit( request: Request, password: str = Form(...), next: str = Form("/"), ): admin_pw = database.get_setting("admin_password", "") if admin_pw and _hash_password(password) == admin_pw: response = RedirectResponse(next, status_code=303) response.set_cookie("session", SESSION_TOKEN, httponly=True, max_age=86400) return response return RedirectResponse("/login?error=1&next=" + next, status_code=303) @app.get("/logout") async def logout(): response = RedirectResponse("/", status_code=303) response.delete_cookie("session") return response # ── Dashboard ────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request): conn = database.get_db() devices = conn.execute(""" SELECT d.id, d.hostname, d.name, d.segment_count FROM wled_devices d ORDER BY d.name, d.hostname """).fetchall() device_data = [] for dev in devices: segments = [] for seg_idx in range(dev["segment_count"]): row = conn.execute(""" SELECT u.display_name, u.azure_id, s.last_attendance_status, s.last_teams_status, COALESCE(s.phone_active, 0) as phone_active, cr_att.r as ar, cr_att.g as ag, cr_att.b as ab, cr_teams.r as tr, cr_teams.g as tg, cr_teams.b as tb FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id LEFT JOIN state s ON s.user_azure_id = u.azure_id LEFT JOIN color_rules cr_att ON cr_att.status = s.last_attendance_status LEFT JOIN color_rules cr_teams ON cr_teams.status = s.last_teams_status WHERE a.wled_device_id = ? AND a.segment = ? """, (dev["id"], seg_idx)).fetchone() if row: phone = row["phone_active"] if phone: pc = conn.execute("SELECT r,g,b FROM color_rules WHERE status='phone_active'").fetchone() r, g, b = (pc["r"], pc["g"], pc["b"]) if pc else (255, 0, 0) elif row["last_attendance_status"] == "anwesend" and row["tr"] is not None: r, g, b = row["tr"], row["tg"], row["tb"] elif row["ar"] is not None: r, g, b = row["ar"], row["ag"], row["ab"] else: r, g, b = 128, 128, 128 segments.append({ "index": seg_idx, "user": row["display_name"], "attendance": row["last_attendance_status"] or "-", "teams": row["last_teams_status"] or "-", "phone": phone, "r": r, "g": g, "b": b, }) else: segments.append({ "index": seg_idx, "user": None, "attendance": "-", "teams": "-", "r": 128, "g": 128, "b": 128, }) device_data.append({ "id": dev["id"], "hostname": dev["hostname"], "name": dev["name"] or dev["hostname"], "segments": segments, }) conn.close() return templates.TemplateResponse(*_ctx(request, "dashboard", devices=device_data)) # ── Users ────────────────────────────────────────────────────────────────── @app.get("/users", response_class=HTMLResponse) async def users_page(request: Request): redir = _require_login(request) if redir: return redir conn = database.get_db() users = conn.execute(""" SELECT u.*, a.id as assignment_id FROM users u LEFT JOIN assignments a ON a.user_azure_id = u.azure_id ORDER BY u.active DESC, u.display_name """).fetchall() conn.close() return templates.TemplateResponse(*_ctx(request, "users", users=users)) @app.post("/api/users/sync") async def sync_users(): if scheduler.graph is None or not scheduler.graph.is_authenticated(): return RedirectResponse("/users?error=not_authenticated", status_code=303) azure_users = scheduler.graph.get_users() conn = database.get_db() count = 0 for au in azure_users: existing = conn.execute( "SELECT azure_id FROM users WHERE azure_id = ?", (au["id"],) ).fetchone() if not existing: conn.execute( "INSERT INTO users (azure_id, display_name, email) VALUES (?, ?, ?)", (au["id"], au.get("displayName", ""), au.get("mail", "")), ) count += 1 else: conn.execute( "UPDATE users SET display_name=?, email=? WHERE azure_id=?", (au.get("displayName", ""), au.get("mail", ""), au["id"]), ) conn.commit() conn.close() return RedirectResponse(f"/users?synced={count}", status_code=303) @app.post("/api/users/{azure_id}/update") async def update_user( azure_id: str, personal_nr: str = Form(""), phone_extension: str = Form(""), active: str = Form("0"), ): conn = database.get_db() conn.execute( "UPDATE users SET personal_nr=?, phone_extension=?, active=? WHERE azure_id=?", (personal_nr, phone_extension, 1 if active == "1" else 0, azure_id), ) conn.commit() conn.close() return RedirectResponse("/users", status_code=303) @app.post("/api/users/{azure_id}/toggle") async def toggle_user(azure_id: str): conn = database.get_db() conn.execute( "UPDATE users SET active = CASE WHEN active=1 THEN 0 ELSE 1 END WHERE azure_id=?", (azure_id,), ) conn.commit() conn.close() return RedirectResponse("/users", status_code=303) # ── Devices ──────────────────────────────────────────────────────────────── @app.get("/devices", response_class=HTMLResponse) async def devices_page(request: Request): redir = _require_login(request) if redir: return redir conn = database.get_db() devices_raw = conn.execute( "SELECT * FROM wled_devices ORDER BY name, hostname" ).fetchall() # Letzte Aktivitaet pro Device aus activity_log devices = [] for dev in devices_raw: last_act = conn.execute(""" SELECT MAX(l.timestamp) as la FROM activity_log l WHERE l.wled_url LIKE '%' || ? || '%' """, (dev["hostname"],)).fetchone() d = dict(dev) d["last_activity"] = last_act["la"] if last_act and last_act["la"] else None # Segment assignments for this device d["segments"] = conn.execute(""" SELECT a.segment, u.display_name FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id WHERE a.wled_device_id = ? ORDER BY a.segment """, (dev["id"],)).fetchall() devices.append(d) last_check_row = conn.execute( "SELECT MAX(last_check) as lc FROM wled_devices WHERE last_check IS NOT NULL" ).fetchone() conn.close() last_check = last_check_row["lc"] if last_check_row and last_check_row["lc"] else None # Firmware files firmwares = [] if os.path.exists(FIRMWARE_DIR): for f in sorted(os.listdir(FIRMWARE_DIR), reverse=True): if f.endswith(".bin"): size = os.path.getsize(os.path.join(FIRMWARE_DIR, f)) / 1024 firmwares.append({"name": f, "size": f"{size:.0f} KB"}) return templates.TemplateResponse(*_ctx(request, "devices", devices=devices, last_check=last_check, firmwares=firmwares)) @app.post("/api/devices") async def add_device( hostname: str = Form(...), name: str = Form(""), segment_count: int = Form(2), ): conn = database.get_db() conn.execute( "INSERT OR IGNORE INTO wled_devices (hostname, name, segment_count) VALUES (?, ?, ?)", (hostname, name or hostname, segment_count), ) conn.commit() conn.close() return RedirectResponse("/devices", status_code=303) @app.post("/api/devices/{device_id}/update") async def update_device( device_id: int, hostname: str = Form(...), name: str = Form(""), segment_count: int = Form(2), ): conn = database.get_db() conn.execute( "UPDATE wled_devices SET hostname=?, name=?, segment_count=? WHERE id=?", (hostname, name, segment_count, device_id), ) conn.commit() conn.close() return RedirectResponse("/devices", status_code=303) @app.post("/api/devices/{device_id}/delete") async def delete_device(device_id: int): conn = database.get_db() conn.execute("DELETE FROM wled_devices WHERE id=?", (device_id,)) conn.commit() conn.close() return RedirectResponse("/devices", status_code=303) @app.post("/api/devices/check-all") async def check_all_devices(): scheduler.check_devices() return RedirectResponse("/devices", status_code=303) @app.post("/api/devices/{device_id}/check") async def check_single_device(device_id: int): from wled_client import get_wled_info conn = database.get_db() dev = conn.execute("SELECT hostname FROM wled_devices WHERE id=?", (device_id,)).fetchone() if dev: from datetime import datetime info = get_wled_info(dev["hostname"]) if info.get("online"): conn.execute(""" UPDATE wled_devices SET online=1, leds=?, version=?, mac=?, last_check=? WHERE id=? """, (info.get("leds", 0), info.get("version", ""), info.get("mac", ""), datetime.now(), device_id)) else: conn.execute(""" UPDATE wled_devices SET online=0, last_check=? WHERE id=? """, (datetime.now(), device_id)) conn.commit() conn.close() return RedirectResponse("/devices", status_code=303) # ── Assignments ──────────────────────────────────────────────────────────── @app.get("/assignments", response_class=HTMLResponse) async def assignments_page(request: Request): redir = _require_login(request) if redir: return redir conn = database.get_db() assignments = conn.execute(""" SELECT a.id, a.segment, u.display_name, u.azure_id, d.hostname, d.name as device_name, d.id as device_id FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id JOIN wled_devices d ON d.id = a.wled_device_id ORDER BY d.name, a.segment """).fetchall() users = conn.execute( "SELECT azure_id, display_name FROM users WHERE active=1 ORDER BY display_name" ).fetchall() devices = conn.execute( "SELECT id, hostname, name, segment_count FROM wled_devices ORDER BY name" ).fetchall() conn.close() return templates.TemplateResponse(*_ctx(request, "assignments", assignments=assignments, users=users, devices=devices)) @app.post("/api/assignments") async def create_assignment( user_azure_id: str = Form(...), wled_device_id: int = Form(...), segment: int = Form(0), ): conn = database.get_db() # Remove existing assignment for this user conn.execute("DELETE FROM assignments WHERE user_azure_id=?", (user_azure_id,)) conn.execute( "INSERT OR REPLACE INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)", (user_azure_id, wled_device_id, segment), ) conn.commit() conn.close() return RedirectResponse("/assignments", status_code=303) @app.post("/api/assignments/{assignment_id}/delete") async def delete_assignment(assignment_id: int): conn = database.get_db() conn.execute("DELETE FROM assignments WHERE id=?", (assignment_id,)) conn.commit() conn.close() return RedirectResponse("/assignments", status_code=303) # ── Color Rules ──────────────────────────────────────────────────────────── @app.get("/colors", response_class=HTMLResponse) async def colors_page(request: Request): redir = _require_login(request) if redir: return redir conn = database.get_db() rules = conn.execute("SELECT * FROM color_rules ORDER BY status").fetchall() conn.close() return templates.TemplateResponse(*_ctx(request, "colors", rules=rules)) @app.post("/api/colors") async def update_colors(request: Request): form = await request.form() conn = database.get_db() statuses = form.getlist("status") for status in statuses: color_hex = form.get(f"color_{status}", "#000000") # Convert hex to RGB color_hex = color_hex.lstrip("#") r = int(color_hex[0:2], 16) g = int(color_hex[2:4], 16) b = int(color_hex[4:6], 16) effect = form.get(f"effect_{status}", "") conn.execute( "UPDATE color_rules SET r=?, g=?, b=?, effect=? WHERE status=?", (r, g, b, effect, status), ) conn.commit() conn.close() return RedirectResponse("/colors?saved=1", status_code=303) @app.post("/api/colors/add") async def add_color( status: str = Form(...), color: str = Form("#00ff00"), effect: str = Form(""), ): color_hex = color.lstrip("#") r = int(color_hex[0:2], 16) g = int(color_hex[2:4], 16) b = int(color_hex[4:6], 16) conn = database.get_db() conn.execute( "INSERT OR REPLACE INTO color_rules (status, r, g, b, effect) VALUES (?, ?, ?, ?, ?)", (status, r, g, b, effect), ) conn.commit() conn.close() return RedirectResponse("/colors", status_code=303) # ── Settings ─────────────────────────────────────────────────────────────── @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): redir = _require_login(request) if redir: return redir settings = database.get_all_settings() return templates.TemplateResponse(*_ctx(request, "settings", settings=settings)) @app.post("/api/settings") async def update_settings(request: Request): form = await request.form() keys = [ "azure_client_id", "azure_client_secret", "azure_tenant_id", "azure_auth_tenant", "azure_scopes", "poll_interval", "attendance_csv_path", "attendance_status_anwesend", "attendance_status_abwesend", "attendance_status_homeoffice", ] for key in keys: value = form.get(key, "") database.set_setting(key, value) # Auth Tenant immer gleich Tenant ID setzen tenant_id = form.get("azure_tenant_id", "") if tenant_id: database.set_setting("azure_auth_tenant", tenant_id) # Admin password new_pw = form.get("admin_password", "") if new_pw: database.set_setting("admin_password", _hash_password(new_pw)) logger.info("Admin-Passwort geaendert") # Update scheduler interval try: interval = int(form.get("poll_interval", "15")) bg_scheduler.reschedule_job("poll", trigger="interval", seconds=interval) logger.info("Polling-Intervall geaendert auf %ds", interval) except Exception as e: logger.error("Fehler beim Aendern des Intervalls: %s", e) return RedirectResponse("/settings?saved=1", status_code=303) # ── Log ──────────────────────────────────────────────────────────────────── @app.get("/log", response_class=HTMLResponse) async def log_page(request: Request): redir = _require_login(request) if redir: return redir conn = database.get_db() logs = conn.execute( "SELECT * FROM activity_log ORDER BY id DESC LIMIT 200" ).fetchall() conn.close() return templates.TemplateResponse(*_ctx(request, "log", logs=logs)) # ── API Status (for dashboard auto-refresh) ─────────────────────────────── @app.get("/api/status") async def api_status(): conn = database.get_db() states = conn.execute(""" SELECT u.display_name, u.azure_id, s.last_attendance_status, s.last_teams_status, s.updated_at, d.hostname, a.segment FROM state s JOIN users u ON u.azure_id = s.user_azure_id LEFT JOIN assignments a ON a.user_azure_id = u.azure_id LEFT JOIN wled_devices d ON d.id = a.wled_device_id ORDER BY u.display_name """).fetchall() conn.close() return [dict(s) for s in states] @app.post("/api/poll") async def trigger_poll(): """Trigger a manual poll.""" scheduler.poll_status() return RedirectResponse("/", status_code=303) # ── Backup ───────────────────────────────────────────────────────────────── @app.get("/backup", response_class=HTMLResponse) async def backup_page(request: Request): redir = _require_login(request) if redir: return redir # 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 # Parse date from filename: busylight_YYYYMMDD_HHMMSS.db try: ts = f.replace("busylight_", "").replace(".db", "") dt = datetime.strptime(ts, "%Y%m%d_%H%M%S") date_display = dt.strftime("%d.%m.%Y %H:%M:%S") except ValueError: date_display = f db_backups.append({"name": f, "date": date_display, "size": f"{size:.0f} KB"}) # WLED config backups grouped by device wled_by_device = {} 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 # Extract device name and timestamp from filename # Format: hostname_parts_YYYYMMDD_HHMMSS.json parts = f.rsplit("_", 2) if len(parts) >= 3: device_key = parts[0] date_str = parts[1] time_str = parts[2].replace(".json", "") try: dt = datetime.strptime(date_str + time_str, "%Y%m%d%H%M%S") date_display = dt.strftime("%d.%m.%Y %H:%M:%S") except ValueError: date_display = f"{date_str} {time_str}" else: device_key = f.replace(".json", "") date_display = "-" hostname = device_key.replace("_", ".") if hostname not in wled_by_device: wled_by_device[hostname] = [] wled_by_device[hostname].append({ "name": f, "size": f"{size:.1f} KB", "date": date_display, }) 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_by_device=wled_by_device, 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) # ── Firmware Update ───────────────────────────────────────────────────────── @app.post("/api/firmware/upload") async def upload_firmware(firmware: UploadFile = File(...)): """Upload a WLED firmware .bin file.""" if not firmware.filename.endswith(".bin"): return RedirectResponse("/devices?fw_error=format", status_code=303) path = os.path.join(FIRMWARE_DIR, firmware.filename) with open(path, "wb") as f: content = await firmware.read() f.write(content) logger.info("Firmware hochgeladen: %s (%d KB)", firmware.filename, len(content) // 1024) return RedirectResponse("/devices?fw_uploaded=1", status_code=303) @app.post("/api/firmware/{device_id}/update") async def ota_update_device(device_id: int, firmware_file: str = Form(...)): """OTA update a single WLED device. Backs up config first.""" from wled_client import get_wled_config, ota_update_wled conn = database.get_db() dev = conn.execute("SELECT hostname, name FROM wled_devices WHERE id=?", (device_id,)).fetchone() conn.close() if not dev: return RedirectResponse("/devices?fw_error=notfound", status_code=303) fw_path = os.path.join(FIRMWARE_DIR, firmware_file) if not os.path.exists(fw_path) or ".." in firmware_file: return RedirectResponse("/devices?fw_error=nofile", status_code=303) # Auto config backup before update config = get_wled_config(dev["hostname"]) if config: ts = datetime.now().strftime("%Y%m%d_%H%M%S") safe_name = dev["hostname"].replace(".", "_") backup_path = os.path.join(WLED_BACKUP_DIR, f"{safe_name}_{ts}_pre_update.json") with open(backup_path, "w") as f: json.dump(config, f, indent=2) logger.info("Pre-Update Backup: %s", backup_path) # Push firmware success, msg = ota_update_wled(dev["hostname"], fw_path) logger.info("OTA Update %s: %s - %s", dev["hostname"], "OK" if success else "FEHLER", msg) if success: return RedirectResponse(f"/devices?fw_updated={dev['hostname']}", status_code=303) else: return RedirectResponse(f"/devices?fw_error=failed", status_code=303) @app.post("/api/firmware/{filename}/delete") async def delete_firmware(filename: str): path = os.path.join(FIRMWARE_DIR, filename) if os.path.exists(path) and ".." not in filename: os.remove(path) return RedirectResponse("/devices", status_code=303) # ── Azure Auth (Web-basiert) ────────────────────────────────────────────── # Global state for device code flow _auth_state = {"status": "idle", "user_code": None, "verification_uri": None, "error": None} def _run_device_code_auth(): """Background task: run device code flow and save auth record.""" from azure.identity import DeviceCodeCredential, TokenCachePersistenceOptions from database import get_setting, set_setting client_id = get_setting("azure_client_id") tenant_id = get_setting("azure_auth_tenant", "common") scopes = get_setting("azure_scopes", "User.Read").split() if not client_id: _auth_state["status"] = "error" _auth_state["error"] = "Anwendungs-ID (Client) nicht konfiguriert" return def prompt_callback(verification_uri, user_code, expires_on): _auth_state["user_code"] = user_code _auth_state["verification_uri"] = verification_uri _auth_state["status"] = "waiting" try: cred = DeviceCodeCredential( client_id=client_id, tenant_id=tenant_id, cache_persistence_options=TokenCachePersistenceOptions( name="busylight", allow_unencrypted_storage=True ), prompt_callback=prompt_callback, ) record = cred.authenticate(tenant_id=tenant_id, scopes=scopes) set_setting("azure_auth_record", record.serialize()) _auth_state["status"] = "success" # Reinit graph client try: scheduler.graph = GraphAPI() logger.info("Graph-Client nach Web-Auth neu initialisiert") except Exception as e: logger.error("Graph-Client Reinit fehlgeschlagen: %s", e) except Exception as e: _auth_state["status"] = "error" _auth_state["error"] = str(e) logger.error("Web-Auth fehlgeschlagen: %s", e) @app.post("/api/azure/auth") async def start_azure_auth(): """Start device code flow in background.""" import threading _auth_state["status"] = "starting" _auth_state["user_code"] = None _auth_state["verification_uri"] = None _auth_state["error"] = None # Clear old auth record database.set_setting("azure_auth_record", "") thread = threading.Thread(target=_run_device_code_auth, daemon=True) thread.start() return JSONResponse({"status": "starting"}) @app.get("/api/azure/auth/status") async def azure_auth_status(): """Poll auth state from frontend.""" return JSONResponse(_auth_state) # ── CLI Auth Command ─────────────────────────────────────────────────────── def cli_auth(): """Run Azure device code authentication from terminal.""" database.init_db() # Clear old auth record to force fresh device code flow database.set_setting("azure_auth_record", "") print("Starte Azure Device Code Authentifizierung...") print("Bitte den angezeigten Code im Browser eingeben.\n") graph = GraphAPI() graph.authenticate() print("\nAuthentifizierung erfolgreich!") if __name__ == "__main__": import uvicorn import threading if len(sys.argv) > 1 and sys.argv[1] == "--auth": cli_auth() else: # Start webhook server on port 8081 in background thread from webhook import webhook_app def run_webhook(): uvicorn.run(webhook_app, host="0.0.0.0", port=8081, log_level="info") webhook_thread = threading.Thread(target=run_webhook, daemon=True) webhook_thread.start() logger.info("Webhook-Server gestartet auf Port 8081") # Main web UI on port 8080 uvicorn.run(app, host="0.0.0.0", port=8080)