#!/usr/bin/env python3 """Busylight WLED Controller - Web Application.""" import logging import sys from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Form from fastapi.responses import HTMLResponse, RedirectResponse 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) # 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.start() logger.info("Polling gestartet (Intervall: %ds)", interval) yield bg_scheduler.shutdown() app = FastAPI(title="Busylight Manager", lifespan=lifespan) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") def _ctx(request, page, **kwargs): """Build template context with common variables.""" authenticated = scheduler.graph is not None and scheduler.graph.is_authenticated() return request, f"{page}.html", {"page": page, "authenticated": authenticated, **kwargs} # ── 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_aida_status, s.last_teams_status, cr_aida.r as ar, cr_aida.g as ag, cr_aida.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_aida ON cr_aida.status = s.last_aida_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: # Use teams color if anwesend, otherwise aida color if row["last_aida_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"], "aida": row["last_aida_status"] or "-", "teams": row["last_teams_status"] or "-", "r": r, "g": g, "b": b, }) else: segments.append({ "index": seg_idx, "user": None, "aida": "-", "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): 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(""), active: str = Form("0"), ): conn = database.get_db() conn.execute( "UPDATE users SET personal_nr=?, active=? WHERE azure_id=?", (personal_nr, 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): conn = database.get_db() devices = conn.execute( "SELECT * FROM wled_devices ORDER BY name, hostname" ).fetchall() conn.close() return templates.TemplateResponse(*_ctx(request, "devices", devices=devices)) @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.get("/api/devices/{device_id}/status") async def device_status(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() conn.close() if not dev: return {"online": False} return get_wled_info(dev["hostname"]) # ── Assignments ──────────────────────────────────────────────────────────── @app.get("/assignments", response_class=HTMLResponse) async def assignments_page(request: Request): 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): 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): 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", "aida_csv_path", "aida_status_anwesend", "aida_status_abwesend", "aida_status_homeoffice", ] for key in keys: value = form.get(key, "") database.set_setting(key, value) # 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): 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_aida_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) # ── 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 if len(sys.argv) > 1 and sys.argv[1] == "--auth": cli_auth() else: uvicorn.run(app, host="0.0.0.0", port=8080)