feat: rewrite busylight as FastAPI web application with SQLite

Replace the standalone Python script + config.cfg with a full web
application featuring:
- FastAPI backend with REST API
- SQLite database (config, state, activity log separated)
- Bootstrap 5 web interface for configuration
- Pages: Dashboard, Users, Devices, Assignments, Color Rules, Settings, Log
- APScheduler background polling for Teams presence + AIDA status
- systemd service file for Linux deployment
- Migration script to import existing config.cfg data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Mueller 2026-04-23 20:46:33 +02:00
commit 5267baf0b4
18 changed files with 1985 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
.env
venv/
busylight.db
config.cfg
config.dev.cfg
*.egg-info/
dist/
build/
.vscode/

455
app.py Normal file
View File

@ -0,0 +1,455 @@
#!/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")
# ── 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()
authenticated = scheduler.graph is not None and scheduler.graph.is_authenticated()
return templates.TemplateResponse("dashboard.html", {
"request": request, "page": "dashboard",
"devices": device_data, "authenticated": authenticated,
})
# ── 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("users.html", {
"request": request, "page": "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("devices.html", {
"request": request, "page": "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)
# ── 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("assignments.html", {
"request": request, "page": "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("colors.html", {
"request": request, "page": "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()
authenticated = scheduler.graph is not None and scheduler.graph.is_authenticated()
return templates.TemplateResponse("settings.html", {
"request": request, "page": "settings",
"settings": settings, "authenticated": authenticated,
})
@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("log.html", {
"request": request, "page": "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()
graph = GraphAPI()
if graph.is_authenticated():
print("Bereits authentifiziert.")
return
print("Starte Azure Device Code Authentifizierung...")
print("Bitte den angezeigten Code im Browser eingeben.")
graph.authenticate()
print("Authentifizierung 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)

17
busylight.service Normal file
View File

@ -0,0 +1,17 @@
[Unit]
Description=Busylight WLED Controller
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=busylight
Group=busylight
WorkingDirectory=/opt/busylight
ExecStart=/opt/busylight/venv/bin/python app.py
Restart=always
RestartSec=10
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target

157
database.py Normal file
View File

@ -0,0 +1,157 @@
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "busylight.db")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db():
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS users (
azure_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL DEFAULT '',
email TEXT DEFAULT '',
personal_nr TEXT DEFAULT '',
active INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS wled_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL UNIQUE,
name TEXT DEFAULT '',
segment_count INTEGER NOT NULL DEFAULT 2
);
CREATE TABLE IF NOT EXISTS assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_azure_id TEXT NOT NULL REFERENCES users(azure_id) ON DELETE CASCADE,
wled_device_id INTEGER NOT NULL REFERENCES wled_devices(id) ON DELETE CASCADE,
segment INTEGER NOT NULL DEFAULT 0,
UNIQUE(wled_device_id, segment),
UNIQUE(user_azure_id)
);
CREATE TABLE IF NOT EXISTS color_rules (
status TEXT PRIMARY KEY,
r INTEGER NOT NULL DEFAULT 0,
g INTEGER NOT NULL DEFAULT 0,
b INTEGER NOT NULL DEFAULT 0,
effect TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS state (
user_azure_id TEXT PRIMARY KEY REFERENCES users(azure_id) ON DELETE CASCADE,
last_aida_status TEXT DEFAULT '',
last_teams_status TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
user_azure_id TEXT,
user_name TEXT,
aida_status TEXT,
teams_status TEXT,
wled_url TEXT,
result TEXT
);
""")
defaults = {
"azure_client_id": "",
"azure_client_secret": "",
"azure_tenant_id": "",
"azure_auth_tenant": "common",
"azure_scopes": "User.Read User.Read.All Presence.Read Presence.Read.All",
"azure_auth_record": "",
"poll_interval": "15",
"aida_csv_path": "",
"aida_status_anwesend": "~~1~~",
"aida_status_abwesend": "~~0~~",
"aida_status_homeoffice": "~~2~~",
}
for key, value in defaults.items():
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
(key, value),
)
default_colors = [
("abwesend", 0, 0, 0, ""),
("homeoffice", 255, 255, 0, ""),
("available", 0, 255, 0, ""),
("teams_available", 0, 255, 0, ""),
("teams_away", 0, 255, 0, ""),
("teams_busy", 255, 0, 0, ""),
("teams_donotdisturb", 255, 0, 255, ""),
("teams_inacall", 255, 0, 255, ""),
("teams_inameeting", 255, 0, 0, ""),
("teams_presenting", 200, 200, 200, ""),
("teams_inaconferencecall", 200, 200, 200, ""),
("teams_berightback", 0, 255, 0, ""),
("teams_offline", 0, 255, 0, ""),
("teams_inactive", 0, 255, 0, ""),
("teams_offwork", 0, 255, 0, ""),
("teams_outofoffice", 0, 255, 0, ""),
]
for status, r, g, b, effect in default_colors:
conn.execute(
"INSERT OR IGNORE INTO color_rules (status, r, g, b, effect) VALUES (?, ?, ?, ?, ?)",
(status, r, g, b, effect),
)
conn.commit()
conn.close()
def get_setting(key, default=""):
conn = get_db()
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
conn.close()
return row["value"] if row else default
def set_setting(key, value):
conn = get_db()
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, str(value)),
)
conn.commit()
conn.close()
def get_all_settings():
conn = get_db()
rows = conn.execute("SELECT key, value FROM settings").fetchall()
conn.close()
return {row["key"]: row["value"] for row in rows}
def log_activity(user_azure_id, user_name, aida_status, teams_status, wled_url, result):
conn = get_db()
conn.execute(
"""INSERT INTO activity_log
(user_azure_id, user_name, aida_status, teams_status, wled_url, result)
VALUES (?, ?, ?, ?, ?, ?)""",
(user_azure_id, user_name, aida_status, teams_status, wled_url, result),
)
conn.execute(
"DELETE FROM activity_log WHERE id NOT IN (SELECT id FROM activity_log ORDER BY id DESC LIMIT 500)"
)
conn.commit()
conn.close()

102
graph_client.py Normal file
View File

@ -0,0 +1,102 @@
import logging
from azure.identity import (
DeviceCodeCredential,
TokenCachePersistenceOptions,
AuthenticationRecord,
)
from msgraph.core import GraphClient
from database import get_setting, set_setting
logger = logging.getLogger(__name__)
CACHE_OPTIONS = TokenCachePersistenceOptions(
name="busylight", allow_unencrypted_cache=True
)
class GraphAPI:
def __init__(self):
self.client = None
self.credential = None
self._init_client()
def _init_client(self):
client_id = get_setting("azure_client_id")
tenant_id = get_setting("azure_auth_tenant", "common")
scopes = get_setting("azure_scopes").split()
auth_record_json = get_setting("azure_auth_record")
if not client_id or not auth_record_json:
logger.warning("Azure nicht konfiguriert oder nicht authentifiziert")
return
try:
record = AuthenticationRecord.deserialize(auth_record_json)
self.credential = DeviceCodeCredential(
client_id=client_id,
tenant_id=tenant_id,
enable_persistent_cache=True,
allow_unencrypted_cache=True,
cache_persistence_options=CACHE_OPTIONS,
authentication_record=record,
)
self.credential.authenticate(tenant_id=tenant_id, scopes=scopes)
self.client = GraphClient(credential=self.credential, scopes=scopes)
logger.info("Graph-Client erfolgreich initialisiert")
except Exception as e:
logger.error("Graph-Client Fehler: %s", e)
self.client = None
def authenticate(self):
"""Trigger device code flow. Must run in terminal."""
client_id = get_setting("azure_client_id")
tenant_id = get_setting("azure_auth_tenant", "common")
scopes = get_setting("azure_scopes").split()
if not client_id:
raise ValueError("Azure Client ID nicht konfiguriert")
self.credential = DeviceCodeCredential(
client_id=client_id,
tenant_id=tenant_id,
enable_persistent_cache=True,
allow_unencrypted_cache=True,
cache_persistence_options=CACHE_OPTIONS,
)
record = self.credential.authenticate(tenant_id=tenant_id, scopes=scopes)
set_setting("azure_auth_record", record.serialize())
self.client = GraphClient(credential=self.credential, scopes=scopes)
logger.info("Azure Authentifizierung erfolgreich")
return record
def is_authenticated(self):
return self.client is not None
def get_users(self):
if not self.client:
return []
try:
resp = self.client.get(
"/users?$select=displayName,id,mail&$orderBy=displayName&$top=999"
)
data = resp.json()
users = data.get("value", [])
# Handle pagination
while "@odata.nextLink" in data:
resp = self.client.get(data["@odata.nextLink"])
data = resp.json()
users.extend(data.get("value", []))
return users
except Exception as e:
logger.error("Fehler beim Abrufen der User: %s", e)
return []
def get_presence(self, user_id):
if not self.client:
return None
try:
resp = self.client.get(f"/users/{user_id}/presence")
return resp.json()
except Exception as e:
logger.error("Fehler beim Abrufen der Presence für %s: %s", user_id, e)
return None

148
migrate.py Normal file
View File

@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Migrate old config.cfg to SQLite database."""
import re
import sys
from configparser import ConfigParser
from database import init_db, get_db, set_setting
def parse_color(value):
"""Parse 'R=255&G=0&B=0' or '/win&A=255&R=200&G=200&B=200' into (r, g, b, effect)."""
effect = ""
# Extract effect prefix (e.g., /win&A=255)
if value.startswith("/"):
parts = value.split("&R=")
if len(parts) == 2:
effect = parts[0]
value = "R=" + parts[1]
r = g = b = 0
for part in value.replace("&", " ").split():
if part.startswith("R="):
r = int(part[2:])
elif part.startswith("G="):
g = int(part[2:])
elif part.startswith("B="):
b = int(part[2:])
return r, g, b, effect
def migrate(config_path="config.cfg"):
"""Import old config.cfg into the SQLite database."""
config = ConfigParser()
config.read(config_path, encoding="utf-8")
init_db()
conn = get_db()
# Azure settings
if config.has_section("azure"):
az = config["azure"]
set_setting("azure_client_id", az.get("clientid", ""))
set_setting("azure_client_secret", az.get("clientsecret", ""))
set_setting("azure_tenant_id", az.get("tenantid", ""))
set_setting("azure_auth_tenant", az.get("authtenant", "common"))
set_setting("azure_scopes", az.get("graphuserscopes", ""))
# Auth record
if config.has_option("cred", "value"):
set_setting("azure_auth_record", config["cred"]["value"])
# Config settings
if config.has_section("config"):
cfg = config["config"]
set_setting("poll_interval", cfg.get("intervall", "15"))
set_setting("aida_csv_path", cfg.get("anwesenheit_csv", ""))
set_setting("aida_status_anwesend", cfg.get("anwesenheit_anwesend", "~~1~~"))
set_setting("aida_status_abwesend", cfg.get("anwesenheit_abwesend", "~~0~~"))
set_setting("aida_status_homeoffice", cfg.get("anwesenheit_homeoffice", "~~2~~"))
# WLED color rules
if config.has_section("wled"):
for status, value in config["wled"].items():
r, g, b, effect = parse_color(value)
conn.execute(
"""INSERT OR REPLACE INTO color_rules (status, r, g, b, effect)
VALUES (?, ?, ?, ?, ?)""",
(status, r, g, b, effect),
)
# Users and WLED devices
devices = {} # hostname -> device_id
if config.has_section("user"):
for azure_id, entry in config["user"].items():
parts = entry.split(" - ", 1)
hostname = parts[0].strip()
display_name = parts[1].strip() if len(parts) > 1 else ""
# Get personal number from anwesenheit section
personal_nr = ""
if config.has_option("anwesenheit", azure_id):
anw_val = config["anwesenheit"][azure_id].split()[0]
personal_nr = anw_val
# Determine if user is active (has a known hostname)
is_active = 0 if hostname == "unbekannt" else 1
conn.execute(
"""INSERT OR REPLACE INTO users (azure_id, display_name, personal_nr, active)
VALUES (?, ?, ?, ?)""",
(azure_id, display_name, personal_nr, is_active),
)
# Create WLED device if hostname is known
if hostname != "unbekannt" and hostname not in devices:
conn.execute(
"INSERT OR IGNORE INTO wled_devices (hostname, name, segment_count) VALUES (?, ?, 2)",
(hostname, hostname),
)
row = conn.execute(
"SELECT id FROM wled_devices WHERE hostname = ?", (hostname,)
).fetchone()
if row:
devices[hostname] = row["id"]
# Segment assignments
if config.has_section("segment"):
for azure_id, entry in config["segment"].items():
parts = entry.split()
seg_str = parts[0] if parts else "-"
if seg_str == "-":
continue
try:
segment = int(seg_str)
except ValueError:
continue
# Find the user's hostname
if config.has_option("user", azure_id):
user_entry = config["user"][azure_id]
hostname = user_entry.split(" - ", 1)[0].strip()
if hostname in devices:
device_id = devices[hostname]
conn.execute(
"""INSERT OR REPLACE INTO assignments
(user_azure_id, wled_device_id, segment)
VALUES (?, ?, ?)""",
(azure_id, device_id, segment),
)
conn.commit()
conn.close()
print(f"Migration abgeschlossen.")
print(f" - {len(devices)} WLED-Geraete importiert")
conn = get_db()
user_count = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"]
active_count = conn.execute("SELECT COUNT(*) as c FROM users WHERE active=1").fetchone()["c"]
assign_count = conn.execute("SELECT COUNT(*) as c FROM assignments").fetchone()["c"]
conn.close()
print(f" - {user_count} User importiert ({active_count} aktiv)")
print(f" - {assign_count} Segment-Zuordnungen importiert")
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "config.cfg"
migrate(path)

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
jinja2>=3.1.0
python-multipart>=0.0.6
requests>=2.31.0
azure-identity>=1.15.0
msgraph-core>=0.2.2
apscheduler>=3.10.0

184
scheduler.py Normal file
View File

@ -0,0 +1,184 @@
import logging
import os
from datetime import datetime
from database import get_db, get_setting, log_activity
from wled_client import build_wled_url, call_wled
logger = logging.getLogger(__name__)
# Global reference to graph client (set by app.py on startup)
graph = None
def read_aida_csv(csv_path):
"""Read AIDA status CSV. Returns dict: personal_nr -> line content."""
if not csv_path or not os.path.exists(csv_path):
logger.warning("AIDA CSV nicht gefunden: %s", csv_path)
return {}
try:
with open(csv_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.read().splitlines()
return lines
except Exception as e:
logger.error("Fehler beim Lesen der AIDA CSV: %s", e)
return []
def determine_aida_status(lines, personal_nr, settings):
"""Determine AIDA status for a user based on their personal number."""
if not personal_nr or not lines:
return None
matching = [l for l in lines if l.startswith(personal_nr.lower())]
if not matching:
return None
line = matching[0]
if line.endswith(settings["aida_status_abwesend"]):
return "abwesend"
if line.endswith(settings["aida_status_anwesend"]):
return "anwesend"
if line.endswith(settings["aida_status_homeoffice"]):
return "homeoffice"
return None
def get_color_for_status(status, conn):
"""Get RGB color for a given status from color_rules."""
row = conn.execute(
"SELECT r, g, b, effect FROM color_rules WHERE status = ?", (status,)
).fetchone()
if row:
return dict(row)
return {"r": 200, "g": 200, "b": 200, "effect": ""}
def poll_status():
"""Main polling function - called periodically by APScheduler."""
global graph
if graph is None or not graph.is_authenticated():
logger.warning("Graph-Client nicht authentifiziert, ueberspringe Polling")
return
conn = get_db()
try:
settings = {}
for row in conn.execute("SELECT key, value FROM settings").fetchall():
settings[row["key"]] = row["value"]
# Get all active users with assignments
users_with_assignments = conn.execute("""
SELECT u.azure_id, u.display_name, u.personal_nr,
a.segment, d.hostname,
s.last_aida_status, s.last_teams_status
FROM users u
JOIN assignments a ON a.user_azure_id = u.azure_id
JOIN wled_devices d ON d.id = a.wled_device_id
LEFT JOIN state s ON s.user_azure_id = u.azure_id
WHERE u.active = 1 AND u.personal_nr != ''
""").fetchall()
if not users_with_assignments:
logger.debug("Keine aktiven User mit Zuordnungen gefunden")
return
# Read AIDA CSV
aida_lines = read_aida_csv(settings.get("aida_csv_path", ""))
for user in users_with_assignments:
try:
_process_user(user, aida_lines, settings, conn)
except Exception as e:
logger.error(
"Fehler bei User %s: %s", user["display_name"], e
)
except Exception as e:
logger.error("Polling Fehler: %s", e)
finally:
conn.close()
def _process_user(user, aida_lines, settings, conn):
"""Process a single user: check status and update WLED if needed."""
global graph
azure_id = user["azure_id"]
display_name = user["display_name"]
personal_nr = user["personal_nr"]
hostname = user["hostname"]
segment = user["segment"]
last_aida = user["last_aida_status"] or ""
last_teams = user["last_teams_status"] or ""
# Determine AIDA status
aida_status = determine_aida_status(aida_lines, personal_nr, settings)
if aida_status is None:
return
# Determine Teams status
teams_status = ""
if aida_status == "anwesend":
presence = graph.get_presence(azure_id)
if presence and "activity" in presence:
teams_status = "teams_" + presence["activity"].lower()
# Check if status changed
if aida_status == last_aida:
if aida_status == "anwesend":
if teams_status == last_teams:
return # No change
else:
return # No change
# Determine color
if aida_status == "abwesend":
color = get_color_for_status("abwesend", conn)
elif aida_status == "homeoffice":
color = get_color_for_status("homeoffice", conn)
elif aida_status == "anwesend" and teams_status:
color = get_color_for_status(teams_status, conn)
if not color:
color = get_color_for_status("teams_available", conn)
else:
color = get_color_for_status("available", conn)
# Build URL and call WLED
url = build_wled_url(
hostname, color["r"], color["g"], color["b"],
segment=segment, effect=color.get("effect", "")
)
success, result_text = call_wled(url)
if success:
# Update state
conn.execute("""
INSERT INTO state (user_azure_id, last_aida_status, last_teams_status, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_azure_id)
DO UPDATE SET last_aida_status=?, last_teams_status=?, updated_at=?
""", (
azure_id, aida_status, teams_status, datetime.now(),
aida_status, teams_status, datetime.now(),
))
conn.commit()
logger.info(
"%-40s aida=%-12s teams=%-20s -> %s",
display_name, aida_status, teams_status, result_text,
)
else:
# Reset state on failure
conn.execute("""
INSERT INTO state (user_azure_id, last_aida_status, last_teams_status, updated_at)
VALUES (?, '', '', ?)
ON CONFLICT(user_azure_id)
DO UPDATE SET last_aida_status='', last_teams_status='', updated_at=?
""", (azure_id, datetime.now(), datetime.now()))
conn.commit()
# Log activity
log_activity(
azure_id, display_name, aida_status, teams_status, url, result_text
)

131
static/style.css Normal file
View File

@ -0,0 +1,131 @@
:root {
--sidebar-width: 240px;
}
body {
min-height: 100vh;
background: #f4f6f9;
}
.sidebar {
width: var(--sidebar-width);
min-height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 100;
overflow-y: auto;
}
.sidebar .nav-link {
padding: 0.6rem 1rem;
border-radius: 0.375rem;
margin: 2px 8px;
transition: background 0.15s;
}
.sidebar .nav-link:hover {
background: rgba(255, 255, 255, 0.1);
}
.sidebar .nav-link.active {
background: rgba(255, 255, 255, 0.15);
font-weight: 600;
}
.sidebar .nav-link i {
width: 24px;
text-align: center;
margin-right: 8px;
}
.main-content {
margin-left: var(--sidebar-width);
min-height: 100vh;
padding: 1.5rem;
}
.status-dot {
width: 16px;
height: 16px;
border-radius: 50%;
display: inline-block;
border: 1px solid rgba(0, 0, 0, 0.2);
}
.busylight-card {
border: none;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s;
}
.busylight-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.segment-bar {
height: 40px;
border-radius: 6px;
display: flex;
align-items: center;
padding: 0 12px;
color: #333;
font-size: 0.9rem;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.segment-bar.empty {
background: repeating-linear-gradient(
45deg,
#f0f0f0,
#f0f0f0 5px,
#e8e8e8 5px,
#e8e8e8 10px
);
color: #999;
font-style: italic;
}
.color-preview {
width: 40px;
height: 40px;
border-radius: 8px;
border: 2px solid rgba(0, 0, 0, 0.15);
cursor: pointer;
}
.table th {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #6c757d;
}
.badge-online {
background: #28a745;
}
.badge-offline {
background: #6c757d;
}
.log-table {
font-size: 0.85rem;
}
.log-table td {
vertical-align: middle;
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
min-height: auto;
position: relative;
}
.main-content {
margin-left: 0;
}
}

115
templates/assignments.html Normal file
View File

@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}Zuordnungen - Busylight{% endblock %}
{% block content %}
<h4 class="mb-4">Zuordnungen (Benutzer &rarr; WLED-Segment)</h4>
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Benutzer</th>
<th>WLED-Geraet</th>
<th>Segment</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for a in assignments %}
<tr>
<td>{{ a.display_name }}</td>
<td>
<code>{{ a.hostname }}</code>
{% if a.device_name and a.device_name != a.hostname %}
<small class="text-muted">({{ a.device_name }})</small>
{% endif %}
</td>
<td>
<span class="badge bg-primary">Segment {{ a.segment }}</span>
</td>
<td>
<form method="post" action="/api/assignments/{{ a.id }}/delete" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-x-lg"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
{% if not assignments %}
<tr>
<td colspan="4" class="text-center text-muted py-4">
Noch keine Zuordnungen vorhanden.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-plus-circle"></i> Neue Zuordnung</h6>
</div>
<div class="card-body">
<form method="post" action="/api/assignments">
<div class="mb-3">
<label class="form-label">Benutzer</label>
<select name="user_azure_id" class="form-select" required>
<option value="">-- Benutzer waehlen --</option>
{% for u in users %}
<option value="{{ u.azure_id }}">{{ u.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">WLED-Geraet</label>
<select name="wled_device_id" class="form-select" id="device-select" required>
<option value="">-- Geraet waehlen --</option>
{% for d in devices %}
<option value="{{ d.id }}" data-segments="{{ d.segment_count }}">
{{ d.name or d.hostname }} ({{ d.hostname }})
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Segment</label>
<select name="segment" class="form-select" id="segment-select" required>
<option value="0">Segment 0 (oben)</option>
<option value="1">Segment 1 (unten)</option>
</select>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-link-45deg"></i> Zuordnen
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('device-select').addEventListener('change', function() {
const opt = this.selectedOptions[0];
const count = parseInt(opt.dataset.segments || '2');
const segSelect = document.getElementById('segment-select');
segSelect.innerHTML = '';
for (let i = 0; i < count; i++) {
const label = i === 0 ? 'oben' : i === 1 ? 'unten' : i.toString();
segSelect.innerHTML += `<option value="${i}">Segment ${i} (${label})</option>`;
}
});
</script>
{% endblock %}

73
templates/base.html Normal file
View File

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Busylight Manager{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="/static/style.css" rel="stylesheet">
{% block head %}{% endblock %}
</head>
<body>
<div class="d-flex">
<nav class="sidebar bg-dark text-white d-flex flex-column">
<div class="p-3 border-bottom border-secondary">
<h5 class="mb-0"><i class="bi bi-lightbulb-fill text-warning"></i> Busylight</h5>
<small class="text-secondary">WLED Controller</small>
</div>
<ul class="nav flex-column mt-2 flex-grow-1">
<li class="nav-item">
<a class="nav-link text-white {% if page == 'dashboard' %}active{% endif %}" href="/">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'users' %}active{% endif %}" href="/users">
<i class="bi bi-people"></i> Benutzer
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'devices' %}active{% endif %}" href="/devices">
<i class="bi bi-router"></i> WLED-Geraete
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'assignments' %}active{% endif %}" href="/assignments">
<i class="bi bi-link-45deg"></i> Zuordnungen
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'colors' %}active{% endif %}" href="/colors">
<i class="bi bi-palette"></i> Farb-Regeln
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'settings' %}active{% endif %}" href="/settings">
<i class="bi bi-gear"></i> Einstellungen
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'log' %}active{% endif %}" href="/log">
<i class="bi bi-journal-text"></i> Protokoll
</a>
</li>
</ul>
<div class="p-3 border-top border-secondary">
{% if authenticated %}
<small class="text-success"><i class="bi bi-check-circle"></i> Azure verbunden</small>
{% else %}
<small class="text-danger"><i class="bi bi-x-circle"></i> Azure getrennt</small>
{% endif %}
</div>
</nav>
<main class="main-content flex-grow-1">
{% block content %}{% endblock %}
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

79
templates/colors.html Normal file
View File

@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Farb-Regeln - Busylight{% endblock %}
{% block content %}
<h4 class="mb-4">Farb-Regeln</h4>
{% if request.query_params.get('saved') %}
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> Farben gespeichert.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<form method="post" action="/api/colors">
<div class="row g-3">
{% for rule in rules %}
<div class="col-md-6 col-lg-4 col-xl-3">
<div class="card busylight-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>{{ rule.status }}</strong>
<input type="hidden" name="status" value="{{ rule.status }}">
</div>
<div class="color-preview"
style="background: rgb({{ rule.r }}, {{ rule.g }}, {{ rule.b }});"
title="Aktuelle Farbe"></div>
</div>
<div class="mb-2">
<label class="form-label small text-muted mb-1">Farbe</label>
<input type="color" name="color_{{ rule.status }}" class="form-control form-control-color w-100"
value="#{{ '%02x%02x%02x' % (rule.r, rule.g, rule.b) | default('000000') }}">
</div>
<div>
<label class="form-label small text-muted mb-1">Effekt (optional)</label>
<input type="text" name="effect_{{ rule.status }}" class="form-control form-control-sm"
value="{{ rule.effect or '' }}" placeholder="z.B. /win&A=255">
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Alle Farben speichern
</button>
</div>
</form>
<hr class="my-4">
<div class="card" style="max-width: 400px;">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-plus-circle"></i> Neue Farb-Regel</h6>
</div>
<div class="card-body">
<form method="post" action="/api/colors/add">
<div class="mb-3">
<label class="form-label">Status-Name</label>
<input type="text" name="status" class="form-control"
placeholder="z.B. teams_focusing" required>
</div>
<div class="mb-3">
<label class="form-label">Farbe</label>
<input type="color" name="color" class="form-control form-control-color w-100"
value="#00ff00">
</div>
<div class="mb-3">
<label class="form-label">Effekt (optional)</label>
<input type="text" name="effect" class="form-control"
placeholder="z.B. /win&A=255">
</div>
<button type="submit" class="btn btn-outline-primary w-100">Hinzufuegen</button>
</form>
</div>
</div>
{% endblock %}

74
templates/dashboard.html Normal file
View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Dashboard - Busylight{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Dashboard</h4>
<div>
<form method="post" action="/api/poll" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-clockwise"></i> Jetzt abfragen
</button>
</form>
<span class="text-muted ms-2" id="last-update"></span>
</div>
</div>
{% if not authenticated %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
Azure ist nicht verbunden. Bitte unter <a href="/settings">Einstellungen</a> konfigurieren und
<code>python app.py --auth</code> ausfuehren.
</div>
{% endif %}
{% if devices %}
<div class="row g-3">
{% for device in devices %}
<div class="col-md-6 col-lg-4 col-xl-3">
<div class="card busylight-card h-100">
<div class="card-header bg-white border-0 pb-0">
<div class="d-flex justify-content-between align-items-center">
<strong>{{ device.name }}</strong>
<small class="text-muted">{{ device.hostname }}</small>
</div>
</div>
<div class="card-body pt-2">
{% for seg in device.segments %}
<div class="mb-2">
<small class="text-muted d-block mb-1">Segment {{ seg.index }}</small>
{% if seg.user %}
<div class="segment-bar" style="background: rgba({{ seg.r }}, {{ seg.g }}, {{ seg.b }}, 0.3); border-color: rgb({{ seg.r }}, {{ seg.g }}, {{ seg.b }});">
<span class="status-dot me-2" style="background: rgb({{ seg.r }}, {{ seg.g }}, {{ seg.b }});"></span>
{{ seg.user }}
</div>
<small class="text-muted">{{ seg.aida }} / {{ seg.teams }}</small>
{% else %}
<div class="segment-bar empty">
<i class="bi bi-dash-circle me-2"></i> Nicht zugeordnet
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-lightbulb display-1 text-muted"></i>
<p class="text-muted mt-3">Noch keine WLED-Geraete konfiguriert.</p>
<a href="/devices" class="btn btn-primary">Geraete hinzufuegen</a>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Auto-refresh every 10 seconds
setTimeout(() => location.reload(), 10000);
document.getElementById('last-update').textContent =
'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE');
</script>
{% endblock %}

124
templates/devices.html Normal file
View File

@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}WLED-Geraete - Busylight{% endblock %}
{% block content %}
<h4 class="mb-4">WLED-Geraete</h4>
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Hostname / IP</th>
<th>Name</th>
<th>Segmente</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr>
<td>
<code>{{ device.hostname }}</code>
</td>
<td>{{ device.name }}</td>
<td>{{ device.segment_count }}</td>
<td>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
data-bs-target="#edit-{{ device.id }}">
<i class="bi bi-pencil"></i>
</button>
<form method="post" action="/api/devices/{{ device.id }}/delete"
class="d-inline"
onsubmit="return confirm('Geraet wirklich loeschen?')">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
<!-- Edit Modal -->
<div class="modal fade" id="edit-{{ device.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/api/devices/{{ device.id }}/update">
<div class="modal-header">
<h5 class="modal-title">Geraet bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Hostname / IP</label>
<input type="text" name="hostname" class="form-control"
value="{{ device.hostname }}" required>
</div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" name="name" class="form-control"
value="{{ device.name }}">
</div>
<div class="mb-3">
<label class="form-label">Anzahl Segmente</label>
<input type="number" name="segment_count" class="form-control"
value="{{ device.segment_count }}" min="1" max="10">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
{% if not devices %}
<tr>
<td colspan="4" class="text-center text-muted py-4">
Noch keine Geraete vorhanden.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-plus-circle"></i> Neues Geraet</h6>
</div>
<div class="card-body">
<form method="post" action="/api/devices">
<div class="mb-3">
<label class="form-label">Hostname / IP</label>
<input type="text" name="hostname" class="form-control"
placeholder="doorlight0027.intra.example.de" required>
</div>
<div class="mb-3">
<label class="form-label">Name (optional)</label>
<input type="text" name="name" class="form-control"
placeholder="Buero 027">
</div>
<div class="mb-3">
<label class="form-label">Anzahl Segmente</label>
<input type="number" name="segment_count" class="form-control"
value="2" min="1" max="10">
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-plus"></i> Hinzufuegen
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

68
templates/log.html Normal file
View File

@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Protokoll - Busylight{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Protokoll</h4>
<span class="text-muted" id="last-update"></span>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover log-table mb-0">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Benutzer</th>
<th>AIDA-Status</th>
<th>Teams-Status</th>
<th>WLED-URL</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td class="text-nowrap">{{ log.timestamp }}</td>
<td>{{ log.user_name }}</td>
<td>
<span class="badge {% if log.aida_status == 'anwesend' %}bg-success{% elif log.aida_status == 'abwesend' %}bg-secondary{% elif log.aida_status == 'homeoffice' %}bg-warning text-dark{% else %}bg-light text-dark{% endif %}">
{{ log.aida_status or '-' }}
</span>
</td>
<td>
<span class="badge bg-info">{{ log.teams_status or '-' }}</span>
</td>
<td><small class="font-monospace">{{ log.wled_url }}</small></td>
<td>
{% if log.result and '200' in log.result %}
<span class="text-success"><i class="bi bi-check-circle"></i> {{ log.result }}</span>
{% else %}
<span class="text-danger"><i class="bi bi-x-circle"></i> {{ log.result }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not logs %}
<tr>
<td colspan="6" class="text-center text-muted py-4">
Noch keine Protokoll-Eintraege vorhanden.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
setTimeout(() => location.reload(), 15000);
document.getElementById('last-update').textContent =
'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE');
</script>
{% endblock %}

118
templates/settings.html Normal file
View File

@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}Einstellungen - Busylight{% endblock %}
{% block content %}
<h4 class="mb-4">Einstellungen</h4>
{% if request.query_params.get('saved') %}
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> Einstellungen gespeichert.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<form method="post" action="/api/settings">
<div class="row g-4">
<!-- Azure AD -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-microsoft"></i> Azure AD / Microsoft Graph</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Client ID</label>
<input type="text" name="azure_client_id" class="form-control font-monospace"
value="{{ settings.azure_client_id }}">
</div>
<div class="mb-3">
<label class="form-label">Client Secret</label>
<input type="password" name="azure_client_secret" class="form-control font-monospace"
value="{{ settings.azure_client_secret }}">
</div>
<div class="mb-3">
<label class="form-label">Tenant ID</label>
<input type="text" name="azure_tenant_id" class="form-control font-monospace"
value="{{ settings.azure_tenant_id }}">
</div>
<div class="mb-3">
<label class="form-label">Auth Tenant</label>
<input type="text" name="azure_auth_tenant" class="form-control"
value="{{ settings.azure_auth_tenant }}">
</div>
<div class="mb-3">
<label class="form-label">Graph Scopes</label>
<input type="text" name="azure_scopes" class="form-control"
value="{{ settings.azure_scopes }}">
</div>
<div class="border rounded p-3 bg-light">
{% if authenticated %}
<i class="bi bi-check-circle text-success"></i>
<strong class="text-success">Authentifiziert</strong>
{% else %}
<i class="bi bi-x-circle text-danger"></i>
<strong class="text-danger">Nicht authentifiziert</strong>
<p class="small text-muted mt-2 mb-0">
Fuehre auf dem Server aus:<br>
<code>python app.py --auth</code>
</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Polling & AIDA -->
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-clock"></i> Polling</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Abfrage-Intervall (Sekunden)</label>
<input type="number" name="poll_interval" class="form-control"
value="{{ settings.poll_interval }}" min="5" max="300">
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> AIDA Zeiterfassung</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">CSV-Pfad</label>
<input type="text" name="aida_csv_path" class="form-control font-monospace"
value="{{ settings.aida_csv_path }}"
placeholder="/mnt/zeiterfassung/Status.csv">
<small class="text-muted">Auf Linux z.B. via SMB-Mount</small>
</div>
<div class="mb-3">
<label class="form-label">Status: Anwesend</label>
<input type="text" name="aida_status_anwesend" class="form-control"
value="{{ settings.aida_status_anwesend }}">
</div>
<div class="mb-3">
<label class="form-label">Status: Abwesend</label>
<input type="text" name="aida_status_abwesend" class="form-control"
value="{{ settings.aida_status_abwesend }}">
</div>
<div class="mb-3">
<label class="form-label">Status: Homeoffice</label>
<input type="text" name="aida_status_homeoffice" class="form-control"
value="{{ settings.aida_status_homeoffice }}">
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Einstellungen speichern
</button>
</div>
</form>
{% endblock %}

94
templates/users.html Normal file
View File

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Benutzer - Busylight{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Benutzer</h4>
<form method="post" action="/api/users/sync">
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-download"></i> Aus Azure synchronisieren
</button>
</form>
</div>
{% if request.query_params.get('synced') %}
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> {{ request.query_params.get('synced') }} neue Benutzer synchronisiert.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
{% if request.query_params.get('error') == 'not_authenticated' %}
<div class="alert alert-danger alert-dismissible fade show">
<i class="bi bi-x-circle"></i> Azure nicht verbunden. Bitte zuerst authentifizieren.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Status</th>
<th>Name</th>
<th>E-Mail</th>
<th>Personal-Nr (AIDA)</th>
<th>Zuordnung</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="{% if not user.active %}table-light text-muted{% endif %}">
<td>
{% if user.active %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
{% endif %}
</td>
<td>{{ user.display_name }}</td>
<td><small>{{ user.email or '-' }}</small></td>
<td>
<form method="post" action="/api/users/{{ user.azure_id }}/update" class="d-flex gap-1">
<input type="text" name="personal_nr" value="{{ user.personal_nr or '' }}"
class="form-control form-control-sm" style="width: 120px;"
placeholder="~~00000~~">
<input type="hidden" name="active" value="{{ '1' if user.active else '0' }}">
<button type="submit" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-check"></i>
</button>
</form>
</td>
<td>
{% if user.assignment_id %}
<span class="badge bg-info"><i class="bi bi-link-45deg"></i> Zugeordnet</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<form method="post" action="/api/users/{{ user.azure_id }}/toggle" class="d-inline">
<button type="submit" class="btn btn-sm {% if user.active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
{% if user.active %}
<i class="bi bi-pause-circle"></i>
{% else %}
<i class="bi bi-play-circle"></i>
{% endif %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-3 text-muted">
<small>{{ users|length }} Benutzer insgesamt</small>
</div>
{% endblock %}

26
wled_client.py Normal file
View File

@ -0,0 +1,26 @@
import logging
import requests
logger = logging.getLogger(__name__)
TIMEOUT = 5
def build_wled_url(hostname, r, g, b, segment=None, effect=""):
"""Build WLED HTTP API URL."""
base = f"http://{hostname}/win&A=255&R={r}&G={g}&B={b}"
if effect:
base = f"http://{hostname}{effect}&R={r}&G={g}&B={b}"
if segment is not None:
base = f"{base}&SS={segment}"
return base
def call_wled(url):
"""Send HTTP request to WLED device. Returns (success, status_text)."""
try:
r = requests.post(url, timeout=TIMEOUT)
return True, f"{r.status_code} {r.reason}"
except requests.exceptions.RequestException as e:
logger.warning("WLED nicht erreichbar: %s - %s", url, e)
return False, str(e)