Dashboard: - Much smaller cards (col-xl-2), compact badges - Short labels (Buero, HO, DND, OoO, etc.) - Optimized for small screens Login: - Password-protected admin pages (all except dashboard) - Dashboard remains public (read-only status view) - Login page with redirect back to requested page - Session cookie (24h, regenerated on restart) - Password set via Settings page (SHA256 hashed) - Sidebar shows only Dashboard when not logged in - Login/Logout link in sidebar footer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
882 lines
32 KiB
Python
882 lines
32 KiB
Python
#!/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
|
|
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_aida_status, s.last_teams_status,
|
|
COALESCE(s.phone_active, 0) as phone_active,
|
|
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:
|
|
phone = row["phone_active"]
|
|
if phone:
|
|
# Phone active: show phone color
|
|
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_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 "-",
|
|
"phone": phone,
|
|
"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):
|
|
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
|
|
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",
|
|
"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)
|
|
|
|
# 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_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)
|
|
|
|
|
|
# ── 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)
|
|
|
|
|
|
# ── 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)
|