"""Versioned REST and realtime API routes.""" from __future__ import annotations import hashlib import json import uuid from collections.abc import AsyncIterator from datetime import UTC, datetime, timedelta from typing import Annotated, Any, cast from fastapi import APIRouter, Depends, Header, Query, Request, Response from fastapi.responses import Response as RawResponse from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from ..application import AppContext from ..connectors.base import DeviceCapabilities, DeviceState from ..connectors.openrgb.adapter import OpenRGBAdapter from ..database import utc_now from ..errors import ConflictError, NotFoundError from ..repository import row_to_dict from ..schemas import ( AutomationCreate, ConnectorSecretInput, DeviceCommandRequest, DevicePatch, GroupCreate, Page, RoomCreate, SceneApplyRequest, SceneCreate, SceneImportRequest, SettingUpdate, SetupCompleteRequest, ZoneResizeRequest, ) api = APIRouter(prefix="/api/v1") class LoginRequest(BaseModel): token: str = Field(min_length=1, max_length=4096) class NameRequest(BaseModel): name: str = Field(min_length=1, max_length=255) class CaptureRequest(NameRequest): device_ids: list[str] | None = None class DuplicateRequest(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=255) class RestoreRequest(BaseModel): name: str = Field(min_length=1, max_length=255) class GroupStateRequest(BaseModel): state: DeviceState def context(request: Request) -> AppContext: return cast(AppContext, request.app.state.context) def actor(request: Request, app: Annotated[AppContext, Depends(context)]) -> str: return app.auth.require(request) @api.get("/auth/status") def auth_status(request: Request, app: Annotated[AppContext, Depends(context)]) -> dict[str, Any]: authenticated = not app.settings.auth_enabled if app.settings.auth_enabled: try: app.auth.require(request) authenticated = True except Exception: authenticated = False return {"auth_enabled": app.settings.auth_enabled, "authenticated": authenticated} @api.post("/auth/login") def login( body: LoginRequest, response: Response, app: Annotated[AppContext, Depends(context)], ) -> dict[str, Any]: return app.auth.login(body.token, response) @api.post("/auth/logout") def logout(response: Response) -> dict[str, bool]: from ..auth import AuthManager AuthManager.logout(response) return {"authenticated": False} @api.get("/system") async def system( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: with app.database.connection() as conn: device_counts = dict( conn.execute( "SELECT COUNT(*) AS total, SUM(online) AS online, " "SUM(CASE WHEN online=0 THEN 1 ELSE 0 END) AS offline FROM devices WHERE deleted_at IS NULL" ).fetchone() ) active_scene = row_to_dict( conn.execute( "SELECT id, name, last_applied_at FROM scenes WHERE last_applied_at IS NOT NULL " "AND deleted_at IS NULL ORDER BY last_applied_at DESC LIMIT 1" ).fetchone() ) recent_commands = [ row_to_dict(row) for row in conn.execute( "SELECT * FROM commands ORDER BY created_at DESC LIMIT 8" ).fetchall() ] warnings = [ row_to_dict(row) for row in conn.execute( "SELECT * FROM activity WHERE severity IN ('warning', 'error') AND acknowledged_at IS NULL " "ORDER BY created_at DESC LIMIT 8" ).fetchall() ] health = await app.health.detailed() return { "name": app.settings.app_name, "version": app.settings.app_version, "openrgb_version": "1.0rc3", "sdk_protocol": 5, "environment": app.settings.environment, "mock_mode": app.settings.connector_mode == "mock", "health": health, "devices": { "total": device_counts.get("total") or 0, "online": device_counts.get("online") or 0, "offline": device_counts.get("offline") or 0, }, "active_scene": active_scene, "recent_commands": recent_commands, "warnings": warnings, "emergency_stop": app.commands.emergency_stop, } @api.get("/health") async def detailed_health( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return await app.health.detailed() @api.get("/setup") def setup_state( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.setup.state() @api.post("/setup/inspect") async def inspect_setup( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], include_network: bool = False, ) -> dict[str, Any]: result = await app.setup.inspect(include_network) if result.get("openrgb", {}).get("connected"): try: result["openrgb_inventory"] = await app.inventory.sync_all() except Exception as exc: result["openrgb_inventory"] = {"status": "failed", "error": str(exc)} return result @api.post("/setup/complete") def complete_setup( body: SetupCompleteRequest, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.setup.complete(body.appdata_confirmed, cast(bool, body.backup_location_confirmed)) @api.get("/devices", response_model=Page) def list_devices( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], limit: Annotated[int, Query(ge=1, le=500)] = 100, offset: Annotated[int, Query(ge=0)] = 0, online: bool | None = None, room_id: str | None = None, source: str | None = None, include_hidden: bool = False, sort: str = "name", ) -> Page: items, total = app.inventory.list_devices( limit=limit, offset=offset, online=online, room_id=room_id, source=source, include_hidden=include_hidden, sort=sort, ) return Page(items=items, total=total, limit=limit, offset=offset) @api.get("/device-groups") def list_device_groups( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> list[dict[str, Any]]: return app.inventory.list_device_groups() @api.get("/device-groups/{group_id}") def get_device_group( group_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.inventory.get_device_group(group_id) @api.post("/device-groups/{group_id}/state") async def set_device_group_state( group_id: str, body: GroupStateRequest, request: Request, app: Annotated[AppContext, Depends(context)], current_actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: group = app.inventory.get_device_group(group_id) results = [] for device in group["devices"]: compatible = _compatible_state(body.state, device) if not compatible.model_dump(exclude_none=True): results.append( {"device_id": device["id"], "status": "skipped", "reason": "incompatible"} ) continue try: results.append( await app.commands.execute_device( device["id"], compatible, actor=current_actor, request_id=request.state.request_id, ) ) except Exception as exc: results.append({"device_id": device["id"], "status": "failed", "error": str(exc)}) failed = sum(item["status"] == "failed" for item in results) app.repository.audit( request_id=request.state.request_id, actor=current_actor, action="device_group.set_state", resource_type="device_group", resource_id=group_id, outcome="failed" if failed else "succeeded", detail={"member_count": len(group["devices"]), "failed": failed}, ) return { "group_id": group_id, "status": "degraded" if failed else "succeeded", "results": results, } @api.get("/devices/{device_id}") def get_device( device_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.inventory.get_device(device_id) @api.patch("/devices/{device_id}") def patch_device( device_id: str, body: DevicePatch, request: Request, app: Annotated[AppContext, Depends(context)], current_actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: result = app.inventory.patch_device(device_id, body) app.repository.audit( request_id=request.state.request_id, actor=current_actor, action="device.update", resource_type="device", resource_id=device_id, outcome="succeeded", detail={"fields": sorted(body.model_dump(exclude_unset=True))}, ) return result @api.post("/devices/{device_id}/state") async def set_device_state( device_id: str, body: DeviceCommandRequest, request: Request, app: Annotated[AppContext, Depends(context)], current_actor: Annotated[str, Depends(actor)], idempotency_key: Annotated[str | None, Header(max_length=255)] = None, ) -> dict[str, Any]: request_hash = hashlib.sha256( body.model_dump_json(exclude_none=True).encode("utf-8") ).hexdigest() if idempotency_key: cached = _idempotency_lookup(app, idempotency_key, request_hash) if cached is not None: return cached result = await app.commands.execute_device( device_id, body.state, actor=current_actor, request_id=request.state.request_id, ) if idempotency_key: _idempotency_store(app, idempotency_key, request_hash, result, result["id"]) return result @api.put("/devices/{device_id}/zones/{zone_index}") async def resize_device_zone( device_id: str, zone_index: int, body: ZoneResizeRequest, request: Request, app: Annotated[AppContext, Depends(context)], current_actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: device = app.inventory.get_device(device_id) if not device["online"]: raise ConflictError("Het apparaat is offline.", device_id=device_id) if device["read_only"]: raise ConflictError("Het apparaat staat in alleen-lezenmodus.", device_id=device_id) if device["blocked"]: raise ConflictError("Het apparaat is geblokkeerd.", device_id=device_id) connector = app.registry.get(device["connector_id"]) await connector.resize_zone(device["external_id"], zone_index, body.led_count) await app.inventory.sync_connector(device["connector_id"]) result = app.inventory.get_device(device_id) app.repository.audit( request_id=request.state.request_id, actor=current_actor, action="device.resize_zone", resource_type="device", resource_id=device_id, outcome="succeeded", detail={"zone_index": zone_index, "led_count": body.led_count}, ) return result @api.post("/devices/{device_id}/zones/{zone_index}/state") async def set_device_zone_state( device_id: str, zone_index: int, body: DeviceCommandRequest, request: Request, app: Annotated[AppContext, Depends(context)], current_actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: device = app.inventory.get_device(device_id) if not device["online"]: raise ConflictError("Het apparaat is offline.", device_id=device_id) if device["read_only"]: raise ConflictError("Het apparaat staat in alleen-lezenmodus.", device_id=device_id) if device["blocked"]: raise ConflictError("Het apparaat is geblokkeerd.", device_id=device_id) try: zone = next(item for item in device["zones"] if int(item["index"]) == zone_index) except StopIteration as exc: raise NotFoundError("zone", str(zone_index)) from exc connector = app.registry.get(device["connector_id"]) leds_min = int(zone.get("leds_min", zone["led_count"])) leds_max = int(zone.get("leds_max", zone["led_count"])) if leds_min != leds_max and int(zone["led_count"]) != leds_max: await connector.resize_zone(device["external_id"], zone_index, leds_max) await app.inventory.sync_connector(device["connector_id"], reconcile=False) state = body.state.model_copy(update={"power": True, "zone_index": zone_index}) result = await app.commands.execute_device( device_id, state, actor=current_actor, request_id=request.state.request_id, ) result["zone_index"] = zone_index result["led_count"] = leds_max return result @api.post("/devices/{device_id}/identify") async def identify_device( device_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return await app.commands.identify(device_id) @api.post("/devices/rescan") async def rescan_devices( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: for connector in app.registry.all(): await connector.rescan() return await app.inventory.sync_all() @api.post("/commands/all-off") async def all_off( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return await app.commands.all_off() @api.post("/commands/emergency-stop") async def emergency_stop( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return await app.commands.all_off(emergency=True) @api.delete("/commands/emergency-stop") def clear_emergency_stop( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: app.commands.clear_emergency_stop() return {"emergency_stop": False} @api.get("/commands", response_model=Page) def list_commands( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], limit: Annotated[int, Query(ge=1, le=500)] = 100, offset: Annotated[int, Query(ge=0)] = 0, ) -> Page: items, total = app.repository.list_rows("commands", limit=limit, offset=offset) return Page(items=items, total=total, limit=limit, offset=offset) @api.get("/commands/{command_id}") def get_command( command_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: with app.database.connection() as conn: command = row_to_dict( conn.execute("SELECT * FROM commands WHERE id=?", (command_id,)).fetchone() ) results = [ row_to_dict(row) for row in conn.execute( "SELECT * FROM command_results WHERE command_id=? ORDER BY id", (command_id,) ).fetchall() ] if command is None: raise NotFoundError("Opdracht", command_id) command["results"] = results return command @api.get("/rooms") def list_rooms( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)] ) -> list[dict[str, Any]]: return app.resources.list_rooms() @api.post("/rooms", status_code=201) def create_room( body: RoomCreate, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.resources.create_room(body) @api.put("/rooms/{room_id}") def update_room( room_id: str, body: RoomCreate, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.resources.update_room(room_id, body) @api.delete("/rooms/{room_id}", status_code=204) def delete_room( room_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> Response: app.resources.delete_room(room_id) return Response(status_code=204) @api.get("/groups") def list_groups( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)] ) -> list[dict[str, Any]]: return app.resources.list_groups() @api.get("/tags") def list_tags( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> list[dict[str, Any]]: return app.resources.list_tags() @api.post("/groups", status_code=201) def create_group( body: GroupCreate, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.resources.create_group(body) @api.get("/groups/{group_id}") def get_group( group_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.resources.get_group(group_id) @api.put("/groups/{group_id}") def update_group( group_id: str, body: GroupCreate, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.resources.update_group(group_id, body) @api.delete("/groups/{group_id}", status_code=204) def delete_group( group_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> Response: app.resources.delete_group(group_id) return Response(status_code=204) @api.post("/groups/{group_id}/state") async def set_group_state( group_id: str, body: GroupStateRequest, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: group = app.resources.get_group(group_id) results = [] for device in group["devices"]: if device["excluded"]: continue compatible = _compatible_state(body.state, device) if not compatible.model_dump(exclude_none=True): results.append( {"device_id": device["id"], "status": "skipped", "reason": "incompatible"} ) continue try: results.append(await app.commands.execute_device(device["id"], compatible)) except Exception as exc: results.append({"device_id": device["id"], "status": "failed", "error": str(exc)}) return {"group_id": group_id, "results": results} def _compatible_state(state: DeviceState, device: dict[str, Any]) -> DeviceState: """Keep only properties the target reports as writable capabilities.""" capabilities = DeviceCapabilities.model_validate(device["capabilities"]) value = state.model_dump(mode="json", exclude_none=True) requirements = { "power": "power", "brightness": "brightness", "colors": "rgb", "mode": "effect", "mode_index": "effect", "speed": "speed", "direction": "direction", "zone_index": "per_zone", "led_index": "per_led", } for field, capability in requirements.items(): if field in value and not getattr(capabilities, capability): value.pop(field) return DeviceState.model_validate(value) @api.get("/scenes", response_model=Page) def list_scenes( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], limit: Annotated[int, Query(ge=1, le=500)] = 100, offset: Annotated[int, Query(ge=0)] = 0, ) -> Page: items, total = app.scenes.list_scenes(limit, offset) return Page(items=items, total=total, limit=limit, offset=offset) @api.post("/scenes", status_code=201) def create_scene( body: SceneCreate, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.scenes.create(body) @api.post("/scenes/capture", status_code=201) def capture_scene( body: CaptureRequest, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.scenes.capture(body.name, body.device_ids) @api.post("/scenes/import", status_code=201) def import_scene( body: SceneImportRequest, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.scenes.create(body.scene) @api.get("/scenes/{scene_id}") def get_scene( scene_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.scenes.get(scene_id) @api.put("/scenes/{scene_id}") def update_scene( scene_id: str, body: SceneCreate, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.scenes.update(scene_id, body) @api.delete("/scenes/{scene_id}", status_code=204) def delete_scene( scene_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> Response: app.scenes.delete(scene_id) return Response(status_code=204) @api.get("/scenes/{scene_id}/preview") def preview_scene( scene_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.scenes.preview(scene_id) @api.post("/scenes/{scene_id}/apply") async def apply_scene( scene_id: str, body: SceneApplyRequest, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return await app.scenes.apply(scene_id, body.rollback_on_failure) @api.post("/scenes/{scene_id}/duplicate", status_code=201) def duplicate_scene( scene_id: str, body: DuplicateRequest, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.scenes.duplicate(scene_id, body.name) @api.get("/scenes/{scene_id}/export") def export_scene( scene_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> RawResponse: scene = app.scenes.get(scene_id) payload = {"format": "lumaops-scene-v1", "scene": scene} return RawResponse( json.dumps(payload, ensure_ascii=False, indent=2), media_type="application/json", headers={"Content-Disposition": f'attachment; filename="scene-{scene_id}.json"'}, ) @api.get("/automations", response_model=Page) def list_automations( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], limit: Annotated[int, Query(ge=1, le=500)] = 100, offset: Annotated[int, Query(ge=0)] = 0, ) -> Page: items, total = app.automations.list(limit, offset) return Page(items=items, total=total, limit=limit, offset=offset) @api.post("/automations", status_code=201) def create_automation( body: AutomationCreate, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.automations.create(body) @api.get("/automations/{automation_id}") def get_automation( automation_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.automations.get(automation_id) @api.put("/automations/{automation_id}") def update_automation( automation_id: str, body: AutomationCreate, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.automations.update(automation_id, body) @api.delete("/automations/{automation_id}", status_code=204) def delete_automation( automation_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> Response: app.automations.delete(automation_id) return Response(status_code=204) @api.post("/automations/{automation_id}/run") async def run_automation( automation_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return await app.automations.run(automation_id) @api.get("/automations/{automation_id}/runs", response_model=Page) def automation_runs( automation_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], limit: Annotated[int, Query(ge=1, le=500)] = 100, offset: Annotated[int, Query(ge=0)] = 0, ) -> Page: items, total = app.repository.list_rows( "automation_runs", where="automation_id=?", params=(automation_id,), order_by="started_at DESC", limit=limit, offset=offset, ) return Page(items=items, total=total, limit=limit, offset=offset) @api.get("/connectors") async def connectors( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> list[dict[str, Any]]: health = await app.registry.health() return [ { "id": connector.id, "kind": connector.kind, "configuration_schema": connector.configuration_schema(), "health": health[connector.id].model_dump(mode="json"), } for connector in app.registry.all() ] @api.post("/connectors/{connector_id}/test") async def test_connector( connector_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return (await app.registry.get(connector_id).test_connection()).model_dump(mode="json") @api.put("/connectors/{connector_id}/secrets", status_code=204) def set_connector_secret( connector_id: str, body: ConnectorSecretInput, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> Response: app.registry.get(connector_id) with app.database.connection() as conn: conn.execute( "INSERT INTO connector_secrets(connector_id, key, ciphertext, updated_at) VALUES (?, ?, ?, ?) " "ON CONFLICT(connector_id, key) DO UPDATE SET ciphertext=excluded.ciphertext, updated_at=excluded.updated_at", (connector_id, body.key, app.secrets.encrypt(body.value), utc_now()), ) return Response(status_code=204) @api.get("/connectors/{connector_id}/profiles") async def list_profiles( connector_id: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> list[str]: connector = app.registry.get(connector_id) if not isinstance(connector, OpenRGBAdapter): return [] return await connector.list_profiles() @api.post("/connectors/{connector_id}/profiles") async def save_profile( connector_id: str, body: NameRequest, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, str]: connector = app.registry.get(connector_id) if not isinstance(connector, OpenRGBAdapter): raise ConflictError("Deze connector ondersteunt geen OpenRGB-profielen.") await connector.save_profile(body.name) return {"status": "saved", "name": body.name} @api.post("/connectors/{connector_id}/profiles/{name}/load") async def load_profile( connector_id: str, name: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, str]: connector = app.registry.get(connector_id) if not isinstance(connector, OpenRGBAdapter): raise ConflictError("Deze connector ondersteunt geen OpenRGB-profielen.") await connector.load_profile(name) return {"status": "loaded", "name": name} @api.delete("/connectors/{connector_id}/profiles/{name}", status_code=204) async def delete_profile( connector_id: str, name: str, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> Response: connector = app.registry.get(connector_id) if not isinstance(connector, OpenRGBAdapter): raise ConflictError("Deze connector ondersteunt geen OpenRGB-profielen.") await connector.delete_profile(name) return Response(status_code=204) @api.post("/discovery") async def discovery( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: run_id = str(uuid.uuid4()) started = utc_now() with app.database.connection() as conn: conn.execute( "INSERT INTO discovery_runs(id, kind, status, started_at) VALUES (?, 'all', 'running', ?)", (run_id, started), ) try: result = await app.inventory.sync_all() found = sum(item.get("device_count", 0) for item in result.values()) with app.database.connection() as conn: conn.execute( "UPDATE discovery_runs SET status='completed', found_count=?, detail_json=?, finished_at=? WHERE id=?", (found, json.dumps(result, separators=(",", ":")), utc_now(), run_id), ) return {"id": run_id, "status": "completed", "found_count": found, "connectors": result} except Exception as exc: with app.database.connection() as conn: conn.execute( "UPDATE discovery_runs SET status='failed', detail_json=?, finished_at=? WHERE id=?", (json.dumps({"error": str(exc)}), utc_now(), run_id), ) raise @api.get("/discovery", response_model=Page) def discovery_history( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], limit: Annotated[int, Query(ge=1, le=500)] = 100, offset: Annotated[int, Query(ge=0)] = 0, ) -> Page: items, total = app.repository.list_rows( "discovery_runs", order_by="started_at DESC", limit=limit, offset=offset ) return Page(items=items, total=total, limit=limit, offset=offset) @api.get("/activity", response_model=Page) def activity( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], limit: Annotated[int, Query(ge=1, le=500)] = 100, offset: Annotated[int, Query(ge=0)] = 0, ) -> Page: items, total = app.repository.list_rows("activity", limit=limit, offset=offset) return Page(items=items, total=total, limit=limit, offset=offset) @api.get("/audit", response_model=Page) def audit( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], limit: Annotated[int, Query(ge=1, le=500)] = 100, offset: Annotated[int, Query(ge=0)] = 0, ) -> Page: items, total = app.repository.list_rows("audit_events", limit=limit, offset=offset) return Page(items=items, total=total, limit=limit, offset=offset) @api.get("/backups") def backups( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)] ) -> list[dict[str, Any]]: return app.backups.list() @api.post("/backups", status_code=201) def create_backup( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)] ) -> dict[str, Any]: return app.backups.create() @api.post("/backups/restore") def restore_backup( body: RestoreRequest, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: return app.backups.restore(body.name) @api.get("/diagnostics/export") async def export_diagnostics( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> RawResponse: payload = await app.diagnostics.build() return RawResponse( payload, media_type="application/zip", headers={"Content-Disposition": "attachment; filename=lumaops-diagnostics.zip"}, ) @api.get("/settings") def settings( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)] ) -> dict[str, Any]: with app.database.connection() as conn: rows = conn.execute( "SELECT key, value_json, updated_at FROM settings ORDER BY key" ).fetchall() return {row["key"]: json.loads(row["value_json"]) for row in rows} @api.put("/settings/{key}") def update_setting( key: str, body: SettingUpdate, app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> dict[str, Any]: allowed = { "language", "theme", "location", "retention", "safe_restore_state", "network_discovery", } if key not in allowed: raise ConflictError( "Deze instelling is alleen via de containeromgeving configureerbaar.", key=key ) now = utc_now() with app.database.connection() as conn: conn.execute( "INSERT INTO settings(key, value_json, updated_at) VALUES (?, ?, ?) " "ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, updated_at=excluded.updated_at", (key, json.dumps(body.value, separators=(",", ":")), now), ) return {"key": key, "value": body.value, "updated_at": now} @api.get("/events") async def events( app: Annotated[AppContext, Depends(context)], _actor: Annotated[str, Depends(actor)], ) -> StreamingResponse: async def stream() -> AsyncIterator[str]: async for event in app.events.subscribe(): yield f"event: {event['type']}\ndata: {json.dumps(event['data'], separators=(',', ':'))}\n\n" return StreamingResponse( stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) def _idempotency_lookup(app: AppContext, key: str, request_hash: str) -> dict[str, Any] | None: with app.database.connection() as conn: row = conn.execute( "SELECT request_hash, response_json, expires_at FROM idempotency_keys WHERE key=?", (key,), ).fetchone() if not row: return None if datetime.fromisoformat(row["expires_at"]) < datetime.now(UTC): with app.database.connection() as conn: conn.execute("DELETE FROM idempotency_keys WHERE key=?", (key,)) return None if row["request_hash"] != request_hash: raise ConflictError("De Idempotency-Key is al gebruikt voor een andere aanvraag.") return cast(dict[str, Any], json.loads(row["response_json"])) def _idempotency_store( app: AppContext, key: str, request_hash: str, response: dict[str, Any], resource_id: str, ) -> None: now = datetime.now(UTC) with app.database.connection() as conn: conn.execute( "INSERT INTO idempotency_keys(key, request_hash, response_status, response_json, resource_id, " "created_at, expires_at) VALUES (?, ?, 200, ?, ?, ?, ?)", ( key, request_hash, json.dumps(response, separators=(",", ":")), resource_id, now.isoformat(), (now + timedelta(hours=24)).isoformat(), ), )