497 lines
18 KiB
Python
497 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 = conn.execute(
|
|
"SELECT * FROM wled_devices ORDER BY name, hostname"
|
|
).fetchall()
|
|
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)
|