Files
LumaOps-Public/lumaops/backend/src/lumaops_backend/api/router.py
T
2026-09-30 00:26:45 +00:00

1103 lines
35 KiB
Python

"""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(),
),
)