1103 lines
35 KiB
Python
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(),
|
|
),
|
|
)
|