Christian Mueller 77ae7b357d feat: show last activity timestamp per device on Tuerschilder page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:28:33 +02:00

508 lines
18 KiB
Python

#!/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.add_job(scheduler.check_devices, "interval", seconds=240, id="device_check")
# Initial device check nach 5 Sekunden (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()
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,
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):
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):
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
return templates.TemplateResponse(*_ctx(request, "devices",
devices=devices, last_check=last_check))
@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"])
conn.execute("""
UPDATE wled_devices SET online=?, leds=?, version=?, last_check=? WHERE id=?
""", (
1 if info.get("online") else 0,
info.get("leds", 0) if info.get("online") else 0,
info.get("version", "") if info.get("online") else "",
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):
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
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)