Publish LumaOps source

This commit is contained in:
LumaOps release export
2026-09-03 01:18:36 +02:00
commit 7f1c0e5f71
2363 changed files with 501543 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# LumaOps backend
FastAPI backend and connector runtime for LumaOps. The package is licensed
under GPL-2.0-or-later and is built into the single production image from the
repository root.
+56
View File
@@ -0,0 +1,56 @@
[build-system]
requires = ["setuptools==80.9.0", "wheel==0.45.1"]
build-backend = "setuptools.build_meta"
[project]
name = "lumaops-backend"
version = "0.1.0"
description = "Local-first OpenRGB control plane for LumaOps"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "GPL-2.0-or-later" }
dependencies = [
"cryptography==45.0.3",
"fastapi==0.115.12",
"httpx==0.28.1",
"pydantic==2.11.5",
"pydantic-settings==2.9.1",
"python-multipart==0.0.20",
"tzdata==2026.3",
"uvicorn[standard]==0.34.3",
]
[project.optional-dependencies]
dev = [
"mypy==1.16.0",
"pytest==8.4.0",
"pytest-asyncio==1.0.0",
"ruff==0.11.12",
]
[project.scripts]
lumaops = "lumaops_backend.main:run"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
lumaops_backend = ["migrations/*.sql"]
[tool.pytest.ini_options]
addopts = "-q"
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
ignore = ["E501", "S101", "S104", "S608"]
[tool.mypy]
python_version = "3.12"
strict = true
packages = ["lumaops_backend"]
@@ -0,0 +1,3 @@
"""LumaOps backend package."""
__version__ = "0.1.0"
@@ -0,0 +1 @@
"""LumaOps API package."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
"""Application dependency container and lifecycle orchestration."""
from __future__ import annotations
from dataclasses import dataclass
from .auth import AuthManager
from .config import Settings
from .connectors.mock import MockOpenRGBAdapter
from .connectors.openrgb import OpenRGBAdapter
from .connectors.registry import ConnectorRegistry
from .database import Database
from .events import EventBus
from .repository import Repository
from .secrets import SecretStore
from .services.automations import AutomationService
from .services.backups import BackupService
from .services.commands import CommandService
from .services.diagnostics import DiagnosticsService
from .services.health import HealthService
from .services.inventory import InventoryService
from .services.resources import ResourceService
from .services.scenes import SceneService
from .services.setup import SetupService
@dataclass(slots=True)
class AppContext:
settings: Settings
database: Database
repository: Repository
secrets: SecretStore
auth: AuthManager
events: EventBus
registry: ConnectorRegistry
inventory: InventoryService
commands: CommandService
resources: ResourceService
scenes: SceneService
automations: AutomationService
setup: SetupService
health: HealthService
backups: BackupService
diagnostics: DiagnosticsService
async def start(self) -> None:
self.database.initialize()
await self.inventory.initialize()
# Perform one complete inventory and desired-state reconciliation before the
# connector monitor starts. Starting both paths concurrently can produce
# duplicate restores and briefly expose stale hardware state as ready.
await self.inventory.sync_all()
await self.registry.start()
await self.automations.start()
async def stop(self) -> None:
await self.automations.stop()
await self.registry.stop()
def build_context(settings: Settings) -> AppContext:
for path in (
settings.config_dir,
settings.openrgb_config_dir,
settings.data_dir,
settings.logs_dir,
):
path.mkdir(parents=True, exist_ok=True)
database = Database(settings)
database.initialize()
connector = (
MockOpenRGBAdapter() if settings.connector_mode == "mock" else OpenRGBAdapter(settings)
)
registry = ConnectorRegistry([connector])
events = EventBus()
inventory = InventoryService(database, registry, events)
commands = CommandService(settings, database, registry, inventory, events)
inventory.set_reconcile_callback(commands.restore_connector)
resources = ResourceService(database)
scenes = SceneService(database, commands, inventory, resources)
automations = AutomationService(database, commands, scenes)
health = HealthService(settings, database, registry)
return AppContext(
settings=settings,
database=database,
repository=Repository(database),
secrets=SecretStore(settings),
auth=AuthManager(settings),
events=events,
registry=registry,
inventory=inventory,
commands=commands,
resources=resources,
scenes=scenes,
automations=automations,
setup=SetupService(settings, database, registry),
health=health,
backups=BackupService(database),
diagnostics=DiagnosticsService(settings, database, health, inventory),
)
+106
View File
@@ -0,0 +1,106 @@
"""Optional local-admin authentication with signed cookies and CSRF checks."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import time
from typing import Any
from fastapi import Request, Response
from .config import Settings
from .errors import LumaOpsError
SESSION_COOKIE = "lumaops_session"
CSRF_COOKIE = "lumaops_csrf"
SESSION_MAX_AGE = 12 * 60 * 60
class AuthManager:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._key = (settings.admin_token or "auth-disabled").encode("utf-8")
def require(self, request: Request) -> str:
if not self.settings.auth_enabled:
return "local-admin"
authorization = request.headers.get("authorization", "")
if authorization.startswith("Bearer ") and self._valid_token(authorization[7:]):
return "api-token"
session = request.cookies.get(SESSION_COOKIE)
if not session or not self._verify_session(session):
raise LumaOpsError("authentication_required", "Aanmelding is vereist.", 401)
if request.method not in {"GET", "HEAD", "OPTIONS"}:
csrf_cookie = request.cookies.get(CSRF_COOKIE, "")
csrf_header = request.headers.get("x-csrf-token", "")
if not csrf_cookie or not hmac.compare_digest(csrf_cookie, csrf_header):
raise LumaOpsError("csrf_failed", "CSRF-validatie is mislukt.", 403)
return "local-admin"
def login(self, token: str, response: Response) -> dict[str, Any]:
if not self.settings.auth_enabled:
return {"authenticated": True, "auth_enabled": False}
if not self._valid_token(token):
raise LumaOpsError("invalid_credentials", "Ongeldige beheertoken.", 401)
csrf = secrets.token_urlsafe(32)
session = self._create_session()
response.set_cookie(
SESSION_COOKIE,
session,
max_age=SESSION_MAX_AGE,
httponly=True,
secure=self.settings.secure_cookies,
samesite="strict",
path="/",
)
response.set_cookie(
CSRF_COOKIE,
csrf,
max_age=SESSION_MAX_AGE,
httponly=False,
secure=self.settings.secure_cookies,
samesite="strict",
path="/",
)
return {"authenticated": True, "auth_enabled": True, "csrf_token": csrf}
@staticmethod
def logout(response: Response) -> None:
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
def _valid_token(self, candidate: str) -> bool:
configured = self.settings.admin_token
return configured is not None and hmac.compare_digest(
candidate.encode("utf-8"), configured.encode("utf-8")
)
def _create_session(self) -> str:
payload = json.dumps(
{"sub": "local-admin", "iat": int(time.time()), "nonce": secrets.token_hex(16)},
separators=(",", ":"),
).encode("utf-8")
encoded = base64.urlsafe_b64encode(payload).rstrip(b"=")
signature = hmac.new(self._key, encoded, hashlib.sha256).digest()
return f"{encoded.decode()}.{base64.urlsafe_b64encode(signature).decode().rstrip('=')}"
def _verify_session(self, value: str) -> bool:
try:
encoded, signature = value.split(".", 1)
expected = hmac.new(self._key, encoded.encode("ascii"), hashlib.sha256).digest()
actual = base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4))
if not hmac.compare_digest(expected, actual):
return False
payload = json.loads(
base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode("utf-8")
)
return (
payload.get("sub") == "local-admin"
and int(payload["iat"]) + SESSION_MAX_AGE > time.time()
)
except (ValueError, KeyError, TypeError, json.JSONDecodeError):
return False
@@ -0,0 +1,117 @@
"""Application configuration with deployment-safe validation."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Runtime configuration sourced from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
app_name: str = "LumaOps"
app_version: str = "0.1.0"
app_host: str = "0.0.0.0"
app_port: int = Field(default=8080, ge=1, le=65535)
app_base_url: str = ""
environment: Literal["development", "test", "production"] = Field(
default="production", alias="LUMAOPS_ENV"
)
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
timezone: str = Field(default="Europe/Brussels", alias="TZ")
openrgb_host: str = "127.0.0.1"
openrgb_port: int = Field(default=6742, ge=1, le=65535)
openrgb_config_dir: Path = Path("/config/openrgb")
openrgb_connect_timeout: float = Field(default=2.0, gt=0, le=30)
openrgb_command_timeout: float = Field(default=5.0, gt=0, le=60)
openrgb_max_packet_size: int = Field(default=16 * 1024 * 1024, ge=1024, le=64 * 1024 * 1024)
config_dir: Path = Path("/config/lumaops")
data_dir: Path = Path("/data")
logs_dir: Path = Path("/logs")
static_dir: Path = Path("/opt/lumaops/static")
database_url: str = "sqlite:////data/lumaops.db"
secret_key: str | None = Field(default=None, alias="LUMAOPS_SECRET_KEY")
enable_network_discovery: bool = True
enable_home_assistant: bool = False
enable_wled: bool = False
connector_mode: Literal["openrgb", "mock"] = "openrgb"
auth_enabled: bool = True
admin_token: str | None = Field(default=None, alias="LUMAOPS_ADMIN_TOKEN")
external_access: bool = False
trusted_proxies: str = ""
cors_origins: str = ""
secure_cookies: bool = True
command_rate_per_second: float = Field(default=10.0, gt=0, le=60)
realtime_rate_per_second: float = Field(default=30.0, gt=0, le=120)
command_timeout_seconds: float = Field(default=10.0, gt=0, le=120)
health_retention_days: int = Field(default=30, ge=1, le=3650)
@field_validator("openrgb_host")
@classmethod
def validate_openrgb_host(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("OPENRGB_HOST mag niet leeg zijn")
return value
@model_validator(mode="after")
def validate_security_invariants(self) -> Settings:
if self.environment == "production" and self.connector_mode == "mock":
raise ValueError("MockOpenRGBAdapter is verboden in productie")
if self.external_access and not self.auth_enabled:
raise ValueError("AUTH_ENABLED is verplicht wanneer EXTERNAL_ACCESS=true")
if self.environment == "production" and self.app_host not in {
"127.0.0.1",
"::1",
"localhost",
} and not self.auth_enabled:
raise ValueError("AUTH_ENABLED is verplicht bij een niet-lokale productiebind")
if self.auth_enabled:
token = (self.admin_token or "").strip()
placeholders = {
"replace-with-a-long-random-token",
"change-me",
"changeme",
}
if len(token) < 32 or token.lower() in placeholders:
raise ValueError(
"LUMAOPS_ADMIN_TOKEN moet minstens 32 tekens lang en niet voorspelbaar zijn"
)
return self
@property
def database_path(self) -> Path:
prefix = "sqlite:///"
if not self.database_url.startswith(prefix):
raise ValueError("LumaOps MVP ondersteunt uitsluitend sqlite:/// DATABASE_URL waarden")
raw = self.database_url[len(prefix) :]
return Path(raw if raw else "lumaops.db").expanduser().resolve()
@property
def trusted_proxy_list(self) -> tuple[str, ...]:
return tuple(part.strip() for part in self.trusted_proxies.split(",") if part.strip())
@property
def cors_origin_list(self) -> tuple[str, ...]:
return tuple(part.strip() for part in self.cors_origins.split(",") if part.strip())
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
@@ -0,0 +1 @@
"""Connector implementations and normalized contracts."""
@@ -0,0 +1,165 @@
"""Brand-neutral connector contract used by every application service."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from datetime import datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field, field_validator
from ..errors import ConnectorError
class HealthStatus(StrEnum):
HEALTHY = "healthy"
DEGRADED = "degraded"
UNHEALTHY = "unhealthy"
UNKNOWN = "unknown"
class ConnectorHealth(BaseModel):
status: HealthStatus
message: str
connected: bool = False
latency_ms: float | None = None
last_success_at: datetime | None = None
details: dict[str, Any] = Field(default_factory=dict)
class DeviceCapabilities(BaseModel):
power: bool = False
restore: bool = False
rgb: bool = False
brightness: bool = False
color_temperature: bool = False
effect: bool = False
speed: bool = False
direction: bool = False
multiple_colors: bool = False
per_zone: bool = False
per_segment: bool = False
per_led: bool = False
profiles: bool = False
readable_state: bool = True
max_leds: int = 0
min_brightness: int = 0
max_brightness: int = 100
min_speed: int | None = None
max_speed: int | None = None
class RGBColor(BaseModel):
red: int = Field(ge=0, le=255)
green: int = Field(ge=0, le=255)
blue: int = Field(ge=0, le=255)
@classmethod
def from_hex(cls, value: str) -> RGBColor:
value = value.removeprefix("#")
if len(value) != 6:
raise ValueError("Een RGB-kleur vereist exact zes hexadecimale tekens")
try:
return cls(red=int(value[0:2], 16), green=int(value[2:4], 16), blue=int(value[4:6], 16))
except ValueError as exc:
raise ValueError("Ongeldige hexadecimale RGB-kleur") from exc
def to_hex(self) -> str:
return f"#{self.red:02X}{self.green:02X}{self.blue:02X}"
class DeviceState(BaseModel):
power: bool | None = None
brightness: int | None = Field(default=None, ge=0, le=100)
colors: list[RGBColor] | None = None
mode: str | None = None
mode_index: int | None = Field(default=None, ge=0)
speed: int | None = Field(default=None, ge=0)
direction: int | None = Field(default=None, ge=0, le=5)
zone_index: int | None = Field(default=None, ge=0)
led_index: int | None = Field(default=None, ge=0)
transition_ms: int | None = Field(default=None, ge=0, le=60_000)
@field_validator("colors")
@classmethod
def limit_colors(cls, value: list[RGBColor] | None) -> list[RGBColor] | None:
if value is not None and len(value) > 65_535:
raise ValueError("Te veel kleuren in één opdracht")
return value
class ConnectorDevice(BaseModel):
external_id: str
fingerprint: str
name: str
vendor: str | None = None
model: str | None = None
serial: str | None = None
location: str | None = None
ip_address: str | None = None
firmware_version: str | None = None
controller_index: int | None = None
source: str
device_type: str = "unknown"
capabilities: DeviceCapabilities
state: DeviceState = Field(default_factory=DeviceState)
zones: list[dict[str, Any]] = Field(default_factory=list)
modes: list[dict[str, Any]] = Field(default_factory=list)
led_count: int = 0
online: bool = True
experimental: bool = False
metadata: dict[str, Any] = Field(default_factory=dict)
InventoryCallback = Callable[[str], Awaitable[Any]]
class Connector(ABC):
id: str
kind: str
@abstractmethod
async def start(self) -> None: ...
@abstractmethod
async def stop(self) -> None: ...
@abstractmethod
async def test_connection(self) -> ConnectorHealth: ...
@abstractmethod
async def health(self) -> ConnectorHealth: ...
@abstractmethod
async def discover(self) -> list[ConnectorDevice]: ...
@abstractmethod
async def inventory(self) -> list[ConnectorDevice]: ...
async def rescan(self) -> None:
"""Request fresh discovery when a connector has no specialized rescan command."""
await self.inventory()
@abstractmethod
async def get_state(self, external_id: str) -> DeviceState: ...
@abstractmethod
async def set_state(self, external_id: str, state: DeviceState) -> DeviceState: ...
async def resize_zone(self, external_id: str, zone_index: int, new_size: int) -> None:
"""Resize an addressable zone when the connector supports it."""
del external_id, zone_index, new_size
raise ConnectorError(
"zone_resize_unsupported",
"Deze connector ondersteunt geen wijzigbare zones.",
status_code=409,
retryable=False,
)
@abstractmethod
def configuration_schema(self) -> dict[str, Any]: ...
@abstractmethod
def set_inventory_callback(self, callback: InventoryCallback | None) -> None: ...
@@ -0,0 +1,214 @@
"""Deterministic connector for tests and explicit non-production demos."""
from __future__ import annotations
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
from ..errors import ConnectorError
from .base import (
Connector,
ConnectorDevice,
ConnectorHealth,
DeviceCapabilities,
DeviceState,
HealthStatus,
InventoryCallback,
RGBColor,
)
class MockOpenRGBAdapter(Connector):
id = "openrgb-mock"
kind = "mock-openrgb"
def __init__(self) -> None:
self._started = False
self._callback: InventoryCallback | None = None
self._devices = {
"mock-mainboard": ConnectorDevice(
external_id="mock-mainboard",
fingerprint="mock-mainboard",
name="Aurora Mainboard",
vendor="LumaOps Lab",
model="Virtual RGB Controller",
serial="MOCK-001",
location="mock://mainboard",
firmware_version="1.0",
controller_index=0,
source="mock",
device_type="motherboard",
capabilities=DeviceCapabilities(
power=True,
restore=True,
rgb=True,
brightness=True,
effect=True,
speed=True,
multiple_colors=True,
per_zone=True,
per_led=True,
profiles=True,
max_leds=12,
min_speed=1,
max_speed=10,
),
state=DeviceState(
power=True,
brightness=72,
colors=[RGBColor(red=86, green=92, blue=255)] * 12,
mode="Static",
mode_index=0,
speed=5,
),
zones=[
{
"index": 0,
"name": "Mainboard",
"type": 1,
"led_count": 8,
"leds_min": 8,
"leds_max": 8,
},
{
"index": 1,
"name": "ARGB header",
"type": 1,
"led_count": 4,
"leds_min": 0,
"leds_max": 120,
},
],
modes=[
{"index": 0, "name": "Static", "brightness": True},
{"index": 1, "name": "Breathing", "speed_min": 1, "speed_max": 10},
{"index": 2, "name": "Rainbow", "speed_min": 1, "speed_max": 10},
],
led_count=12,
),
"mock-desk-light": ConnectorDevice(
external_id="mock-desk-light",
fingerprint="mock-desk-light",
name="Bureaulamp",
vendor="WLED",
model="ESP32 strip",
serial="AA:BB:CC:DD:EE:01",
location="mock://desk",
ip_address="192.0.2.42",
firmware_version="0.15.0",
controller_index=1,
source="mock",
device_type="light",
capabilities=DeviceCapabilities(
power=True,
restore=True,
rgb=True,
brightness=True,
effect=True,
multiple_colors=True,
per_zone=True,
max_leds=30,
),
state=DeviceState(
power=False,
brightness=40,
colors=[RGBColor(red=255, green=154, blue=92)],
mode="Solid",
),
zones=[
{
"index": 0,
"name": "Desk",
"type": 1,
"led_count": 30,
"leds_min": 30,
"leds_max": 30,
}
],
modes=[{"index": 0, "name": "Solid"}, {"index": 1, "name": "Colorloop"}],
led_count=30,
),
}
async def start(self) -> None:
self._started = True
async def stop(self) -> None:
self._started = False
def set_inventory_callback(self, callback: InventoryCallback | None) -> None:
self._callback = callback
async def test_connection(self) -> ConnectorHealth:
return await self.health()
async def health(self) -> ConnectorHealth:
return ConnectorHealth(
status=HealthStatus.HEALTHY if self._started else HealthStatus.DEGRADED,
message="Mockadapter actief — geen echte hardware."
if self._started
else "Mockadapter gestopt.",
connected=self._started,
last_success_at=datetime.now(UTC) if self._started else None,
details={"mock": True, "device_count": len(self._devices)},
)
async def discover(self) -> list[ConnectorDevice]:
return await self.inventory()
async def inventory(self) -> list[ConnectorDevice]:
return [deepcopy(device) for device in self._devices.values()]
async def get_state(self, external_id: str) -> DeviceState:
return deepcopy(self._devices[external_id].state)
async def set_state(self, external_id: str, state: DeviceState) -> DeviceState:
current = self._devices[external_id].state
patch = state.model_dump(exclude_none=True)
merged = current.model_dump()
merged.update(patch)
updated = DeviceState.model_validate(merged)
if updated.power is False:
updated.colors = [RGBColor(red=0, green=0, blue=0)] * max(
1, self._devices[external_id].led_count
)
elif updated.power is True and not updated.colors:
updated.colors = [RGBColor(red=255, green=255, blue=255)]
self._devices[external_id].state = updated
return deepcopy(updated)
async def resize_zone(self, external_id: str, zone_index: int, new_size: int) -> None:
device = self._devices[external_id]
if not 0 <= zone_index < len(device.zones):
raise ConnectorError(
"zone_index_invalid",
"OpenRGB-zoneindex valt buiten bereik.",
status_code=400,
)
zone = device.zones[zone_index]
lower = int(zone.get("leds_min", zone["led_count"]))
upper = int(zone.get("leds_max", zone["led_count"]))
if not min(lower, upper) <= new_size <= max(lower, upper):
raise ConnectorError(
"zone_size_invalid",
"Nieuwe OpenRGB-zonegrootte valt buiten bereik.",
status_code=400,
details={"minimum": lower, "maximum": upper},
)
zone["led_count"] = new_size
device.led_count = sum(int(item["led_count"]) for item in device.zones)
color = (
device.state.colors[0]
if device.state.colors
else RGBColor(red=255, green=255, blue=255)
)
device.state.colors = [color] * device.led_count
def configuration_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {},
"secret_fields": [],
"warning": "TESTMODUS: opdrachten bereiken geen echte hardware.",
}
@@ -0,0 +1,6 @@
"""OpenRGB SDK protocol 5 connector."""
from .adapter import OpenRGBAdapter
__all__ = ["OpenRGBAdapter"]
@@ -0,0 +1,680 @@
"""Async OpenRGB SDK v5 adapter with reconnect and serialized writes."""
from __future__ import annotations
import asyncio
import contextlib
import logging
import struct
import time
from datetime import UTC, datetime
from typing import Any
from ...config import Settings
from ...errors import ConnectorError
from ..base import (
Connector,
ConnectorDevice,
ConnectorHealth,
DeviceState,
HealthStatus,
InventoryCallback,
RGBColor,
)
from .protocol import (
HEADER,
PROTOCOL_VERSION,
Controller,
Mode,
ModeFlag,
PacketHeader,
PacketId,
ProtocolError,
ZoneFlag,
pack_header,
pack_mode,
pack_update_leds,
pack_update_single_led,
pack_update_zone,
parse_controller,
parse_header,
parse_profile_list,
)
LOGGER = logging.getLogger(__name__)
class OpenRGBAdapter(Connector):
id = "openrgb-local"
kind = "openrgb"
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._reader_task: asyncio.Task[None] | None = None
self._monitor_task: asyncio.Task[None] | None = None
self._connect_lock = asyncio.Lock()
self._request_lock = asyncio.Lock()
self._write_lock = asyncio.Lock()
self._response_queues: dict[int, asyncio.Queue[tuple[PacketHeader, bytes] | Exception]] = {}
self._controllers: dict[str, Controller] = {}
self._inventory_callback: InventoryCallback | None = None
self._stopping = False
self._connected_at: datetime | None = None
self._last_success_at: datetime | None = None
self._last_error: str | None = None
self._protocol_version: int | None = None
self._reconnect_attempt = 0
self._last_nonzero: dict[str, list[RGBColor]] = {}
@property
def connected(self) -> bool:
return self._writer is not None and not self._writer.is_closing() and self._protocol_version == 5
async def start(self) -> None:
if self._monitor_task and not self._monitor_task.done():
return
self._stopping = False
self._monitor_task = asyncio.create_task(self._monitor(), name="openrgb-monitor")
async def stop(self) -> None:
self._stopping = True
if self._monitor_task:
self._monitor_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._monitor_task
await self._disconnect()
def set_inventory_callback(self, callback: InventoryCallback | None) -> None:
self._inventory_callback = callback
async def _monitor(self) -> None:
while not self._stopping:
try:
await self._ensure_connected()
if not self._controllers:
await self.inventory()
await self._notify_inventory_callback()
await asyncio.sleep(10)
except asyncio.CancelledError:
raise
except Exception as exc:
self._last_error = str(exc)
LOGGER.warning(
"OpenRGB connection unavailable: %s",
exc,
extra={"connector": self.id, "error_category": "connection"},
)
await self._disconnect()
delay = min(30.0, 0.5 * (2 ** min(self._reconnect_attempt, 6)))
self._reconnect_attempt += 1
await asyncio.sleep(delay)
async def _ensure_connected(self) -> None:
if self.connected:
return
async with self._connect_lock:
if self.connected:
return
await self._disconnect()
try:
connect = asyncio.open_connection(self.settings.openrgb_host, self.settings.openrgb_port)
self._reader, self._writer = await asyncio.wait_for(
connect, timeout=self.settings.openrgb_connect_timeout
)
self._reader_task = asyncio.create_task(self._reader_loop(), name="openrgb-reader")
header, payload = await self._request_connected(
PacketId.REQUEST_PROTOCOL_VERSION,
struct.pack("<I", PROTOCOL_VERSION),
timeout=self.settings.openrgb_connect_timeout,
)
del header
if len(payload) != 4:
raise ProtocolError("OpenRGB-protocolantwoord heeft geen vier bytes.")
server_version = struct.unpack("<I", payload)[0]
if server_version != PROTOCOL_VERSION:
raise ConnectorError(
"openrgb_version_mismatch",
f"OpenRGB SDK-protocol {server_version} is niet compatibel met vereist protocol 5.",
status_code=503,
retryable=False,
details={"server_version": server_version, "required_version": PROTOCOL_VERSION},
)
self._protocol_version = server_version
await self._send_connected(
0, PacketId.SET_CLIENT_NAME, b"LumaOps\0", expect_response=False
)
self._connected_at = datetime.now(UTC)
self._last_success_at = self._connected_at
self._last_error = None
self._reconnect_attempt = 0
LOGGER.info(
"Connected to OpenRGB SDK protocol 5",
extra={"connector": self.id},
)
except Exception:
await self._disconnect()
raise
async def _disconnect(self) -> None:
writer, self._writer = self._writer, None
reader_task, self._reader_task = self._reader_task, None
self._reader = None
self._protocol_version = None
self._controllers.clear()
if reader_task and reader_task is not asyncio.current_task():
reader_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await reader_task
if writer:
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
error = ConnectorError(
"openrgb_disconnected",
"De verbinding met OpenRGB is verbroken.",
retryable=True,
)
for queue in self._response_queues.values():
with contextlib.suppress(asyncio.QueueFull):
queue.put_nowait(error)
async def _reader_loop(self) -> None:
assert self._reader is not None
try:
while not self._stopping:
raw_header = await self._reader.readexactly(HEADER.size)
header = parse_header(raw_header, self.settings.openrgb_max_packet_size)
payload = await self._reader.readexactly(header.payload_size)
if header.packet_id == PacketId.DEVICE_LIST_UPDATED:
if payload:
raise ProtocolError("Device-list-updated bevat onverwachte data.")
self._controllers.clear()
if self._inventory_callback:
asyncio.create_task(
self._notify_inventory_callback(), name="openrgb-inventory-change"
)
continue
queue = self._response_queues.setdefault(header.packet_id, asyncio.Queue(maxsize=1))
if queue.full():
raise ProtocolError(
"Onverwacht dubbel OpenRGB-antwoord.", packet_id=header.packet_id
)
queue.put_nowait((header, payload))
except asyncio.CancelledError:
raise
except (asyncio.IncompleteReadError, ConnectionError, OSError, ProtocolError) as exc:
self._last_error = str(exc)
error = ConnectorError(
"openrgb_connection_lost",
"De verbinding met OpenRGB is onverwacht verbroken.",
retryable=True,
details={"reason": str(exc)},
)
for queue in self._response_queues.values():
with contextlib.suppress(asyncio.QueueFull):
queue.put_nowait(error)
if self._writer:
self._writer.close()
async def _notify_inventory_callback(self) -> None:
if self._inventory_callback:
await self._inventory_callback(self.id)
async def _send_connected(
self,
device_index: int,
packet_id: int | PacketId,
payload: bytes = b"",
*,
expect_response: bool = False,
) -> None:
del expect_response
if self._writer is None or self._writer.is_closing():
raise ConnectorError(
"openrgb_disconnected", "OpenRGB is niet verbonden.", retryable=True
)
if len(payload) > self.settings.openrgb_max_packet_size:
raise ProtocolError("Uitgaand OpenRGB-pakket is te groot.", size=len(payload))
self._writer.write(pack_header(device_index, packet_id, len(payload)) + payload)
await self._writer.drain()
async def _request_connected(
self,
packet_id: int | PacketId,
payload: bytes = b"",
*,
device_index: int = 0,
timeout: float | None = None,
) -> tuple[PacketHeader, bytes]:
packet_value = int(packet_id)
async with self._request_lock:
queue = self._response_queues.setdefault(packet_value, asyncio.Queue(maxsize=1))
while not queue.empty():
queue.get_nowait()
await self._send_connected(device_index, packet_id, payload, expect_response=True)
try:
result = await asyncio.wait_for(
queue.get(), timeout=timeout or self.settings.openrgb_command_timeout
)
except TimeoutError as exc:
raise ConnectorError(
"openrgb_timeout",
"OpenRGB antwoordde niet binnen de ingestelde tijd.",
retryable=True,
details={"packet_id": packet_value},
) from exc
if isinstance(result, Exception):
raise result
return result
async def _request(
self,
packet_id: int | PacketId,
payload: bytes = b"",
*,
device_index: int = 0,
) -> tuple[PacketHeader, bytes]:
await self._ensure_connected()
return await self._request_connected(
packet_id, payload, device_index=device_index
)
async def _write(self, device_index: int, packet_id: int | PacketId, payload: bytes = b"") -> None:
await self._ensure_connected()
async with self._write_lock:
await asyncio.wait_for(
self._send_connected(device_index, packet_id, payload),
timeout=self.settings.openrgb_command_timeout,
)
self._last_success_at = datetime.now(UTC)
async def test_connection(self) -> ConnectorHealth:
started = time.perf_counter()
try:
await self._ensure_connected()
await self._request(PacketId.REQUEST_CONTROLLER_COUNT)
return ConnectorHealth(
status=HealthStatus.HEALTHY,
message="OpenRGB SDK protocol 5 is bereikbaar.",
connected=True,
latency_ms=(time.perf_counter() - started) * 1000,
last_success_at=datetime.now(UTC),
details={"protocol_version": self._protocol_version},
)
except Exception as exc:
return ConnectorHealth(
status=HealthStatus.DEGRADED,
message=str(exc),
connected=False,
latency_ms=(time.perf_counter() - started) * 1000,
last_success_at=self._last_success_at,
details={"host": self.settings.openrgb_host, "port": self.settings.openrgb_port},
)
async def health(self) -> ConnectorHealth:
return ConnectorHealth(
status=HealthStatus.HEALTHY if self.connected else HealthStatus.DEGRADED,
message="OpenRGB is verbonden." if self.connected else (self._last_error or "OpenRGB is niet verbonden."),
connected=self.connected,
last_success_at=self._last_success_at,
details={
"host": self.settings.openrgb_host,
"port": self.settings.openrgb_port,
"protocol_version": self._protocol_version,
"controllers": len(self._controllers),
"connected_at": self._connected_at.isoformat() if self._connected_at else None,
},
)
async def discover(self) -> list[ConnectorDevice]:
return await self.inventory()
async def inventory(self) -> list[ConnectorDevice]:
_header, payload = await self._request(PacketId.REQUEST_CONTROLLER_COUNT)
if len(payload) != 4:
raise ProtocolError("OpenRGB-controllertelling heeft geen vier bytes.")
count = struct.unpack("<I", payload)[0]
if count > 4096:
raise ProtocolError("OpenRGB rapporteert onredelijk veel controllers.", count=count)
controllers: dict[str, Controller] = {}
for index in range(count):
_header, controller_payload = await self._request(
PacketId.REQUEST_CONTROLLER_DATA,
struct.pack("<I", PROTOCOL_VERSION),
device_index=index,
)
controller = parse_controller(controller_payload, index)
controllers[controller.fingerprint] = controller
self._controllers = controllers
self._last_success_at = datetime.now(UTC)
return [self._normalize(controller) for controller in controllers.values()]
def _normalize(self, controller: Controller) -> ConnectorDevice:
active_mode = next(
(mode for mode in controller.modes if mode.index == controller.active_mode), None
)
active_flags = ModeFlag(active_mode.flags) if active_mode else ModeFlag(0)
colors = (
active_mode.colors
if active_mode and ModeFlag.HAS_MODE_SPECIFIC_COLOR in active_flags
else controller.colors
)
direct_mode = not active_mode or active_mode.name.casefold() in {"direct", "custom"}
if active_mode and active_mode.name.casefold() == "off":
power = False
elif direct_mode:
power = any(color.red or color.green or color.blue for color in controller.colors)
else:
power = True
brightness = None
if active_mode and ModeFlag.HAS_BRIGHTNESS in ModeFlag(active_mode.flags):
span = active_mode.brightness_max - active_mode.brightness_min
brightness = (
round((active_mode.brightness - active_mode.brightness_min) * 100 / span)
if span
else 100
)
return ConnectorDevice(
external_id=controller.fingerprint,
fingerprint=controller.fingerprint,
name=controller.name,
vendor=controller.vendor or None,
model=controller.description or controller.name,
serial=controller.serial or None,
location=controller.location or None,
firmware_version=controller.firmware_version or None,
controller_index=controller.index,
source="openrgb",
device_type=controller.device_type_name,
capabilities=controller.capabilities,
state=DeviceState(
power=power,
brightness=brightness,
colors=colors,
mode=active_mode.name if active_mode else None,
mode_index=active_mode.index if active_mode else None,
speed=active_mode.speed if active_mode and active_mode.flags & ModeFlag.HAS_SPEED else None,
direction=(
active_mode.direction
if active_mode
and active_mode.flags
& (ModeFlag.HAS_DIRECTION_LR | ModeFlag.HAS_DIRECTION_UD | ModeFlag.HAS_DIRECTION_HV)
else None
),
),
zones=[
{
"index": zone.index,
"name": zone.name,
"type": zone.zone_type,
"led_count": zone.led_count,
"leds_min": zone.leds_min,
"leds_max": zone.leds_max,
"start_index": zone.start_index,
"resizable_effects_only": bool(zone.flags & ZoneFlag.RESIZE_EFFECTS_ONLY),
"segments": [
{
"name": segment.name,
"type": segment.zone_type,
"start_index": segment.start_index,
"led_count": segment.led_count,
}
for segment in zone.segments
],
}
for zone in controller.zones
],
modes=[
{
"index": mode.index,
"name": mode.name,
"flags": mode.flags,
"speed_min": mode.speed_min if mode.flags & ModeFlag.HAS_SPEED else None,
"speed_max": mode.speed_max if mode.flags & ModeFlag.HAS_SPEED else None,
"brightness": bool(mode.flags & ModeFlag.HAS_BRIGHTNESS),
"colors_min": mode.colors_min,
"colors_max": mode.colors_max,
}
for mode in controller.modes
],
led_count=len(controller.leds),
metadata={"controller_flags": controller.flags, "led_alt_names": controller.led_alt_names},
)
def _controller(self, external_id: str) -> Controller:
try:
return self._controllers[external_id]
except KeyError as exc:
raise ConnectorError(
"openrgb_identity_changed",
"De OpenRGB-controlleridentiteit is gewijzigd; voer eerst een rescan uit.",
status_code=409,
retryable=True,
) from exc
async def get_state(self, external_id: str) -> DeviceState:
controller = self._controller(external_id)
_header, payload = await self._request(
PacketId.REQUEST_CONTROLLER_DATA,
struct.pack("<I", PROTOCOL_VERSION),
device_index=controller.index,
)
refreshed = parse_controller(payload, controller.index)
if refreshed.fingerprint != external_id:
self._controllers.clear()
raise ConnectorError(
"openrgb_identity_mismatch",
"OpenRGB rapporteert een ander apparaat op de verwachte controllerindex.",
status_code=409,
retryable=True,
)
self._controllers[external_id] = refreshed
return self._normalize(refreshed).state
async def set_state(self, external_id: str, state: DeviceState) -> DeviceState:
controller = self._controller(external_id)
# Re-read identity immediately before a hardware write.
await self.get_state(external_id)
controller = self._controller(external_id)
colors = list(state.colors or [])
selected_mode: Mode | None = None
mode_specific_colors = False
if (
state.mode is not None
or state.mode_index is not None
or state.brightness is not None
or state.speed is not None
or state.direction is not None
):
selected_mode = self._select_mode(controller, state)
mode = selected_mode
mode_flags = ModeFlag(mode.flags)
mode_specific_colors = bool(
colors
and ModeFlag.HAS_MODE_SPECIFIC_COLOR in mode_flags
and state.zone_index is None
and state.led_index is None
)
brightness_percent = state.brightness
if (
brightness_percent is not None
and ModeFlag.HAS_BRIGHTNESS not in ModeFlag(mode.flags)
and colors
):
LOGGER.info(
"OpenRGB brightness omitted for color write: controller=%s mode=%s",
controller.index,
mode.name,
)
brightness_percent = None
payload = pack_mode(
mode,
mode.index,
brightness_percent=brightness_percent,
speed=state.speed,
direction=state.direction,
colors=colors if mode_specific_colors else None,
)
await self._write(controller.index, PacketId.UPDATE_MODE, payload)
controller.active_mode = mode.index
if brightness_percent is not None:
span = mode.brightness_max - mode.brightness_min
mode.brightness = round(mode.brightness_min + span * brightness_percent / 100)
if state.speed is not None:
mode.speed = state.speed
if state.direction is not None:
mode.direction = state.direction
if mode_specific_colors:
mode.colors = list(colors)
has_leds = bool(controller.leds)
if state.power is False:
if has_leds and controller.colors and any(
c.red or c.green or c.blue for c in controller.colors
):
self._last_nonzero[external_id] = list(controller.colors)
colors = [RGBColor(red=0, green=0, blue=0)] if has_leds else []
mode_specific_colors = False
elif state.power is True and not colors and selected_mode is None and has_leds:
colors = self._last_nonzero.get(
external_id, [RGBColor(red=255, green=255, blue=255)]
)
if colors and not mode_specific_colors:
active_mode = next(
(mode for mode in controller.modes if mode.index == controller.active_mode),
None,
)
if (
selected_mode is not None
or active_mode is None
or active_mode.name.casefold() not in {"custom", "direct"}
):
await self._write(controller.index, PacketId.SET_CUSTOM_MODE)
custom_mode = next(
(
mode
for mode in controller.modes
if mode.name.casefold() in {"custom", "direct"}
),
None,
)
if custom_mode is not None:
controller.active_mode = custom_mode.index
if state.led_index is not None:
if len(colors) != 1:
raise ProtocolError("Een ledopdracht vereist exact één kleur.")
payload = pack_update_single_led(state.led_index, colors[0], len(controller.leds))
await self._write(controller.index, PacketId.UPDATE_SINGLE_LED, payload)
controller.colors[state.led_index] = colors[0]
elif state.zone_index is not None:
if state.zone_index >= len(controller.zones):
raise ProtocolError("OpenRGB-zoneindex valt buiten bereik.")
zone = controller.zones[state.zone_index]
expanded = expand_colors(colors, zone.led_count)
payload = pack_update_zone(zone.index, expanded, zone.led_count)
await self._write(controller.index, PacketId.UPDATE_ZONE_LEDS, payload)
controller.colors[zone.start_index : zone.start_index + zone.led_count] = expanded
else:
expanded = expand_colors(colors, len(controller.leds))
payload = pack_update_leds(expanded, len(controller.leds))
await self._write(controller.index, PacketId.UPDATE_LEDS, payload)
controller.colors = expanded
if any(color.red or color.green or color.blue for color in controller.colors):
self._last_nonzero[external_id] = list(controller.colors)
# OpenRGB may map SET_CUSTOM_MODE to a controller-specific mode (for example,
# Corsair DRAM reports Direct even when it also advertises Custom). Read the
# controller back so persisted desired state reflects hardware truth and does
# not cause an endless reconciliation loop after discovery or restart.
return await self.get_state(external_id)
def _select_mode(self, controller: Controller, state: DeviceState) -> Mode:
if state.mode_index is not None:
mode = next((item for item in controller.modes if item.index == state.mode_index), None)
elif state.mode is not None:
mode = next(
(item for item in controller.modes if item.name.casefold() == state.mode.casefold()),
None,
)
else:
mode = next(
(item for item in controller.modes if item.index == controller.active_mode), None
)
if mode is None:
raise ProtocolError("De gevraagde OpenRGB-modus bestaat niet.")
return mode
async def rescan(self) -> None:
await self._write(0, PacketId.REQUEST_RESCAN_DEVICES)
self._controllers.clear()
async def list_profiles(self) -> list[str]:
_header, payload = await self._request(PacketId.REQUEST_PROFILE_LIST)
return parse_profile_list(payload)
async def save_profile(self, name: str) -> None:
await self._profile_write(PacketId.REQUEST_SAVE_PROFILE, name)
async def load_profile(self, name: str) -> None:
await self._profile_write(PacketId.REQUEST_LOAD_PROFILE, name)
devices = await self.inventory()
for device in devices:
controller = self._controller(device.external_id)
mode = self._select_mode(controller, DeviceState())
await self._write(controller.index, PacketId.UPDATE_MODE, pack_mode(mode, mode.index))
async def delete_profile(self, name: str) -> None:
await self._profile_write(PacketId.REQUEST_DELETE_PROFILE, name)
async def _profile_write(self, packet_id: PacketId, name: str) -> None:
if not name or "\0" in name or len(name.encode("utf-8")) > 255:
raise ProtocolError("Ongeldige OpenRGB-profielnaam.")
await self._write(0, packet_id, name.encode("utf-8") + b"\0")
async def resize_zone(self, external_id: str, zone_index: int, new_size: int) -> None:
controller = self._controller(external_id)
if not 0 <= zone_index < len(controller.zones):
raise ProtocolError("OpenRGB-zoneindex valt buiten bereik.")
zone = controller.zones[zone_index]
if not min(zone.leds_min, zone.leds_max) <= new_size <= max(zone.leds_min, zone.leds_max):
raise ProtocolError("Nieuwe OpenRGB-zonegrootte valt buiten bereik.")
await self._write(controller.index, PacketId.RESIZE_ZONE, struct.pack("<ii", zone_index, new_size))
LOGGER.info(
"OpenRGB-zone resized: controller=%s zone=%s leds=%s",
controller.index,
zone_index,
new_size,
)
async def clear_segments(self, external_id: str, zone_index: int) -> None:
controller = self._controller(external_id)
if not 0 <= zone_index < len(controller.zones):
raise ProtocolError("OpenRGB-zoneindex valt buiten bereik.")
await self._write(controller.index, PacketId.CLEAR_SEGMENTS, struct.pack("<i", zone_index))
def configuration_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"host": {"type": "string", "default": "127.0.0.1"},
"port": {"type": "integer", "minimum": 1, "maximum": 65535, "default": 6742},
},
"required": ["host", "port"],
"secret_fields": [],
"warning": "OpenRGB SDK heeft geen authenticatie of TLS; gebruik uitsluitend loopback of een beveiligde tunnel.",
}
def expand_colors(colors: list[RGBColor], expected: int) -> list[RGBColor]:
if expected <= 0:
raise ProtocolError("OpenRGB-controller heeft geen adresseerbare leds.")
if colors and all(color == colors[0] for color in colors):
return [colors[0]] * expected
if len(colors) != expected:
raise ProtocolError(
"Geef één kleur of exact één kleur per led op.", colors=len(colors), expected=expected
)
return colors
@@ -0,0 +1,549 @@
"""Bounded parser and serializer for OpenRGB SDK protocol version 5."""
from __future__ import annotations
import struct
from dataclasses import dataclass, field
from enum import IntEnum, IntFlag
from hashlib import sha256
from ...errors import ConnectorError
from ..base import DeviceCapabilities, RGBColor
MAGIC = b"ORGB"
HEADER = struct.Struct("<4sIII")
PROTOCOL_VERSION = 5
DEFAULT_MAX_PACKET_SIZE = 16 * 1024 * 1024
MAX_COLLECTION_ITEMS = 65_535
MAX_STRING_BYTES = 16_384
MAX_MATRIX_CELLS = 1_000_000
class PacketId(IntEnum):
REQUEST_CONTROLLER_COUNT = 0
REQUEST_CONTROLLER_DATA = 1
REQUEST_PROTOCOL_VERSION = 40
SET_CLIENT_NAME = 50
DEVICE_LIST_UPDATED = 100
REQUEST_RESCAN_DEVICES = 140
REQUEST_PROFILE_LIST = 150
REQUEST_SAVE_PROFILE = 151
REQUEST_LOAD_PROFILE = 152
REQUEST_DELETE_PROFILE = 153
REQUEST_PLUGIN_LIST = 200
PLUGIN_SPECIFIC = 201
RESIZE_ZONE = 1000
CLEAR_SEGMENTS = 1001
ADD_SEGMENT = 1002
UPDATE_LEDS = 1050
UPDATE_ZONE_LEDS = 1051
UPDATE_SINGLE_LED = 1052
SET_CUSTOM_MODE = 1100
UPDATE_MODE = 1101
SAVE_MODE = 1102
class ModeFlag(IntFlag):
HAS_SPEED = 1 << 0
HAS_DIRECTION_LR = 1 << 1
HAS_DIRECTION_UD = 1 << 2
HAS_DIRECTION_HV = 1 << 3
HAS_BRIGHTNESS = 1 << 4
HAS_PER_LED_COLOR = 1 << 5
HAS_MODE_SPECIFIC_COLOR = 1 << 6
HAS_RANDOM_COLOR = 1 << 7
MANUAL_SAVE = 1 << 8
AUTOMATIC_SAVE = 1 << 9
class ZoneFlag(IntFlag):
RESIZE_EFFECTS_ONLY = 1 << 0
DEVICE_TYPES = {
0: "motherboard",
1: "dram",
2: "gpu",
3: "cooler",
4: "ledstrip",
5: "keyboard",
6: "mouse",
7: "mousemat",
8: "headset",
9: "headset_stand",
10: "gamepad",
11: "light",
12: "speaker",
13: "virtual",
14: "storage",
15: "case",
16: "microphone",
17: "accessory",
18: "keypad",
19: "unknown",
}
class ProtocolError(ConnectorError):
def __init__(self, message: str, **details: object) -> None:
super().__init__(
"openrgb_protocol_error",
message,
status_code=502,
retryable=False,
details={str(key): value for key, value in details.items()},
)
@dataclass(slots=True, frozen=True)
class PacketHeader:
device_index: int
packet_id: int
payload_size: int
@dataclass(slots=True)
class Mode:
index: int
name: str
value: int
flags: int
speed_min: int
speed_max: int
brightness_min: int
brightness_max: int
colors_min: int
colors_max: int
speed: int
brightness: int
direction: int
color_mode: int
colors: list[RGBColor] = field(default_factory=list)
@dataclass(slots=True)
class Segment:
name: str
zone_type: int
start_index: int
led_count: int
@dataclass(slots=True)
class Zone:
index: int
name: str
zone_type: int
leds_min: int
leds_max: int
led_count: int
matrix_height: int | None
matrix_width: int | None
matrix_map: list[int | None]
segments: list[Segment]
flags: int
start_index: int = 0
@dataclass(slots=True)
class Led:
name: str
value: int
@dataclass(slots=True)
class Controller:
index: int
device_type: int
name: str
vendor: str
description: str
firmware_version: str
serial: str
location: str
modes: list[Mode]
active_mode: int
zones: list[Zone]
leds: list[Led]
colors: list[RGBColor]
led_alt_names: list[str]
flags: int
@property
def fingerprint(self) -> str:
parts = [self.vendor, self.description, self.serial, self.location, self.name]
durable = "\x1f".join(part.strip().casefold() for part in parts)
return sha256(durable.encode("utf-8")).hexdigest()
@property
def device_type_name(self) -> str:
return DEVICE_TYPES.get(self.device_type, "unknown")
@property
def capabilities(self) -> DeviceCapabilities:
flags = [ModeFlag(mode.flags) for mode in self.modes]
speeds = [mode for mode in self.modes if ModeFlag.HAS_SPEED in ModeFlag(mode.flags)]
brightness = [
mode for mode in self.modes if ModeFlag.HAS_BRIGHTNESS in ModeFlag(mode.flags)
]
return DeviceCapabilities(
power=bool(self.leds),
restore=bool(self.colors),
rgb=bool(self.colors or self.leds),
brightness=bool(brightness),
effect=bool(self.modes),
speed=bool(speeds),
direction=any(
f & (ModeFlag.HAS_DIRECTION_LR | ModeFlag.HAS_DIRECTION_UD | ModeFlag.HAS_DIRECTION_HV)
for f in flags
),
multiple_colors=any(
f & (ModeFlag.HAS_PER_LED_COLOR | ModeFlag.HAS_MODE_SPECIFIC_COLOR) for f in flags
),
per_zone=bool(self.zones),
per_segment=any(zone.segments for zone in self.zones),
per_led=bool(self.leds),
profiles=True,
max_leds=len(self.leds),
min_speed=min((m.speed_min for m in speeds), default=None),
max_speed=max((m.speed_max for m in speeds), default=None),
)
class Cursor:
def __init__(self, data: bytes) -> None:
self.data = memoryview(data)
self.offset = 0
@property
def remaining(self) -> int:
return len(self.data) - self.offset
def take(self, size: int) -> bytes:
if size < 0 or size > self.remaining:
raise ProtocolError(
"OpenRGB-pakket eindigt onverwacht.",
requested=size,
remaining=self.remaining,
offset=self.offset,
)
result = self.data[self.offset : self.offset + size].tobytes()
self.offset += size
return result
def u16(self) -> int:
return int(struct.unpack("<H", self.take(2))[0])
def u32(self) -> int:
return int(struct.unpack("<I", self.take(4))[0])
def i32(self) -> int:
return int(struct.unpack("<i", self.take(4))[0])
def string(self) -> str:
length = self.u16()
if length < 1 or length > MAX_STRING_BYTES:
raise ProtocolError("Ongeldige OpenRGB-stringlengte.", length=length)
raw = self.take(length)
if raw[-1:] != b"\0":
raise ProtocolError("OpenRGB-string is niet NUL-afgesloten.")
try:
return raw[:-1].decode("utf-8", errors="strict")
except UnicodeDecodeError as exc:
raise ProtocolError("OpenRGB-string bevat ongeldige UTF-8.") from exc
def count(self, label: str, maximum: int = MAX_COLLECTION_ITEMS) -> int:
count = self.u16()
if count > maximum:
raise ProtocolError("OpenRGB-verzameling is te groot.", field=label, count=count)
return count
def pack_header(device_index: int, packet_id: int | PacketId, payload_size: int) -> bytes:
if not 0 <= device_index <= 0xFFFFFFFF:
raise ProtocolError("Ongeldige OpenRGB-controllerindex.", index=device_index)
if not 0 <= payload_size <= DEFAULT_MAX_PACKET_SIZE:
raise ProtocolError("Ongeldige OpenRGB-pakketgrootte.", size=payload_size)
return HEADER.pack(MAGIC, device_index, int(packet_id), payload_size)
def parse_header(data: bytes, max_packet_size: int = DEFAULT_MAX_PACKET_SIZE) -> PacketHeader:
if len(data) != HEADER.size:
raise ProtocolError("Ongeldige OpenRGB-headerlengte.", length=len(data))
magic, device_index, packet_id, payload_size = HEADER.unpack(data)
if magic != MAGIC:
raise ProtocolError("Ongeldige OpenRGB-pakketmagic.")
if payload_size > max_packet_size:
raise ProtocolError(
"OpenRGB-pakket overschrijdt de ingestelde limiet.",
size=payload_size,
maximum=max_packet_size,
)
return PacketHeader(device_index, packet_id, payload_size)
def pack_string(value: str) -> bytes:
raw = value.encode("utf-8")
if len(raw) + 1 > min(MAX_STRING_BYTES, 0xFFFF):
raise ProtocolError("OpenRGB-string is te lang.", length=len(raw))
return struct.pack("<H", len(raw) + 1) + raw + b"\0"
def pack_color(color: RGBColor) -> bytes:
return struct.pack("<BBBx", color.red, color.green, color.blue)
def parse_color(cursor: Cursor) -> RGBColor:
red, green, blue, _padding = struct.unpack("<BBBB", cursor.take(4))
return RGBColor(red=red, green=green, blue=blue)
def parse_mode(cursor: Cursor, index: int) -> Mode:
name = cursor.string()
value = cursor.i32()
flags = cursor.u32()
speed_min = cursor.u32()
speed_max = cursor.u32()
brightness_min = cursor.u32()
brightness_max = cursor.u32()
colors_min = cursor.u32()
colors_max = cursor.u32()
speed = cursor.u32()
brightness = cursor.u32()
direction = cursor.u32()
color_mode = cursor.u32()
color_count = cursor.count("mode.colors")
colors = [parse_color(cursor) for _ in range(color_count)]
return Mode(
index,
name,
value,
flags,
speed_min,
speed_max,
brightness_min,
brightness_max,
colors_min,
colors_max,
speed,
brightness,
direction,
color_mode,
colors,
)
def parse_zone(cursor: Cursor, index: int, start_index: int) -> Zone:
name = cursor.string()
zone_type = cursor.i32()
leds_min = cursor.u32()
leds_max = cursor.u32()
led_count = cursor.u32()
if led_count > MAX_COLLECTION_ITEMS:
raise ProtocolError("OpenRGB-zone bevat te veel leds.", count=led_count)
matrix_len = cursor.u16()
matrix_height: int | None = None
matrix_width: int | None = None
matrix: list[int | None] = []
if matrix_len:
if matrix_len < 8 or (matrix_len - 8) % 4:
raise ProtocolError("Ongeldige OpenRGB-matrixlengte.", length=matrix_len)
matrix_height = cursor.u32()
matrix_width = cursor.u32()
cells = (matrix_len - 8) // 4
if cells > MAX_MATRIX_CELLS or matrix_height * matrix_width != cells:
raise ProtocolError(
"OpenRGB-matrixafmetingen komen niet overeen.",
height=matrix_height,
width=matrix_width,
cells=cells,
)
matrix = [None if (cell := cursor.u32()) == 0xFFFFFFFF else cell for _ in range(cells)]
segment_count = cursor.count("zone.segments")
segments = [
Segment(cursor.string(), cursor.i32(), cursor.u32(), cursor.u32())
for _ in range(segment_count)
]
flags = cursor.u32()
return Zone(
index,
name,
zone_type,
leds_min,
leds_max,
led_count,
matrix_height,
matrix_width,
matrix,
segments,
flags,
start_index,
)
def parse_controller(payload: bytes, index: int) -> Controller:
cursor = Cursor(payload)
declared_size = cursor.u32()
if declared_size != len(payload):
raise ProtocolError(
"OpenRGB-controllerblok heeft een afwijkende lengte.",
declared=declared_size,
actual=len(payload),
)
device_type = cursor.i32()
name = cursor.string()
vendor = cursor.string()
description = cursor.string()
firmware_version = cursor.string()
serial = cursor.string()
location = cursor.string()
mode_count = cursor.count("controller.modes")
active_mode = cursor.i32()
modes = [parse_mode(cursor, mode_index) for mode_index in range(mode_count)]
zone_count = cursor.count("controller.zones")
zones: list[Zone] = []
start = 0
for zone_index in range(zone_count):
zone = parse_zone(cursor, zone_index, start)
zones.append(zone)
start += zone.led_count
led_count = cursor.count("controller.leds")
leds = [Led(cursor.string(), cursor.u32()) for _ in range(led_count)]
color_count = cursor.count("controller.colors")
colors = [parse_color(cursor) for _ in range(color_count)]
alt_count = cursor.count("controller.led_alt_names")
led_alt_names = [cursor.string() for _ in range(alt_count)]
flags = cursor.u32()
if cursor.remaining:
raise ProtocolError("Onverwachte bytes na OpenRGB-controllerblok.", remaining=cursor.remaining)
if len(colors) not in (0, len(leds)):
raise ProtocolError(
"Aantal OpenRGB-kleuren komt niet overeen met het aantal leds.",
colors=len(colors),
leds=len(leds),
)
if sum(zone.led_count for zone in zones) > len(leds):
raise ProtocolError("OpenRGB-zones verwijzen buiten de ledlijst.")
return Controller(
index,
device_type,
name,
vendor,
description,
firmware_version,
serial,
location,
modes,
active_mode,
zones,
leds,
colors,
led_alt_names,
flags,
)
def pack_update_leds(colors: list[RGBColor], expected_count: int) -> bytes:
if len(colors) != expected_count or len(colors) > 0xFFFF:
raise ProtocolError(
"Aantal kleuren moet exact overeenkomen met het aantal leds.",
colors=len(colors),
expected=expected_count,
)
body = struct.pack("<H", len(colors)) + b"".join(pack_color(color) for color in colors)
return struct.pack("<I", len(body) + 4) + body
def pack_update_zone(zone_index: int, colors: list[RGBColor], expected_count: int) -> bytes:
if zone_index < 0:
raise ProtocolError("Ongeldige OpenRGB-zoneindex.", index=zone_index)
if len(colors) != expected_count or len(colors) > 0xFFFF:
raise ProtocolError(
"Aantal zonekleuren moet exact overeenkomen met het aantal zoneleds.",
colors=len(colors),
expected=expected_count,
)
body = struct.pack("<IH", zone_index, len(colors)) + b"".join(
pack_color(color) for color in colors
)
return struct.pack("<I", len(body) + 4) + body
def pack_update_single_led(led_index: int, color: RGBColor, led_count: int) -> bytes:
if not 0 <= led_index < led_count:
raise ProtocolError("OpenRGB-ledindex valt buiten bereik.", index=led_index, count=led_count)
return struct.pack("<i", led_index) + pack_color(color)
def pack_mode(
mode: Mode,
mode_index: int,
*,
brightness_percent: int | None = None,
speed: int | None = None,
direction: int | None = None,
colors: list[RGBColor] | None = None,
) -> bytes:
if mode_index < 0:
raise ProtocolError("Ongeldige OpenRGB-modusindex.")
selected_speed = mode.speed if speed is None else speed
if ModeFlag.HAS_SPEED in ModeFlag(mode.flags) and not min(mode.speed_min, mode.speed_max) <= selected_speed <= max(mode.speed_min, mode.speed_max):
raise ProtocolError("OpenRGB-effectsnelheid valt buiten bereik.")
selected_brightness = mode.brightness
if brightness_percent is not None:
if not 0 <= brightness_percent <= 100:
raise ProtocolError("Helderheid moet tussen 0 en 100 liggen.")
if ModeFlag.HAS_BRIGHTNESS not in ModeFlag(mode.flags):
raise ProtocolError("De geselecteerde OpenRGB-modus ondersteunt geen helderheid.")
low, high = mode.brightness_min, mode.brightness_max
selected_brightness = round(low + (high - low) * brightness_percent / 100)
selected_direction = mode.direction if direction is None else direction
direction_flags = (
ModeFlag.HAS_DIRECTION_LR | ModeFlag.HAS_DIRECTION_UD | ModeFlag.HAS_DIRECTION_HV
)
if direction is not None and not ModeFlag(mode.flags) & direction_flags:
raise ProtocolError("De geselecteerde OpenRGB-modus ondersteunt geen richting.")
selected_colors = list(mode.colors if colors is None else colors)
if colors is not None:
if ModeFlag.HAS_MODE_SPECIFIC_COLOR not in ModeFlag(mode.flags):
raise ProtocolError("De geselecteerde OpenRGB-modus ondersteunt geen effectkleuren.")
if not mode.colors_min <= len(selected_colors) <= mode.colors_max:
raise ProtocolError(
"Aantal OpenRGB-effectkleuren valt buiten bereik.",
colors=len(selected_colors),
minimum=mode.colors_min,
maximum=mode.colors_max,
)
mode_body = (
pack_string(mode.name)
+ struct.pack(
"<iIIIIIIIIIIIH",
mode.value,
mode.flags,
mode.speed_min,
mode.speed_max,
mode.brightness_min,
mode.brightness_max,
mode.colors_min,
mode.colors_max,
selected_speed,
selected_brightness,
selected_direction,
mode.color_mode,
len(selected_colors),
)
+ b"".join(pack_color(color) for color in selected_colors)
)
body = struct.pack("<i", mode_index) + mode_body
return struct.pack("<I", len(body) + 4) + body
def parse_profile_list(payload: bytes) -> list[str]:
cursor = Cursor(payload)
declared = cursor.u32()
if declared != len(payload):
raise ProtocolError("OpenRGB-profiellijst heeft een afwijkende lengte.")
profiles = [cursor.string() for _ in range(cursor.count("profiles"))]
if cursor.remaining:
raise ProtocolError("Onverwachte bytes na OpenRGB-profiellijst.")
return profiles
@@ -0,0 +1,51 @@
"""Connector lifecycle and lookup registry."""
from __future__ import annotations
import asyncio
from collections.abc import Iterable
from ..errors import NotFoundError
from .base import Connector, ConnectorHealth
class ConnectorRegistry:
def __init__(self, connectors: Iterable[Connector]) -> None:
self._connectors = {connector.id: connector for connector in connectors}
async def start(self) -> None:
await asyncio.gather(*(connector.start() for connector in self._connectors.values()))
async def stop(self) -> None:
await asyncio.gather(
*(connector.stop() for connector in self._connectors.values()),
return_exceptions=True,
)
def get(self, connector_id: str) -> Connector:
try:
return self._connectors[connector_id]
except KeyError as exc:
raise NotFoundError("Connector", connector_id) from exc
def all(self) -> tuple[Connector, ...]:
return tuple(self._connectors.values())
async def health(self) -> dict[str, ConnectorHealth]:
results = await asyncio.gather(
*(connector.health() for connector in self._connectors.values()),
return_exceptions=True,
)
health: dict[str, ConnectorHealth] = {}
for connector, result in zip(self._connectors.values(), results, strict=True):
if isinstance(result, BaseException):
from .base import HealthStatus
health[connector.id] = ConnectorHealth(
status=HealthStatus.UNHEALTHY,
message=str(result),
connected=False,
)
else:
health[connector.id] = result
return health
@@ -0,0 +1,112 @@
"""SQLite connection management and ordered migration runner."""
from __future__ import annotations
import contextlib
import logging
import shutil
import sqlite3
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from .config import Settings
LOGGER = logging.getLogger(__name__)
class Database:
def __init__(self, settings: Settings) -> None:
self.path = settings.database_path
self.backup_dir = settings.data_dir / "backups"
self.migrations_dir = Path(__file__).with_name("migrations")
def initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.backup_dir.mkdir(parents=True, exist_ok=True)
with self.connection() as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {
row["version"] for row in conn.execute("SELECT version FROM schema_migrations")
}
for migration in sorted(self.migrations_dir.glob("*.sql")):
version = migration.stem
if version in applied:
continue
sql = migration.read_text(encoding="utf-8")
if "-- destructive: true" in sql.lower() and self.path.exists():
self.backup("pre-migration")
LOGGER.info("Applying database migration %s", version)
conn.executescript(sql)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(version, utc_now()),
)
conn.commit()
@contextlib.contextmanager
def connection(self) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self.path, timeout=5, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute("PRAGMA busy_timeout = 5000")
try:
yield conn
finally:
conn.close()
@contextlib.contextmanager
def transaction(self) -> Iterator[sqlite3.Connection]:
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
try:
yield conn
except Exception:
conn.rollback()
raise
else:
conn.commit()
def backup(self, reason: str = "manual") -> Path:
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
target = self.backup_dir / f"lumaops-{timestamp}-{safe_filename(reason)}.db"
with self.connection() as source, sqlite3.connect(target) as destination:
source.backup(destination)
return target
def restore(self, backup_path: Path) -> Path:
resolved = backup_path.resolve()
if self.backup_dir.resolve() not in resolved.parents:
raise ValueError("Backupbestand valt buiten de beheerde back-upmap")
if not resolved.is_file():
raise FileNotFoundError(resolved)
safety = self.backup("pre-restore")
temp = self.path.with_suffix(".restore.tmp")
shutil.copy2(resolved, temp)
with sqlite3.connect(temp) as candidate:
result = candidate.execute("PRAGMA integrity_check").fetchone()
if result is None or result[0] != "ok":
temp.unlink(missing_ok=True)
raise ValueError("De back-up slaagt niet voor de SQLite-integriteitscontrole")
temp.replace(self.path)
return safety
def ping(self) -> bool:
try:
with self.connection() as conn:
return bool(conn.execute("SELECT 1").fetchone()[0] == 1)
except sqlite3.Error:
return False
def utc_now() -> str:
return datetime.now(UTC).isoformat()
def safe_filename(value: str) -> str:
return "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in value)[:48]
@@ -0,0 +1,48 @@
"""Normalized application errors."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class LumaOpsError(Exception):
code: str
message: str
status_code: int = 400
details: dict[str, Any] = field(default_factory=dict)
recovery: list[str] = field(default_factory=list)
def __str__(self) -> str:
return self.message
class NotFoundError(LumaOpsError):
def __init__(self, resource: str, identifier: str) -> None:
super().__init__(
code="not_found",
message=f"{resource} is niet gevonden.",
status_code=404,
details={"resource": resource, "id": identifier},
)
class ConflictError(LumaOpsError):
def __init__(self, message: str, **details: Any) -> None:
super().__init__("conflict", message, 409, details)
class ConnectorError(LumaOpsError):
def __init__(
self,
code: str,
message: str,
*,
status_code: int = 503,
retryable: bool = False,
details: dict[str, Any] | None = None,
) -> None:
merged = dict(details or {})
merged["retryable"] = retryable
super().__init__(code, message, status_code, merged)
@@ -0,0 +1,35 @@
"""In-process event fan-out for Server-Sent Events clients."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from typing import Any
class EventBus:
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
async def publish(self, event_type: str, data: dict[str, Any]) -> None:
event = {"type": event_type, "data": data}
dead: list[asyncio.Queue[dict[str, Any]]] = []
for queue in self._subscribers:
try:
queue.put_nowait(event)
except asyncio.QueueFull:
dead.append(queue)
for queue in dead:
self._subscribers.discard(queue)
async def subscribe(self) -> AsyncIterator[dict[str, Any]]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100)
self._subscribers.add(queue)
try:
while True:
try:
yield await asyncio.wait_for(queue.get(), timeout=20)
except TimeoutError:
yield {"type": "heartbeat", "data": {}}
finally:
self._subscribers.discard(queue)
@@ -0,0 +1,57 @@
"""Structured logging with conservative secret redaction."""
from __future__ import annotations
import json
import logging
import re
from contextvars import ContextVar
from datetime import UTC, datetime
from typing import Any
request_id_context: ContextVar[str] = ContextVar("request_id", default="-")
_SECRET_KEYS = re.compile(
r"(authorization|cookie|password|passwd|secret|token|api[_-]?key|client[_-]?key)", re.I
)
def redact(value: Any, key: str = "") -> Any:
if _SECRET_KEYS.search(key):
return "[REDACTED]"
if isinstance(value, dict):
return {str(k): redact(v, str(k)) for k, v in value.items()}
if isinstance(value, list | tuple):
return [redact(item) for item in value]
return value
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
safe_record = logging.makeLogRecord(record.__dict__)
if isinstance(record.args, tuple):
safe_record.args = tuple(redact(list(record.args)))
elif isinstance(record.args, dict):
safe_record.args = redact(record.args)
payload: dict[str, Any] = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": safe_record.getMessage(),
"request_id": request_id_context.get(),
}
for key in ("connector", "device_uuid", "command_id", "duration_ms", "error_category"):
if hasattr(record, key):
payload[key] = getattr(record, key)
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(redact(payload), ensure_ascii=False, default=str)
def configure_logging(level: str) -> None:
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)
+260
View File
@@ -0,0 +1,260 @@
"""FastAPI application entry point."""
from __future__ import annotations
import logging
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
import uvicorn
from fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import __version__
from .api.router import api
from .application import AppContext, build_context
from .config import Settings, get_settings
from .errors import LumaOpsError
from .logging_config import configure_logging, request_id_context
LOGGER = logging.getLogger(__name__)
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
configure_logging(settings.log_level)
app_context = build_context(settings)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.context = app_context
await app_context.start()
try:
yield
finally:
await app_context.stop()
app = FastAPI(
title="LumaOps API",
description="Lokaal OpenRGB- en smart-lightingbeheer",
version=__version__,
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
app.state.context = app_context
if settings.cors_origin_list:
app.add_middleware(
CORSMiddleware,
allow_origins=list(settings.cors_origin_list),
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=[
"Authorization",
"Content-Type",
"Idempotency-Key",
"X-CSRF-Token",
"X-Request-ID",
],
)
@app.middleware("http")
async def request_context(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
supplied = request.headers.get("x-request-id", "")
request_id = supplied if supplied and len(supplied) <= 128 else str(uuid.uuid4())
request.state.request_id = request_id
token = request_id_context.set(request_id)
try:
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
)
if request.method in {"POST", "PUT", "PATCH", "DELETE"} and request.url.path.startswith(
"/api/v1/"
):
try:
_audit_api_mutation(app_context, request, response, request_id)
except Exception:
LOGGER.exception("mutation_audit_failed")
return response
finally:
request_id_context.reset(token)
@app.exception_handler(LumaOpsError)
async def lumaops_error(request: Request, exc: LumaOpsError) -> JSONResponse:
return error_response(
request,
status_code=exc.status_code,
code=exc.code,
message=exc.message,
details=exc.details,
recovery=exc.recovery,
)
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
return error_response(
request,
status_code=422,
code="validation_error",
message="De aanvraag bevat ongeldige gegevens.",
details={"fields": serializable_validation_errors(exc)},
)
@app.exception_handler(ValueError)
async def value_error(request: Request, exc: ValueError) -> JSONResponse:
return error_response(
request,
status_code=422,
code="invalid_value",
message=str(exc),
)
@app.exception_handler(Exception)
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
LOGGER.exception("unhandled_request_error", extra={"error_category": type(exc).__name__})
return error_response(
request,
status_code=500,
code="internal_error",
message="Er ging intern iets mis.",
recovery=["Probeer opnieuw en gebruik de request-id bij diagnostiek."],
)
@app.get("/health/live", include_in_schema=False)
async def live() -> dict[str, str]:
return {"status": "alive"}
@app.get("/health/ready", include_in_schema=False)
async def ready(request: Request) -> JSONResponse:
context: AppContext = request.app.state.context
is_ready = context.health.ready()
return JSONResponse(
{"status": "ready" if is_ready else "not_ready"}, status_code=200 if is_ready else 503
)
@app.get("/health/container", include_in_schema=False)
async def container_health(request: Request) -> JSONResponse:
context: AppContext = request.app.state.context
report = await context.health.detailed()
status_code = 503 if report["status"] == "unhealthy" else 200
return JSONResponse(report, status_code=status_code)
app.include_router(api)
assets = settings.static_dir / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=assets), name="assets")
@app.get("/{full_path:path}", include_in_schema=False, response_model=None)
async def spa(full_path: str) -> FileResponse | JSONResponse:
del full_path
index = settings.static_dir / "index.html"
if index.is_file():
return FileResponse(index)
return JSONResponse(
{
"name": "LumaOps",
"status": "backend-only",
"message": "De frontendbuild is niet aanwezig in STATIC_DIR.",
"api_docs": "/api/docs",
},
status_code=503,
)
return app
def _audit_api_mutation(
context: AppContext, request: Request, response: Response, request_id: str
) -> None:
"""Record every state-changing API request without persisting request bodies or secrets."""
segments = [segment for segment in request.url.path.split("/") if segment]
resource_type = segments[2] if len(segments) > 2 else "api"
resource_id = segments[3] if len(segments) > 3 else None
route = request.scope.get("route")
route_name = getattr(route, "name", None)
actor_name = "anonymous"
try:
actor_name = context.auth.require(request)
except LumaOpsError:
actor_name = "anonymous"
context.repository.audit(
request_id=request_id,
actor=actor_name,
action=f"api.{route_name or request.method.lower()}",
resource_type=resource_type,
resource_id=resource_id,
outcome="succeeded" if response.status_code < 400 else "failed",
detail={
"method": request.method,
"path": getattr(route, "path", request.url.path),
"status_code": response.status_code,
},
)
def error_response(
request: Request,
*,
status_code: int,
code: str,
message: str,
details: dict[str, Any] | None = None,
recovery: list[str] | None = None,
) -> JSONResponse:
request_id = getattr(request.state, "request_id", "-")
return JSONResponse(
{
"error": {
"code": code,
"message": message,
"request_id": request_id,
"details": details or {},
"recovery": recovery or [],
}
},
status_code=status_code,
headers={"X-Request-ID": request_id},
)
def serializable_validation_errors(exc: RequestValidationError) -> list[dict[str, Any]]:
fields: list[dict[str, Any]] = []
for error in exc.errors():
field = dict(error)
if context := field.get("ctx"):
field["ctx"] = {key: str(value) for key, value in context.items()}
fields.append(field)
return fields
def run() -> None:
settings = get_settings()
uvicorn.run(
"lumaops_backend.main:create_app",
factory=True,
host=settings.app_host,
port=settings.app_port,
log_config=None,
proxy_headers=bool(settings.trusted_proxy_list),
forwarded_allow_ips=",".join(settings.trusted_proxy_list),
)
if __name__ == "__main__":
run()
@@ -0,0 +1,287 @@
-- LumaOps initial SQLite schema.
-- destructive: false
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE connectors (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
config_json TEXT NOT NULL DEFAULT '{}',
health TEXT NOT NULL DEFAULT 'unknown',
last_error TEXT,
last_seen_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE connector_secrets (
connector_id TEXT NOT NULL REFERENCES connectors(id) ON DELETE CASCADE,
key TEXT NOT NULL,
ciphertext BLOB NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (connector_id, key)
);
CREATE TABLE rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE devices (
id TEXT PRIMARY KEY,
connector_id TEXT NOT NULL REFERENCES connectors(id),
external_id TEXT NOT NULL,
fingerprint TEXT NOT NULL,
source TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT 'openrgb',
name TEXT NOT NULL,
alias TEXT,
vendor TEXT,
model TEXT,
serial TEXT,
location TEXT,
ip_address TEXT,
firmware_version TEXT,
controller_index INTEGER,
capabilities_json TEXT NOT NULL DEFAULT '{}',
state_json TEXT NOT NULL DEFAULT '{}',
zones_json TEXT NOT NULL DEFAULT '[]',
modes_json TEXT NOT NULL DEFAULT '[]',
led_count INTEGER NOT NULL DEFAULT 0,
online INTEGER NOT NULL DEFAULT 0 CHECK (online IN (0, 1)),
hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)),
favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0, 1)),
exclude_global INTEGER NOT NULL DEFAULT 0 CHECK (exclude_global IN (0, 1)),
read_only INTEGER NOT NULL DEFAULT 0 CHECK (read_only IN (0, 1)),
blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)),
experimental INTEGER NOT NULL DEFAULT 0 CHECK (experimental IN (0, 1)),
room_id TEXT REFERENCES rooms(id) ON DELETE SET NULL,
error_status TEXT,
last_detected_at TEXT,
last_command_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
UNIQUE(connector_id, external_id)
);
CREATE INDEX idx_devices_fingerprint ON devices(connector_id, fingerprint);
CREATE INDEX idx_devices_room ON devices(room_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_devices_online ON devices(online) WHERE deleted_at IS NULL;
CREATE TABLE device_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
value TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
UNIQUE(device_id, kind, value)
);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE device_tags (
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (device_id, tag_id)
);
CREATE TABLE device_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
dynamic_query_json TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE group_members (
group_id TEXT NOT NULL REFERENCES device_groups(id) ON DELETE CASCADE,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
excluded INTEGER NOT NULL DEFAULT 0 CHECK (excluded IN (0, 1)),
PRIMARY KEY (group_id, device_id)
);
CREATE TABLE scenes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1,
last_applied_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE scene_items (
id TEXT PRIMARY KEY,
scene_id TEXT NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
state_json TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 1 CHECK (required IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_scene_items_scene ON scene_items(scene_id, sort_order);
CREATE TABLE automations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
trigger_json TEXT NOT NULL,
actions_json TEXT NOT NULL,
timezone TEXT NOT NULL,
cooldown_seconds INTEGER NOT NULL DEFAULT 0,
conflict_key TEXT,
last_run_at TEXT,
next_run_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE automation_runs (
id TEXT PRIMARY KEY,
automation_id TEXT NOT NULL REFERENCES automations(id),
status TEXT NOT NULL,
trigger_json TEXT NOT NULL,
result_json TEXT,
error TEXT,
started_at TEXT NOT NULL,
finished_at TEXT
);
CREATE TABLE commands (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
actor TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
action TEXT NOT NULL,
desired_state_json TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE INDEX idx_commands_created ON commands(created_at DESC);
CREATE TABLE command_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_id TEXT NOT NULL REFERENCES commands(id) ON DELETE CASCADE,
device_id TEXT,
connector_id TEXT,
status TEXT NOT NULL,
prior_state_json TEXT,
applied_state_json TEXT,
error_code TEXT,
error_message TEXT,
duration_ms REAL,
created_at TEXT NOT NULL
);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
outcome TEXT NOT NULL,
detail_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX idx_audit_created ON audit_events(created_at DESC);
CREATE TABLE activity (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
severity TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
detail_json TEXT NOT NULL DEFAULT '{}',
acknowledged_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE health_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component TEXT NOT NULL,
status TEXT NOT NULL,
detail_json TEXT NOT NULL DEFAULT '{}',
sampled_at TEXT NOT NULL
);
CREATE INDEX idx_health_sampled ON health_history(sampled_at DESC);
CREATE TABLE discovery_runs (
id TEXT PRIMARY KEY,
connector_id TEXT,
kind TEXT NOT NULL,
status TEXT NOT NULL,
found_count INTEGER NOT NULL DEFAULT 0,
inaccessible_count INTEGER NOT NULL DEFAULT 0,
detail_json TEXT NOT NULL DEFAULT '{}',
started_at TEXT NOT NULL,
finished_at TEXT
);
CREATE TABLE ignored_devices (
id TEXT PRIMARY KEY,
connector_id TEXT,
fingerprint TEXT NOT NULL,
reason TEXT,
created_at TEXT NOT NULL,
UNIQUE(connector_id, fingerprint)
);
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
response_status INTEGER,
response_json TEXT,
resource_id TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE setup_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1)),
current_step TEXT NOT NULL DEFAULT 'welcome',
report_json TEXT NOT NULL DEFAULT '{}',
completed_at TEXT,
updated_at TEXT NOT NULL
);
INSERT INTO setup_state(singleton, completed, current_step, report_json, updated_at)
VALUES (1, 0, 'welcome', '{}', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
@@ -0,0 +1,4 @@
-- Persist optional automation descriptions accepted by the public schema.
-- destructive: false
ALTER TABLE automations ADD COLUMN description TEXT;
@@ -0,0 +1,16 @@
-- Persist the last intended state so hardware can be reconciled after OpenRGB restarts.
-- destructive: false
ALTER TABLE devices ADD COLUMN desired_state_json TEXT;
UPDATE devices
SET desired_state_json = (
SELECT cr.applied_state_json
FROM command_results AS cr
JOIN commands AS c ON c.id = cr.command_id
WHERE cr.device_id = devices.id
AND cr.status = 'succeeded'
AND cr.applied_state_json IS NOT NULL
ORDER BY COALESCE(c.finished_at, c.created_at) DESC, cr.id DESC
LIMIT 1
);
@@ -0,0 +1,7 @@
-- Persist connector classification so physical modules can be presented as device families.
-- destructive: false
ALTER TABLE devices ADD COLUMN device_type TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE devices ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}';
CREATE INDEX idx_devices_type ON devices(device_type) WHERE deleted_at IS NULL;
@@ -0,0 +1,159 @@
"""Small transactional repository helpers for the SQLite data model."""
from __future__ import annotations
import json
import sqlite3
import uuid
from typing import Any
from .database import Database, utc_now
from .errors import NotFoundError
JSON_FIELDS = {
"config_json",
"capabilities_json",
"state_json",
"zones_json",
"modes_json",
"metadata_json",
"dynamic_query_json",
"trigger_json",
"actions_json",
"desired_state_json",
"prior_state_json",
"applied_state_json",
"detail_json",
"report_json",
"result_json",
}
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
result = dict(row)
for key in JSON_FIELDS & result.keys():
if result[key] is not None:
result[key.removesuffix("_json")] = json.loads(result.pop(key))
for key in (
"enabled",
"online",
"hidden",
"favorite",
"exclude_global",
"read_only",
"blocked",
"experimental",
"required",
"excluded",
"completed",
):
if key in result:
result[key] = bool(result[key])
return result
class Repository:
def __init__(self, database: Database) -> None:
self.database = database
def list_rows(
self,
table: str,
*,
where: str = "1=1",
params: tuple[Any, ...] = (),
order_by: str = "created_at DESC",
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
checked_table = safe_identifier(table)
with self.database.connection() as conn:
total = conn.execute(
f"SELECT COUNT(*) FROM {checked_table} WHERE {where}",
params, # noqa: S608
).fetchone()[0]
rows = conn.execute(
f"SELECT * FROM {checked_table} WHERE {where} ORDER BY {order_by} LIMIT ? OFFSET ?", # noqa: S608
(*params, limit, offset),
).fetchall()
return [row_to_dict(row) or {} for row in rows], total
def get(self, table: str, identifier: str) -> dict[str, Any]:
checked_table = safe_identifier(table)
with self.database.connection() as conn:
row = conn.execute(
f"SELECT * FROM {checked_table} WHERE id = ? AND deleted_at IS NULL", # noqa: S608
(identifier,),
).fetchone()
result = row_to_dict(row)
if result is None:
raise NotFoundError(table, identifier)
return result
def audit(
self,
*,
request_id: str,
actor: str,
action: str,
resource_type: str,
resource_id: str | None,
outcome: str,
detail: dict[str, Any] | None = None,
) -> str:
event_id = str(uuid.uuid4())
with self.database.connection() as conn:
conn.execute(
"INSERT INTO audit_events(id, request_id, actor, action, resource_type, "
"resource_id, outcome, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
event_id,
request_id,
actor,
action,
resource_type,
resource_id,
outcome,
json.dumps(detail or {}, separators=(",", ":")),
utc_now(),
),
)
return event_id
def activity(
self,
title: str,
message: str,
*,
category: str = "system",
severity: str = "info",
resource_type: str | None = None,
resource_id: str | None = None,
detail: dict[str, Any] | None = None,
) -> str:
event_id = str(uuid.uuid4())
with self.database.connection() as conn:
conn.execute(
"INSERT INTO activity(id, category, severity, title, message, resource_type, "
"resource_id, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
event_id,
category,
severity,
title,
message,
resource_type,
resource_id,
json.dumps(detail or {}, separators=(",", ":")),
utc_now(),
),
)
return event_id
def safe_identifier(value: str) -> str:
if not value.replace("_", "").isalnum():
raise ValueError("Ongeldige SQL-identifier")
return value
@@ -0,0 +1,177 @@
"""Versioned API request/response schemas."""
from __future__ import annotations
import re
from typing import Any, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, Field, field_validator, model_validator
from .connectors.base import DeviceState
class Page(BaseModel):
items: list[dict[str, Any]]
total: int
limit: int
offset: int
class DevicePatch(BaseModel):
alias: str | None = Field(default=None, max_length=120)
room_id: str | None = None
hidden: bool | None = None
favorite: bool | None = None
exclude_global: bool | None = None
read_only: bool | None = None
blocked: bool | None = None
owner: Literal["openrgb", "native", "home_assistant", "unmanaged"] | None = None
tags: list[str] | None = Field(default=None, max_length=100)
@field_validator("tags")
@classmethod
def normalize_tags(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = [tag.strip() for tag in value if tag.strip()]
if any(len(tag) > 64 for tag in normalized):
raise ValueError("Tags mogen maximaal 64 tekens bevatten")
return list(dict.fromkeys(normalized))
class DeviceCommandRequest(BaseModel):
state: DeviceState
class ZoneResizeRequest(BaseModel):
led_count: int = Field(ge=0, le=65_535)
class NamedResourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=1000)
@field_validator("name")
@classmethod
def strip_name(cls, value: str) -> str:
if not (value := value.strip()):
raise ValueError("Naam mag niet leeg zijn")
return value
class RoomCreate(NamedResourceCreate):
sort_order: int = 0
class GroupCreate(NamedResourceCreate):
device_ids: list[str] = Field(default_factory=list, max_length=4096)
dynamic_query: dict[str, Any] | None = None
sort_order: int = 0
class SceneItemInput(BaseModel):
target_type: Literal["device", "group"]
target_id: str
state: DeviceState
required: bool = True
sort_order: int = 0
class SceneCreate(NamedResourceCreate):
favorite: bool = False
items: list[SceneItemInput] = Field(default_factory=list, max_length=4096)
class SceneImportRequest(BaseModel):
format: Literal["lumaops-scene-v1"]
scene: SceneCreate
class SceneApplyRequest(BaseModel):
rollback_on_failure: bool = True
class AutomationCreate(NamedResourceCreate):
enabled: bool = True
trigger: dict[str, Any]
actions: list[dict[str, Any]] = Field(min_length=1, max_length=100)
timezone: str = "Europe/Brussels"
cooldown_seconds: int = Field(default=0, ge=0, le=604800)
conflict_key: str | None = Field(default=None, max_length=120)
@field_validator("trigger")
@classmethod
def validate_trigger(cls, value: dict[str, Any]) -> dict[str, Any]:
if value.get("type") not in {"time", "weekday"}:
raise ValueError("Alleen tijdgestuurde automations worden ondersteund")
at = value.get("at")
if not isinstance(at, str) or not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", at):
raise ValueError("Een automationtijd moet HH:MM tussen 00:00 en 23:59 zijn")
weekdays = value.get("weekdays")
if not isinstance(weekdays, list) or not weekdays:
raise ValueError("Selecteer minstens één weekdag")
if any(
isinstance(day, bool) or not isinstance(day, int) or day < 0 or day > 6
for day in weekdays
):
raise ValueError("Weekdagen moeten unieke waarden van 0 tot en met 6 zijn")
if len(set(weekdays)) != len(weekdays):
raise ValueError("Weekdagen mogen niet dubbel voorkomen")
return value
@field_validator("actions")
@classmethod
def validate_actions(cls, value: list[dict[str, Any]]) -> list[dict[str, Any]]:
for action in value:
kind = action.get("type")
if kind == "scene":
if not isinstance(action.get("scene_id"), str) or not action["scene_id"].strip():
raise ValueError("Een scèneactie vereist een geldige scene_id")
elif kind == "device":
if not isinstance(action.get("device_id"), str) or not action["device_id"].strip():
raise ValueError("Een apparaat-actie vereist een geldige device_id")
if not isinstance(action.get("state"), dict):
raise ValueError("Een apparaat-actie vereist een geldige state")
DeviceState.model_validate(action["state"])
elif kind == "notification":
if "title" in action and not isinstance(action["title"], str):
raise ValueError("De titel van een notificatie moet tekst zijn")
if "message" in action and not isinstance(action["message"], str):
raise ValueError("Het bericht van een notificatie moet tekst zijn")
elif kind != "all_off":
raise ValueError(f"Onbekende automationactie: {kind}")
return value
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
value = value.strip()
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError("Gebruik een geldige IANA-tijdzone") from exc
return value
class SetupCompleteRequest(BaseModel):
appdata_confirmed: bool
backup_location_confirmed: bool | None = None
backup_confirmed: bool | None = None
@model_validator(mode="after")
def _normalize_backup(self) -> SetupCompleteRequest:
if self.backup_location_confirmed is None and self.backup_confirmed is not None:
self.backup_location_confirmed = self.backup_confirmed
if self.backup_location_confirmed is None:
raise ValueError("Veld 'backup_location_confirmed' of 'backup_confirmed' is verplicht.")
return self
class SettingUpdate(BaseModel):
value: Any
class ConnectorSecretInput(BaseModel):
key: str = Field(pattern=r"^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$")
value: str = Field(min_length=1, max_length=16384)
@@ -0,0 +1,71 @@
"""Authenticated encryption for connector secrets."""
from __future__ import annotations
import os
from cryptography.fernet import Fernet, InvalidToken
from .config import Settings
from .errors import LumaOpsError
class SecretStore:
def __init__(self, settings: Settings) -> None:
self.path = settings.config_dir / "secret.key"
key = (
settings.secret_key.encode("ascii")
if settings.secret_key
else self._load_or_create_key()
)
try:
self._fernet = Fernet(key)
except (ValueError, TypeError) as exc:
raise LumaOpsError(
"invalid_secret_key",
"De persistente LumaOps-encryptiesleutel is ongeldig.",
500,
recovery=["Herstel secret.key uit de appdata-back-up"],
) from exc
def _load_or_create_key(self) -> bytes:
self.path.parent.mkdir(parents=True, exist_ok=True)
if self.path.exists():
return self.path.read_bytes().strip()
key = Fernet.generate_key()
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
descriptor = os.open(self.path, flags, 0o600)
try:
os.write(descriptor, key + b"\n")
finally:
os.close(descriptor)
with contextlib_suppress_on_windows():
self.path.chmod(0o600)
return key
def encrypt(self, value: str) -> bytes:
return self._fernet.encrypt(value.encode("utf-8"))
def decrypt(self, value: bytes) -> str:
try:
return self._fernet.decrypt(value).decode("utf-8")
except InvalidToken as exc:
raise LumaOpsError(
"secret_decryption_failed",
"Een connectorsecret kan niet worden ontsleuteld.",
500,
recovery=[
"Herstel de bijbehorende secret.key",
"Voer het connectorsecret opnieuw in",
],
) from exc
class contextlib_suppress_on_windows:
"""Ignore chmod limitations without hiding key I/O failures."""
def __enter__(self) -> None:
return None
def __exit__(self, exc_type: object, exc: object, traceback: object) -> bool:
return isinstance(exc, PermissionError)
@@ -0,0 +1 @@
"""Application service layer."""
@@ -0,0 +1,219 @@
"""Extensible MVP automation engine with cooldown and conflict locks."""
from __future__ import annotations
import asyncio
import contextlib
import json
import uuid
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from ..connectors.base import DeviceState
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..repository import Repository
from ..schemas import AutomationCreate
from .commands import CommandService
from .scenes import SceneService
class AutomationService:
def __init__(self, database: Database, commands: CommandService, scenes: SceneService) -> None:
self.database = database
self.commands = commands
self.scenes = scenes
self.repo = Repository(database)
self._task: asyncio.Task[None] | None = None
self._locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
self._stopping = False
async def start(self) -> None:
self._stopping = False
self._task = asyncio.create_task(self._loop(), name="automation-scheduler")
async def stop(self) -> None:
self._stopping = True
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
async def _loop(self) -> None:
while not self._stopping:
now = utc_now()
with self.database.connection() as conn:
rows = conn.execute(
"SELECT id FROM automations WHERE enabled=1 AND deleted_at IS NULL "
"AND next_run_at IS NOT NULL AND next_run_at <= ?",
(now,),
).fetchall()
for row in rows:
asyncio.create_task(self.run(row["id"], {"type": "schedule"}))
await asyncio.sleep(15)
def create(self, data: AutomationCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
next_run = self._next_run(data.trigger, data.timezone)
with self.database.connection() as conn:
conn.execute(
"INSERT INTO automations(id, name, description, enabled, trigger_json, actions_json, timezone, "
"cooldown_seconds, conflict_key, next_run_at, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
identifier,
data.name,
data.description,
int(data.enabled),
json.dumps(data.trigger, separators=(",", ":")),
json.dumps(data.actions, separators=(",", ":")),
data.timezone,
data.cooldown_seconds,
data.conflict_key,
next_run,
now,
now,
),
)
return self.get(identifier)
def list(self, limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
return self.repo.list_rows(
"automations",
where="deleted_at IS NULL",
order_by="name COLLATE NOCASE",
limit=limit,
offset=offset,
)
def get(self, identifier: str) -> dict[str, Any]:
return self.repo.get("automations", identifier)
def update(self, identifier: str, data: AutomationCreate) -> dict[str, Any]:
next_run = self._next_run(data.trigger, data.timezone)
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE automations SET name=?, description=?, enabled=?, trigger_json=?, actions_json=?, timezone=?, "
"cooldown_seconds=?, conflict_key=?, next_run_at=?, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(
data.name,
data.description,
int(data.enabled),
json.dumps(data.trigger, separators=(",", ":")),
json.dumps(data.actions, separators=(",", ":")),
data.timezone,
data.cooldown_seconds,
data.conflict_key,
next_run,
utc_now(),
identifier,
),
)
if not cursor.rowcount:
raise NotFoundError("Automation", identifier)
return self.get(identifier)
def delete(self, identifier: str) -> None:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE automations SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Automation", identifier)
async def run(self, identifier: str, trigger: dict[str, Any] | None = None) -> dict[str, Any]:
automation = self.get(identifier)
key = automation["conflict_key"] or identifier
if self._locks[key].locked():
return self._record_skipped(automation, "conflict")
if automation["last_run_at"] and automation["cooldown_seconds"]:
last = datetime.fromisoformat(automation["last_run_at"])
if datetime.now(UTC) < last + timedelta(seconds=automation["cooldown_seconds"]):
return self._record_skipped(automation, "cooldown")
run_id = str(uuid.uuid4())
started = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO automation_runs(id, automation_id, status, trigger_json, started_at) "
"VALUES (?, ?, 'running', ?, ?)",
(run_id, identifier, json.dumps(trigger or {"type": "manual"}), started),
)
results: list[dict[str, Any]] = []
status = "succeeded"
error: str | None = None
async with self._locks[key]:
try:
for action in automation["actions"]:
results.append(await self._execute_action(action))
except Exception as exc:
status = "failed"
error = str(exc)
finished = utc_now()
next_run = self._next_run(automation["trigger"], automation["timezone"])
with self.database.transaction() as conn:
conn.execute(
"UPDATE automation_runs SET status=?, result_json=?, error=?, finished_at=? WHERE id=?",
(status, json.dumps(results, default=str), error, finished, run_id),
)
conn.execute(
"UPDATE automations SET last_run_at=?, next_run_at=?, last_error=?, updated_at=? WHERE id=?",
(finished, next_run, error, finished, identifier),
)
return {"id": run_id, "status": status, "results": results, "error": error}
async def _execute_action(self, action: dict[str, Any]) -> dict[str, Any]:
kind = action.get("type")
if kind == "scene":
return await self.scenes.apply(str(action["scene_id"]))
if kind == "device":
return await self.commands.execute_device(
str(action["device_id"]), DeviceState.model_validate(action["state"])
)
if kind == "all_off":
return await self.commands.all_off()
if kind == "notification":
event_id = self.repo.activity(
str(action.get("title", "Automation")),
str(action.get("message", "Automation uitgevoerd.")),
category="automation",
)
return {"status": "created", "activity_id": event_id}
raise ValueError(f"Onbekende automationactie: {kind}")
def _record_skipped(self, automation: dict[str, Any], reason: str) -> dict[str, Any]:
run_id = str(uuid.uuid4())
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO automation_runs(id, automation_id, status, trigger_json, result_json, "
"started_at, finished_at) VALUES (?, ?, 'skipped', ?, ?, ?, ?)",
(
run_id,
automation["id"],
json.dumps({"type": "manual"}),
json.dumps({"reason": reason}),
now,
now,
),
)
return {"id": run_id, "status": "skipped", "reason": reason}
@staticmethod
def _next_run(trigger: dict[str, Any], timezone: str) -> str | None:
if trigger.get("type") not in {"time", "weekday"}:
return None
hour, minute = map(int, str(trigger.get("at", "00:00")).split(":"))
zone = ZoneInfo(timezone)
now = datetime.now(zone)
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
weekdays = set(trigger.get("weekdays", range(7)))
for _ in range(8):
if candidate > now and candidate.weekday() in weekdays:
return candidate.astimezone(UTC).isoformat()
candidate += timedelta(days=1)
return None
@@ -0,0 +1,34 @@
"""Managed backup listing, creation, and guarded restore."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..database import Database
class BackupService:
def __init__(self, database: Database) -> None:
self.database = database
def list(self) -> list[dict[str, Any]]:
return [
{
"name": path.name,
"size": path.stat().st_size,
"modified_at": path.stat().st_mtime,
}
for path in sorted(self.database.backup_dir.glob("*.db"), reverse=True)
if path.is_file()
]
def create(self) -> dict[str, Any]:
path = self.database.backup("manual")
return {"name": path.name, "size": path.stat().st_size}
def restore(self, name: str) -> dict[str, Any]:
if Path(name).name != name:
raise ValueError("Ongeldige back-upnaam")
safety = self.database.restore(self.database.backup_dir / name)
return {"status": "restored", "safety_backup": safety.name}
@@ -0,0 +1,445 @@
"""Validated, rate-limited and audited hardware command execution."""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
from ..config import Settings
from ..connectors.base import DeviceCapabilities, DeviceState, RGBColor
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
from ..errors import ConflictError, LumaOpsError
from ..events import EventBus
from ..logging_config import request_id_context
from ..repository import Repository
from .inventory import InventoryService
LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class TokenBucket:
rate: float
capacity: float
tokens: float
updated_at: float
async def acquire(self) -> None:
while True:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.updated_at) * self.rate)
self.updated_at = now
if self.tokens >= 1:
self.tokens -= 1
return
await asyncio.sleep((1 - self.tokens) / self.rate)
class CommandService:
def __init__(
self,
settings: Settings,
database: Database,
registry: ConnectorRegistry,
inventory: InventoryService,
events: EventBus,
) -> None:
self.settings = settings
self.database = database
self.registry = registry
self.inventory = inventory
self.events = events
self.repo = Repository(database)
self._locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
self._buckets: dict[str, TokenBucket] = {}
self._emergency_stop = False
async def execute_device(
self,
device_id: str,
state: DeviceState,
*,
actor: str = "local-admin",
request_id: str | None = None,
bypass_emergency: bool = False,
) -> dict[str, Any]:
request_id = request_id or request_id_context.get()
device = self.inventory.get_device(device_id)
effective_state = self._effective_state(device, state)
self._validate_device(device, effective_state, bypass_emergency)
command_id = str(uuid.uuid4())
desired_json = json.dumps(
effective_state.model_dump(mode="json", exclude_none=True), separators=(",", ":")
)
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO commands(id, request_id, actor, target_type, target_id, action, "
"desired_state_json, status, created_at) VALUES (?, ?, ?, 'device', ?, 'set_state', ?, 'queued', ?)",
(command_id, request_id, actor, device_id, desired_json, now),
)
await self.events.publish(
"command.queued", {"command_id": command_id, "device_id": device_id}
)
started = time.perf_counter()
prior_state: DeviceState | None = None
connector = self.registry.get(device["connector_id"])
try:
async with self._locks[device_id]:
await self._bucket(device_id).acquire()
with self.database.connection() as conn:
conn.execute(
"UPDATE commands SET status='running', started_at=? WHERE id=?",
(utc_now(), command_id),
)
prior_state = await asyncio.wait_for(
connector.get_state(device["external_id"]),
timeout=self.settings.command_timeout_seconds,
)
applied = await asyncio.wait_for(
connector.set_state(device["external_id"], effective_state),
timeout=self.settings.command_timeout_seconds,
)
desired = self._desired_snapshot(device, effective_state, applied)
duration = (time.perf_counter() - started) * 1000
finished = utc_now()
with self.database.transaction() as conn:
conn.execute(
"UPDATE commands SET status='succeeded', finished_at=? WHERE id=?",
(finished, command_id),
)
conn.execute(
"INSERT INTO command_results(command_id, device_id, connector_id, status, "
"prior_state_json, applied_state_json, duration_ms, created_at) "
"VALUES (?, ?, ?, 'succeeded', ?, ?, ?, ?)",
(
command_id,
device_id,
device["connector_id"],
json.dumps(prior_state.model_dump(mode="json"), separators=(",", ":")),
json.dumps(applied.model_dump(mode="json"), separators=(",", ":")),
duration,
finished,
),
)
conn.execute(
"UPDATE devices SET state_json=?, desired_state_json=?, last_command_at=?, "
"error_status=NULL, updated_at=? WHERE id=?",
(
json.dumps(applied.model_dump(mode="json"), separators=(",", ":")),
json.dumps(desired.model_dump(mode="json"), separators=(",", ":")),
finished,
finished,
device_id,
),
)
self.repo.audit(
request_id=request_id,
actor=actor,
action="device.set_state",
resource_type="device",
resource_id=device_id,
outcome="succeeded",
detail={
"command_id": command_id,
"state": effective_state.model_dump(mode="json", exclude_none=True),
},
)
await self.events.publish(
"command.completed",
{"command_id": command_id, "device_id": device_id, "status": "succeeded"},
)
return {
"id": command_id,
"status": "succeeded",
"device_id": device_id,
"state": applied.model_dump(mode="json"),
"duration_ms": duration,
}
except Exception as exc:
duration = (time.perf_counter() - started) * 1000
finished = utc_now()
code = exc.code if isinstance(exc, LumaOpsError) else "command_failed"
with self.database.transaction() as conn:
conn.execute(
"UPDATE commands SET status='failed', finished_at=? WHERE id=?",
(finished, command_id),
)
conn.execute(
"INSERT INTO command_results(command_id, device_id, connector_id, status, "
"prior_state_json, error_code, error_message, duration_ms, created_at) "
"VALUES (?, ?, ?, 'failed', ?, ?, ?, ?, ?)",
(
command_id,
device_id,
device["connector_id"],
json.dumps(prior_state.model_dump(mode="json")) if prior_state else None,
code,
str(exc),
duration,
finished,
),
)
conn.execute(
"UPDATE devices SET error_status=?, updated_at=? WHERE id=?",
(code, finished, device_id),
)
self.repo.audit(
request_id=request_id,
actor=actor,
action="device.set_state",
resource_type="device",
resource_id=device_id,
outcome="failed",
detail={"command_id": command_id, "error_code": code},
)
await self.events.publish(
"command.completed",
{
"command_id": command_id,
"device_id": device_id,
"status": "failed",
"error": code,
},
)
raise
async def restore_connector(self, connector_id: str) -> dict[str, Any]:
devices, _ = self.inventory.list_devices(
limit=4096,
online=True,
include_hidden=True,
)
candidates = [device for device in devices if device["connector_id"] == connector_id]
restored = 0
unchanged = 0
skipped = 0
failed = 0
for device in candidates:
desired_payload = device.get("desired_state")
capabilities = DeviceCapabilities.model_validate(device["capabilities"])
if (
not desired_payload
or not capabilities.restore
or device["blocked"]
or device["read_only"]
or device["owner"] == "unmanaged"
):
skipped += 1
continue
desired = DeviceState.model_validate(desired_payload)
actual = DeviceState.model_validate(device["state"])
if self._state_matches(actual, desired):
unchanged += 1
continue
try:
await self.execute_device(
device["id"],
desired,
actor="system-restore",
request_id=f"restore-{uuid.uuid4()}",
)
restored += 1
except Exception as exc:
failed += 1
LOGGER.warning(
"Desired state restore failed: connector=%s device=%s error=%s",
connector_id,
device["id"],
exc,
)
result = {
"status": "completed" if not failed else "degraded",
"connector_id": connector_id,
"restored": restored,
"unchanged": unchanged,
"skipped": skipped,
"failed": failed,
}
LOGGER.info("Desired state reconciliation: %s", result)
return result
@staticmethod
def _effective_state(device: dict[str, Any], requested: DeviceState) -> DeviceState:
prior_payload = device.get("desired_state")
explicit_control_fields = (
"mode",
"mode_index",
"brightness",
"speed",
"direction",
"zone_index",
"led_index",
"transition_ms",
)
direct_color_mode = CommandService._is_direct_color_mode(device, requested)
if (
requested.power is not True
or requested.colors
or not prior_payload
or (
any(getattr(requested, field) is not None for field in explicit_control_fields)
and not direct_color_mode
)
):
return requested
prior = DeviceState.model_validate(prior_payload)
if not prior.colors or not any(
color.red or color.green or color.blue for color in prior.colors
):
return requested
effective = requested.model_dump(mode="json")
effective["colors"] = [color.model_dump(mode="json") for color in prior.colors]
for field in ("mode", "mode_index", "brightness", "speed", "direction"):
if effective[field] is None:
effective[field] = getattr(prior, field)
return DeviceState.model_validate(effective)
@staticmethod
def _is_direct_color_mode(device: dict[str, Any], requested: DeviceState) -> bool:
if requested.mode is not None:
return requested.mode.casefold() in {"direct", "custom"}
if requested.mode_index is None:
return False
return any(
int(mode.get("index", -1)) == requested.mode_index
and str(mode.get("name", "")).casefold() in {"direct", "custom"}
for mode in device.get("modes", [])
)
@staticmethod
def _desired_snapshot(
device: dict[str, Any], requested: DeviceState, applied: DeviceState
) -> DeviceState:
payload = applied.model_dump(mode="json")
prior_payload = device.get("desired_state")
if requested.power is False and not requested.colors and prior_payload:
prior = DeviceState.model_validate(prior_payload)
if prior.colors and any(
color.red or color.green or color.blue for color in prior.colors
):
payload["colors"] = [color.model_dump(mode="json") for color in prior.colors]
payload["mode"] = prior.mode
payload["mode_index"] = prior.mode_index
payload["brightness"] = prior.brightness
payload["speed"] = prior.speed
payload["direction"] = prior.direction
return DeviceState.model_validate(payload)
@staticmethod
def _state_matches(actual: DeviceState, desired: DeviceState) -> bool:
if desired.power is False:
return actual.power is False
if desired.power is not None and actual.power is not desired.power:
return False
if desired.mode_index is not None and actual.mode_index != desired.mode_index:
return False
if desired.mode_index is None and desired.mode is not None and actual.mode != desired.mode:
return False
for field in ("brightness", "speed", "direction"):
expected = getattr(desired, field)
if expected is not None and getattr(actual, field) != expected:
return False
if desired.colors:
if not actual.colors:
return False
expected_colors = desired.colors
if len(expected_colors) == 1 and len(actual.colors) > 1:
expected_colors = expected_colors * len(actual.colors)
if expected_colors != actual.colors:
return False
return True
def _validate_device(
self, device: dict[str, Any], state: DeviceState, bypass_emergency: bool
) -> None:
if self._emergency_stop and not bypass_emergency:
raise ConflictError("De globale noodstop is actief.", device_id=device["id"])
if not device["online"]:
raise LumaOpsError("device_offline", "Het apparaat is offline.", 409)
if device["blocked"]:
raise LumaOpsError("device_blocked", "Het apparaat is volledig geblokkeerd.", 403)
if device["read_only"]:
raise LumaOpsError("device_read_only", "Het apparaat staat in alleen-lezenmodus.", 403)
if device["owner"] == "unmanaged":
raise LumaOpsError(
"device_unmanaged", "Het apparaat heeft geen beheerder geselecteerd.", 409
)
capabilities = DeviceCapabilities.model_validate(device["capabilities"])
requirements = {
"power": state.power is not None,
"brightness": state.brightness is not None,
"rgb": state.colors is not None,
"effect": state.mode is not None or state.mode_index is not None,
"speed": state.speed is not None,
"per_zone": state.zone_index is not None,
"per_led": state.led_index is not None,
}
unsupported = [
name
for name, required in requirements.items()
if required and not getattr(capabilities, name)
]
if unsupported:
raise LumaOpsError(
"unsupported_capability",
"Het apparaat ondersteunt niet alle gevraagde eigenschappen.",
422,
details={"unsupported": unsupported},
)
def _bucket(self, device_id: str) -> TokenBucket:
if device_id not in self._buckets:
rate = self.settings.command_rate_per_second
self._buckets[device_id] = TokenBucket(
rate, max(1.0, rate), max(1.0, rate), time.monotonic()
)
return self._buckets[device_id]
async def identify(self, device_id: str) -> dict[str, Any]:
device = self.inventory.get_device(device_id)
connector = self.registry.get(device["connector_id"])
prior = await connector.get_state(device["external_id"])
flash = DeviceState(power=True, colors=[RGBColor(red=96, green=160, blue=255)])
async with self._locks[device_id]:
for _ in range(2):
await connector.set_state(device["external_id"], flash)
await asyncio.sleep(0.35)
await connector.set_state(device["external_id"], DeviceState(power=False))
await asyncio.sleep(0.35)
await connector.set_state(device["external_id"], prior)
return {"status": "completed", "device_id": device_id}
async def all_off(self, *, emergency: bool = False) -> dict[str, Any]:
if emergency:
self._emergency_stop = True
devices, _ = self.inventory.list_devices(limit=4096, online=True, include_hidden=True)
results = []
for device in devices:
if device["blocked"] or device["read_only"]:
results.append({"device_id": device["id"], "status": "skipped"})
continue
try:
result = await self.execute_device(
device["id"],
DeviceState(power=False),
bypass_emergency=emergency,
)
results.append(result)
except Exception as exc:
results.append({"device_id": device["id"], "status": "failed", "error": str(exc)})
return {"status": "completed", "emergency_stop": self._emergency_stop, "results": results}
def clear_emergency_stop(self) -> None:
self._emergency_stop = False
@property
def emergency_stop(self) -> bool:
return self._emergency_stop
@@ -0,0 +1,84 @@
"""Allowlisted and redacted diagnostic bundle generation."""
from __future__ import annotations
import io
import json
import platform
import zipfile
from typing import Any
from ..config import Settings
from ..database import Database, utc_now
from ..repository import row_to_dict
from .health import HealthService
from .inventory import InventoryService
class DiagnosticsService:
def __init__(
self,
settings: Settings,
database: Database,
health: HealthService,
inventory: InventoryService,
) -> None:
self.settings = settings
self.database = database
self.health = health
self.inventory = inventory
async def build(self) -> bytes:
health = await self.health.detailed()
devices, _ = self.inventory.list_devices(limit=4096, include_hidden=True)
sanitized_devices = [
{
"id": device["id"],
"source": device["source"],
"vendor": device["vendor"],
"model": device["model"],
"online": device["online"],
"capabilities": device["capabilities"],
"error_status": device["error_status"],
}
for device in devices
]
with self.database.connection() as conn:
error_rows = conn.execute(
"SELECT id, category, severity, title, message, created_at FROM activity "
"WHERE severity IN ('warning', 'error') ORDER BY created_at DESC LIMIT 100"
).fetchall()
migration_rows = conn.execute(
"SELECT version, applied_at FROM schema_migrations ORDER BY version"
).fetchall()
info: dict[str, Any] = {
"generated_at": utc_now(),
"lumaops_version": self.settings.app_version,
"openrgb_required_version": "1.0rc3",
"sdk_protocol": 5,
"python": platform.python_version(),
"platform": platform.platform(),
"environment": self.settings.environment,
"features": {
"network_discovery": self.settings.enable_network_discovery,
"wled": self.settings.enable_wled,
"home_assistant": self.settings.enable_home_assistant,
},
}
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
self._json(archive, "system.json", info)
self._json(archive, "health.json", health)
self._json(archive, "devices-redacted.json", sanitized_devices)
self._json(archive, "recent-errors.json", [row_to_dict(row) for row in error_rows])
self._json(archive, "migrations.json", [dict(row) for row in migration_rows])
archive.writestr(
"README.txt",
"LumaOps diagnostic bundle. Secrets, raw environment variables, database files, "
"cookies, tokens, IP addresses and device serial numbers are deliberately excluded.\n",
)
return output.getvalue()
@staticmethod
def _json(archive: zipfile.ZipFile, name: str, value: Any) -> None:
archive.writestr(name, json.dumps(value, ensure_ascii=False, indent=2, default=str))
@@ -0,0 +1,94 @@
"""Aggregated system health without making hardware a liveness dependency."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from ..config import Settings
from ..connectors.base import HealthStatus
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
class HealthService:
def __init__(self, settings: Settings, database: Database, registry: ConnectorRegistry) -> None:
self.settings = settings
self.database = database
self.registry = registry
async def detailed(self) -> dict[str, Any]:
connectors = await self.registry.health()
components: dict[str, dict[str, Any]] = {
"database": {
"status": "healthy" if self.database.ping() else "unhealthy",
"message": "SQLite is bereikbaar."
if self.database.ping()
else "SQLite is niet bereikbaar.",
},
"storage": self._storage_health(),
"openrgb_process": self._process_health(),
}
components.update(
{
connector_id: health.model_dump(mode="json")
for connector_id, health in connectors.items()
}
)
statuses = {component["status"] for component in components.values()}
if "unhealthy" in statuses:
aggregate = (
"unhealthy" if components["database"]["status"] == "unhealthy" else "degraded"
)
elif "degraded" in statuses or "unknown" in statuses:
aggregate = "degraded"
else:
aggregate = "healthy"
return {
"status": aggregate,
"components": components,
"checked_at": utc_now(),
}
def ready(self) -> bool:
return self.database.ping()
def _storage_health(self) -> dict[str, Any]:
paths = [self.settings.config_dir, self.settings.data_dir, self.settings.logs_dir]
failures = [
str(path) for path in paths if not path.exists() or not os.access(path, os.W_OK)
]
return {
"status": "degraded" if failures else "healthy",
"message": "Persistente opslag is schrijfbaar."
if not failures
else "Niet alle opslagpaden zijn schrijfbaar.",
"details": {"failed_paths": failures},
}
@staticmethod
def _process_health() -> dict[str, Any]:
pid_file = Path("/run/lumaops/openrgb.pid")
if not pid_file.exists():
return {
"status": "unknown",
"message": "Geen supervisor-PID-bestand beschikbaar.",
}
try:
pid = int(pid_file.read_text(encoding="ascii").strip())
os.kill(pid, 0)
return {
"status": "healthy",
"message": "OpenRGB-proces draait.",
"details": {"pid": pid},
}
except (OSError, ValueError):
return {"status": "unhealthy", "message": "OpenRGB-proces draait niet."}
def connector_health_status(value: str) -> HealthStatus:
try:
return HealthStatus(value)
except ValueError:
return HealthStatus.UNKNOWN
@@ -0,0 +1,446 @@
"""Stable device identity reconciliation and inventory queries."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
from ..connectors.base import ConnectorDevice
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..events import EventBus
from ..repository import row_to_dict
from ..schemas import DevicePatch
LOGGER = logging.getLogger(__name__)
ReconcileCallback = Callable[[str], Awaitable[dict[str, Any]]]
GROUPED_DEVICE_TYPES = frozenset({"dram"})
class InventoryService:
def __init__(self, database: Database, registry: ConnectorRegistry, events: EventBus) -> None:
self.database = database
self.registry = registry
self.events = events
self._sync_lock = asyncio.Lock()
self._reconcile_callback: ReconcileCallback | None = None
for connector in registry.all():
connector.set_inventory_callback(self.sync_connector)
def set_reconcile_callback(self, callback: ReconcileCallback | None) -> None:
self._reconcile_callback = callback
async def initialize(self) -> None:
now = utc_now()
with self.database.connection() as conn:
for connector in self.registry.all():
conn.execute(
"INSERT INTO connectors(id, kind, name, enabled, config_json, created_at, updated_at) "
"VALUES (?, ?, ?, 1, '{}', ?, ?) ON CONFLICT(id) DO UPDATE SET "
"kind=excluded.kind, updated_at=excluded.updated_at, deleted_at=NULL",
(connector.id, connector.kind, connector.id, now, now),
)
async def sync_all(self) -> dict[str, Any]:
results: dict[str, Any] = {}
for connector in self.registry.all():
try:
results[connector.id] = await self.sync_connector(connector.id)
except Exception as exc:
LOGGER.warning("Inventory sync failed for %s: %s", connector.id, exc)
results[connector.id] = {"status": "failed", "error": str(exc)}
return results
async def sync_connector(self, connector_id: str, *, reconcile: bool = True) -> dict[str, Any]:
async with self._sync_lock:
connector = self.registry.get(connector_id)
devices = await connector.inventory()
now = utc_now()
seen: set[str] = set()
with self.database.transaction() as conn:
conn.execute(
"UPDATE devices SET online=0, updated_at=? WHERE connector_id=? AND deleted_at IS NULL",
(now, connector_id),
)
for device in devices:
device_id = self._upsert(conn, connector_id, device, now)
seen.add(device_id)
conn.execute(
"UPDATE connectors SET health='healthy', last_error=NULL, last_seen_at=?, updated_at=? WHERE id=?",
(now, now, connector_id),
)
await self.events.publish(
"inventory.updated", {"connector_id": connector_id, "device_count": len(seen)}
)
reconciliation: dict[str, Any] = {"status": "disabled"}
if reconcile and self._reconcile_callback:
try:
reconciliation = await self._reconcile_callback(connector_id)
except Exception as exc:
LOGGER.exception("Desired state reconciliation failed for %s", connector_id)
reconciliation = {"status": "failed", "error": str(exc)}
return {
"status": "completed",
"connector_id": connector_id,
"device_count": len(seen),
"reconciliation": reconciliation,
}
def _upsert(self, conn: Any, connector_id: str, device: ConnectorDevice, now: str) -> str:
row = conn.execute(
"SELECT id FROM devices WHERE connector_id=? AND fingerprint=? AND deleted_at IS NULL",
(connector_id, device.fingerprint),
).fetchone()
if row is None:
row = conn.execute(
"SELECT d.id FROM devices d JOIN device_identities i ON i.device_id=d.id "
"WHERE d.connector_id=? AND i.kind='external_id' AND i.value=? AND d.deleted_at IS NULL",
(connector_id, device.external_id),
).fetchone()
device_id = row["id"] if row else str(uuid.uuid4())
payload = (
device_id,
connector_id,
device.external_id,
device.fingerprint,
device.source,
device.device_type,
device.name,
device.vendor,
device.model,
device.serial,
device.location,
device.ip_address,
device.firmware_version,
device.controller_index,
json.dumps(device.capabilities.model_dump(mode="json"), separators=(",", ":")),
json.dumps(device.state.model_dump(mode="json"), separators=(",", ":")),
json.dumps(device.zones, separators=(",", ":")),
json.dumps(device.modes, separators=(",", ":")),
json.dumps(device.metadata, separators=(",", ":")),
device.led_count,
int(device.online),
int(device.experimental),
now,
now,
now,
)
conn.execute(
"INSERT INTO devices(id, connector_id, external_id, fingerprint, source, device_type, name, vendor, model, "
"serial, location, ip_address, firmware_version, controller_index, capabilities_json, state_json, "
"zones_json, modes_json, metadata_json, led_count, online, experimental, last_detected_at, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET external_id=excluded.external_id, fingerprint=excluded.fingerprint, "
"source=excluded.source, device_type=excluded.device_type, name=excluded.name, vendor=excluded.vendor, model=excluded.model, "
"serial=excluded.serial, location=excluded.location, ip_address=excluded.ip_address, "
"firmware_version=excluded.firmware_version, controller_index=excluded.controller_index, "
"capabilities_json=excluded.capabilities_json, state_json=excluded.state_json, "
"zones_json=excluded.zones_json, modes_json=excluded.modes_json, metadata_json=excluded.metadata_json, led_count=excluded.led_count, "
"online=excluded.online, experimental=excluded.experimental, last_detected_at=excluded.last_detected_at, "
"updated_at=excluded.updated_at, deleted_at=NULL",
payload,
)
identities = {
"external_id": device.external_id,
"fingerprint": device.fingerprint,
"serial": device.serial,
"location": device.location,
}
for kind, value in identities.items():
if value:
conn.execute(
"INSERT INTO device_identities(device_id, kind, value, first_seen_at, last_seen_at) "
"VALUES (?, ?, ?, ?, ?) ON CONFLICT(device_id, kind, value) DO UPDATE SET "
"last_seen_at=excluded.last_seen_at",
(device_id, kind, value, now, now),
)
return device_id
def list_devices(
self,
*,
limit: int = 100,
offset: int = 0,
online: bool | None = None,
room_id: str | None = None,
source: str | None = None,
include_hidden: bool = False,
sort: str = "name",
) -> tuple[list[dict[str, Any]], int]:
clauses = ["d.deleted_at IS NULL"]
params: list[Any] = []
if not include_hidden:
clauses.append("d.hidden=0")
if online is not None:
clauses.append("d.online=?")
params.append(int(online))
if room_id:
clauses.append("d.room_id=?")
params.append(room_id)
if source:
clauses.append("d.source=?")
params.append(source)
order_map = {
"name": "COALESCE(d.alias, d.name) COLLATE NOCASE ASC",
"-name": "COALESCE(d.alias, d.name) COLLATE NOCASE DESC",
"online": "d.online DESC, COALESCE(d.alias, d.name) ASC",
"recent": "d.last_detected_at DESC",
}
order = order_map.get(sort, order_map["name"])
where = " AND ".join(clauses)
with self.database.connection() as conn:
total = conn.execute(
f"SELECT COUNT(*) FROM devices d WHERE {where}",
tuple(params), # noqa: S608
).fetchone()[0]
rows = conn.execute(
f"SELECT d.*, r.name AS room_name FROM devices d LEFT JOIN rooms r ON r.id=d.room_id " # noqa: S608
f"WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?",
(*params, limit, offset),
).fetchall()
devices = [row_to_dict(row) or {} for row in rows]
self._attach_tags(conn, devices)
return devices, total
def get_device(self, device_id: str) -> dict[str, Any]:
with self.database.connection() as conn:
row = conn.execute(
"SELECT d.*, r.name AS room_name FROM devices d LEFT JOIN rooms r ON r.id=d.room_id "
"WHERE d.id=? AND d.deleted_at IS NULL",
(device_id,),
).fetchone()
result = row_to_dict(row)
if result is None:
raise NotFoundError("Apparaat", device_id)
with self.database.connection() as conn:
self._attach_tags(conn, [result])
return result
def list_device_groups(self) -> list[dict[str, Any]]:
"""Return stable physical-device families that should be presented as one device."""
devices, _ = self.list_devices(limit=4096, include_hidden=False, sort="name")
buckets: dict[tuple[str, str, str, str, str], list[dict[str, Any]]] = {}
for device in devices:
device_type = str(device.get("device_type") or "unknown")
if device_type not in GROUPED_DEVICE_TYPES:
continue
key = (
str(device.get("connector_id") or ""),
device_type,
str(device.get("vendor") or ""),
str(device.get("model") or ""),
str(device.get("name") or ""),
)
buckets.setdefault(key, []).append(device)
groups = [
self._device_group(key, members) for key, members in buckets.items() if len(members) > 1
]
return sorted(groups, key=lambda item: (str(item["name"]).casefold(), str(item["id"])))
def get_device_group(self, group_id: str) -> dict[str, Any]:
group = next((item for item in self.list_device_groups() if item["id"] == group_id), None)
if group is None:
raise NotFoundError("Apparaatgroep", group_id)
return group
@staticmethod
def _device_group(
key: tuple[str, str, str, str, str], members: list[dict[str, Any]]
) -> dict[str, Any]:
connector_id, device_type, vendor, model, name = key
ordered = sorted(
members,
key=lambda item: (
item.get("controller_index") is None,
item.get("controller_index") or 0,
str(item["id"]),
),
)
digest = hashlib.sha256("\x1f".join(key).encode()).hexdigest()[:16]
first = ordered[0]
states = [item.get("state") or {} for item in ordered]
state_fields = (
"power",
"brightness",
"colors",
"mode",
"mode_index",
"speed",
"direction",
)
aggregate_state = {
field: states[0].get(field)
if all(state.get(field) == states[0].get(field) for state in states[1:])
else None
for field in state_fields
}
mixed = any(
any(state.get(field) != states[0].get(field) for state in states[1:])
for field in state_fields
if field in states[0]
)
boolean_capabilities = (
"power",
"restore",
"rgb",
"brightness",
"color_temperature",
"effect",
"speed",
"direction",
"multiple_colors",
"per_zone",
"per_segment",
"per_led",
"profiles",
"readable_state",
)
capabilities: dict[str, Any] = {
field: all(bool((item.get("capabilities") or {}).get(field)) for item in ordered)
for field in boolean_capabilities
}
capabilities.update(
{
"max_leds": sum(int(item.get("led_count") or 0) for item in ordered),
"min_brightness": max(
int((item.get("capabilities") or {}).get("min_brightness") or 0)
for item in ordered
),
"max_brightness": min(
int((item.get("capabilities") or {}).get("max_brightness") or 100)
for item in ordered
),
"min_speed": InventoryService._shared_limit(ordered, "min_speed", max),
"max_speed": InventoryService._shared_limit(ordered, "max_speed", min),
}
)
modes = []
for mode in first.get("modes") or []:
name_key = str(mode.get("name") or "").casefold()
matches = [
next(
(
candidate
for candidate in item.get("modes") or []
if str(candidate.get("name") or "").casefold() == name_key
),
None,
)
for item in ordered[1:]
]
signature = {
field: mode.get(field)
for field in (
"flags",
"speed_min",
"speed_max",
"brightness",
"colors_min",
"colors_max",
)
}
if all(
match is not None
and all(match.get(field) == value for field, value in signature.items())
for match in matches
):
modes.append(mode)
return {
"id": f"{device_type}-{digest}",
"kind": "device-family",
"device_type": device_type,
"connector_id": connector_id,
"name": name,
"vendor": vendor or None,
"model": model or None,
"online": all(bool(item.get("online")) for item in ordered),
"online_count": sum(bool(item.get("online")) for item in ordered),
"module_count": len(ordered),
"led_count": sum(int(item.get("led_count") or 0) for item in ordered),
"capabilities": capabilities,
"state": aggregate_state,
"modes": modes,
"mixed": mixed,
"devices": ordered,
}
@staticmethod
def _shared_limit(
devices: list[dict[str, Any]], field: str, reducer: Callable[[list[int]], int]
) -> int | None:
values = [
int(value)
for item in devices
if (value := (item.get("capabilities") or {}).get(field)) is not None
]
return reducer(values) if len(values) == len(devices) else None
def patch_device(self, device_id: str, patch: DevicePatch) -> dict[str, Any]:
values = patch.model_dump(exclude_unset=True)
if not values:
return self.get_device(device_id)
tags = values.pop("tags", None)
allowed = {
"alias",
"room_id",
"hidden",
"favorite",
"exclude_global",
"read_only",
"blocked",
"owner",
}
columns: list[str] = []
params: list[Any] = []
for key, value in values.items():
if key not in allowed:
continue
columns.append(f"{key}=?")
params.append(int(value) if isinstance(value, bool) else value)
columns.append("updated_at=?")
params.extend([utc_now(), device_id])
with self.database.transaction() as conn:
cursor = conn.execute(
f"UPDATE devices SET {', '.join(columns)} WHERE id=? AND deleted_at IS NULL", # noqa: S608
tuple(params),
)
if not cursor.rowcount:
raise NotFoundError("Apparaat", device_id)
if tags is not None:
conn.execute("DELETE FROM device_tags WHERE device_id=?", (device_id,))
for name in tags:
tag_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO tags(id, name, created_at) VALUES (?, ?, ?) "
"ON CONFLICT(name) DO NOTHING",
(tag_id, name, utc_now()),
)
row = conn.execute("SELECT id FROM tags WHERE name=?", (name,)).fetchone()
conn.execute(
"INSERT INTO device_tags(device_id, tag_id) VALUES (?, ?)",
(device_id, row["id"]),
)
return self.get_device(device_id)
@staticmethod
def _attach_tags(conn: Any, devices: list[dict[str, Any]]) -> None:
if not devices:
return
identifiers = [device["id"] for device in devices]
placeholders = ",".join("?" for _ in identifiers)
rows = conn.execute(
f"SELECT dt.device_id, t.name FROM device_tags dt JOIN tags t ON t.id=dt.tag_id " # noqa: S608
f"WHERE dt.device_id IN ({placeholders}) ORDER BY t.name COLLATE NOCASE",
identifiers,
).fetchall()
tags: dict[str, list[str]] = {identifier: [] for identifier in identifiers}
for row in rows:
tags[row["device_id"]].append(row["name"])
for device in devices:
device["tags"] = tags[device["id"]]
@@ -0,0 +1,178 @@
"""Transactional room and group resource operations."""
from __future__ import annotations
import json
import uuid
from typing import Any
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..repository import Repository, row_to_dict
from ..schemas import GroupCreate, RoomCreate
class ResourceService:
def __init__(self, database: Database) -> None:
self.database = database
self.repo = Repository(database)
def create_room(self, data: RoomCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO rooms(id, name, description, sort_order, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(identifier, data.name, data.description, data.sort_order, now, now),
)
return self.repo.get("rooms", identifier)
def list_rooms(self) -> list[dict[str, Any]]:
with self.database.connection() as conn:
rows = conn.execute(
"SELECT r.*, COUNT(d.id) AS device_count FROM rooms r LEFT JOIN devices d "
"ON d.room_id=r.id AND d.deleted_at IS NULL WHERE r.deleted_at IS NULL "
"GROUP BY r.id ORDER BY r.sort_order, r.name COLLATE NOCASE"
).fetchall()
return [row_to_dict(row) or {} for row in rows]
def update_room(self, identifier: str, data: RoomCreate) -> dict[str, Any]:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE rooms SET name=?, description=?, sort_order=?, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(data.name, data.description, data.sort_order, utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Kamer", identifier)
return self.repo.get("rooms", identifier)
def delete_room(self, identifier: str) -> None:
with self.database.transaction() as conn:
cursor = conn.execute(
"UPDATE rooms SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Kamer", identifier)
conn.execute(
"UPDATE devices SET room_id=NULL, updated_at=? WHERE room_id=? AND deleted_at IS NULL",
(utc_now(), identifier),
)
def create_group(self, data: GroupCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
with self.database.transaction() as conn:
conn.execute(
"INSERT INTO device_groups(id, name, description, dynamic_query_json, sort_order, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
identifier,
data.name,
data.description,
json.dumps(data.dynamic_query) if data.dynamic_query else None,
data.sort_order,
now,
now,
),
)
for index, device_id in enumerate(dict.fromkeys(data.device_ids)):
conn.execute(
"INSERT INTO group_members(group_id, device_id, sort_order) VALUES (?, ?, ?)",
(identifier, device_id, index),
)
return self.get_group(identifier)
def list_groups(self) -> list[dict[str, Any]]:
with self.database.connection() as conn:
rows = conn.execute(
"SELECT g.*, COUNT(m.device_id) AS device_count FROM device_groups g "
"LEFT JOIN group_members m ON m.group_id=g.id WHERE g.deleted_at IS NULL "
"GROUP BY g.id ORDER BY g.sort_order, g.name COLLATE NOCASE"
).fetchall()
groups = [row_to_dict(row) or {} for row in rows]
for group in groups:
group["device_count"] = len(self.get_group(group["id"])["devices"])
return groups
def get_group(self, identifier: str) -> dict[str, Any]:
with self.database.connection() as conn:
group_row = conn.execute(
"SELECT * FROM device_groups WHERE id=? AND deleted_at IS NULL", (identifier,)
).fetchone()
members = conn.execute(
"SELECT d.*, m.sort_order AS membership_order, m.excluded FROM group_members m "
"JOIN devices d ON d.id=m.device_id WHERE m.group_id=? AND d.deleted_at IS NULL "
"ORDER BY m.sort_order",
(identifier,),
).fetchall()
group = row_to_dict(group_row)
if group is None:
raise NotFoundError("Groep", identifier)
devices = [row_to_dict(row) or {} for row in members]
dynamic = group.get("dynamic_query") or {}
tag_names = [str(tag).strip() for tag in dynamic.get("tags", []) if str(tag).strip()]
if tag_names:
placeholders = ",".join("?" for _ in tag_names)
required = len(tag_names) if dynamic.get("match", "all") == "all" else 1
with self.database.connection() as conn:
dynamic_rows = conn.execute(
f"SELECT d.* FROM devices d JOIN device_tags dt ON dt.device_id=d.id " # noqa: S608
"JOIN tags t ON t.id=dt.tag_id WHERE d.deleted_at IS NULL "
f"AND t.name IN ({placeholders}) GROUP BY d.id " # noqa: S608
"HAVING COUNT(DISTINCT t.name) >= ? ORDER BY COALESCE(d.alias, d.name)",
(*tag_names, required),
).fetchall()
known = {device["id"] for device in devices}
for row in dynamic_rows:
device = row_to_dict(row) or {}
if device["id"] not in known:
device["membership_order"] = 100_000 + len(devices)
device["excluded"] = False
devices.append(device)
group["devices"] = devices
return group
def list_tags(self) -> list[dict[str, Any]]:
with self.database.connection() as conn:
rows = conn.execute(
"SELECT t.*, COUNT(dt.device_id) AS device_count FROM tags t "
"LEFT JOIN device_tags dt ON dt.tag_id=t.id GROUP BY t.id "
"ORDER BY t.name COLLATE NOCASE"
).fetchall()
return [row_to_dict(row) or {} for row in rows]
def update_group(self, identifier: str, data: GroupCreate) -> dict[str, Any]:
with self.database.transaction() as conn:
cursor = conn.execute(
"UPDATE device_groups SET name=?, description=?, dynamic_query_json=?, sort_order=?, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(
data.name,
data.description,
json.dumps(data.dynamic_query) if data.dynamic_query else None,
data.sort_order,
utc_now(),
identifier,
),
)
if not cursor.rowcount:
raise NotFoundError("Groep", identifier)
conn.execute("DELETE FROM group_members WHERE group_id=?", (identifier,))
for index, device_id in enumerate(dict.fromkeys(data.device_ids)):
conn.execute(
"INSERT INTO group_members(group_id, device_id, sort_order) VALUES (?, ?, ?)",
(identifier, device_id, index),
)
return self.get_group(identifier)
def delete_group(self, identifier: str) -> None:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE device_groups SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Groep", identifier)
@@ -0,0 +1,221 @@
"""LumaOps scene persistence, capture, apply, and compensating rollback."""
from __future__ import annotations
import json
import uuid
from typing import Any
from ..connectors.base import DeviceState
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..repository import row_to_dict
from ..schemas import SceneCreate, SceneItemInput
from .commands import CommandService
from .inventory import InventoryService
from .resources import ResourceService
class SceneService:
def __init__(
self,
database: Database,
commands: CommandService,
inventory: InventoryService,
resources: ResourceService,
) -> None:
self.database = database
self.commands = commands
self.inventory = inventory
self.resources = resources
def create(self, data: SceneCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
with self.database.transaction() as conn:
conn.execute(
"INSERT INTO scenes(id, name, description, favorite, version, created_at, updated_at) "
"VALUES (?, ?, ?, ?, 1, ?, ?)",
(identifier, data.name, data.description, int(data.favorite), now, now),
)
self._replace_items(conn, identifier, data.items)
return self.get(identifier)
def update(self, identifier: str, data: SceneCreate) -> dict[str, Any]:
with self.database.transaction() as conn:
cursor = conn.execute(
"UPDATE scenes SET name=?, description=?, favorite=?, version=version+1, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(data.name, data.description, int(data.favorite), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Scène", identifier)
self._replace_items(conn, identifier, data.items)
return self.get(identifier)
def _replace_items(self, conn: Any, scene_id: str, items: list[SceneItemInput]) -> None:
conn.execute("DELETE FROM scene_items WHERE scene_id=?", (scene_id,))
for item in items:
conn.execute(
"INSERT INTO scene_items(id, scene_id, target_type, target_id, state_json, required, sort_order) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
str(uuid.uuid4()),
scene_id,
item.target_type,
item.target_id,
json.dumps(
item.state.model_dump(mode="json", exclude_none=True), separators=(",", ":")
),
int(item.required),
item.sort_order,
),
)
def list_scenes(self, limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
with self.database.connection() as conn:
total = conn.execute("SELECT COUNT(*) FROM scenes WHERE deleted_at IS NULL").fetchone()[
0
]
rows = conn.execute(
"SELECT s.*, COUNT(i.id) AS item_count FROM scenes s LEFT JOIN scene_items i ON i.scene_id=s.id "
"WHERE s.deleted_at IS NULL GROUP BY s.id ORDER BY s.favorite DESC, s.name COLLATE NOCASE "
"LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return [row_to_dict(row) or {} for row in rows], total
def get(self, identifier: str) -> dict[str, Any]:
with self.database.connection() as conn:
row = conn.execute(
"SELECT * FROM scenes WHERE id=? AND deleted_at IS NULL", (identifier,)
).fetchone()
item_rows = conn.execute(
"SELECT * FROM scene_items WHERE scene_id=? ORDER BY sort_order", (identifier,)
).fetchall()
scene = row_to_dict(row)
if scene is None:
raise NotFoundError("Scène", identifier)
scene["items"] = [row_to_dict(item) or {} for item in item_rows]
return scene
def delete(self, identifier: str) -> None:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE scenes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Scène", identifier)
def capture(self, name: str, device_ids: list[str] | None = None) -> dict[str, Any]:
devices, _ = self.inventory.list_devices(limit=4096, online=True, include_hidden=False)
selected: set[str] = set(device_ids or [])
items = [
SceneItemInput(
target_type="device",
target_id=device["id"],
state=DeviceState.model_validate(device["state"]),
required=True,
sort_order=index,
)
for index, device in enumerate(devices)
if not selected or device["id"] in selected
]
return self.create(SceneCreate(name=name, items=items))
def duplicate(self, identifier: str, name: str | None = None) -> dict[str, Any]:
source = self.get(identifier)
items = [SceneItemInput.model_validate(item) for item in source["items"]]
return self.create(
SceneCreate(
name=name or f"{source['name']} (kopie)",
description=source.get("description"),
favorite=False,
items=items,
)
)
async def apply(self, identifier: str, rollback_on_failure: bool = True) -> dict[str, Any]:
scene = self.get(identifier)
plan = self._resolve_items(scene["items"])
applied: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
prior: dict[str, DeviceState] = {}
for item in plan:
device_id = item["device_id"]
device = self.inventory.get_device(device_id)
if device["exclude_global"]:
skipped.append({"device_id": device_id, "reason": "excluded"})
continue
try:
prior[device_id] = DeviceState.model_validate(device["state"])
result = await self.commands.execute_device(device_id, item["state"])
applied.append(result)
except Exception as exc:
failed.append({"device_id": device_id, "error": str(exc)})
if item["required"]:
break
rolled_back: list[dict[str, Any]] = []
if failed and rollback_on_failure:
for result in reversed(applied):
device_id = result["device_id"]
try:
rollback = await self.commands.execute_device(device_id, prior[device_id])
rolled_back.append({"device_id": device_id, "status": rollback["status"]})
except Exception as exc:
rolled_back.append(
{"device_id": device_id, "status": "failed", "error": str(exc)}
)
if not failed:
with self.database.connection() as conn:
conn.execute(
"UPDATE scenes SET last_applied_at=?, updated_at=? WHERE id=?",
(utc_now(), utc_now(), identifier),
)
return {
"scene_id": identifier,
"status": "failed" if failed else "succeeded",
"applied": applied,
"failed": failed,
"skipped": skipped,
"rolled_back": rolled_back,
}
def preview(self, identifier: str) -> dict[str, Any]:
scene = self.get(identifier)
plan = self._resolve_items(scene["items"])
return {
"scene_id": identifier,
"device_count": len({item["device_id"] for item in plan}),
"commands": [
{
"device_id": item["device_id"],
"required": item["required"],
"state": item["state"].model_dump(mode="json", exclude_none=True),
}
for item in plan
],
}
def _resolve_items(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
plan: dict[str, dict[str, Any]] = {}
for item in items:
state = DeviceState.model_validate(item["state"])
if item["target_type"] == "device":
plan[item["target_id"]] = {
"device_id": item["target_id"],
"state": state,
"required": item["required"],
}
elif item["target_type"] == "group":
group = self.resources.get_group(item["target_id"])
for device in group["devices"]:
if not device["excluded"]:
plan[device["id"]] = {
"device_id": device["id"],
"state": state,
"required": item["required"],
}
return list(plan.values())
@@ -0,0 +1,129 @@
"""Read-only first-run diagnostics and setup completion state."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from ..config import Settings
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
from ..repository import row_to_dict
class SetupService:
def __init__(self, settings: Settings, database: Database, registry: ConnectorRegistry) -> None:
self.settings = settings
self.database = database
self.registry = registry
def state(self) -> dict[str, Any]:
with self.database.connection() as conn:
return (
row_to_dict(conn.execute("SELECT * FROM setup_state WHERE singleton=1").fetchone())
or {}
)
async def inspect(self, include_network: bool = False) -> dict[str, Any]:
del include_network
paths = [
self.settings.config_dir,
self.settings.openrgb_config_dir,
self.settings.data_dir,
self.settings.logs_dir,
]
storage = [self._path_status(path) for path in paths]
usb = self._device_nodes(Path("/dev/bus/usb"), "*")
i2c = self._device_nodes(Path("/dev"), "i2c-*")
serial = self._device_nodes(Path("/dev"), "ttyUSB*") + self._device_nodes(
Path("/dev"), "ttyACM*"
)
connector_health = {
key: value.model_dump(mode="json")
for key, value in (await self.registry.health()).items()
}
openrgb = next(
(value for key, value in connector_health.items() if key.startswith("openrgb")), None
)
recovery: list[dict[str, str]] = []
if any(not item["writable"] for item in storage):
recovery.append(
{
"area": "storage",
"message": "Controleer de Unraid appdata-mounts en UID/GID-rechten voor /config, /data en /logs.",
}
)
if not usb:
recovery.append(
{
"area": "usb",
"message": "Map /dev/bus/usb in de container en controleer host-udev/hidraw-permissies.",
}
)
if not i2c:
recovery.append(
{
"area": "i2c",
"message": "Laad i2c-dev plus de chipsetdriver op Unraid en map alleen de benodigde /dev/i2c-* nodes.",
}
)
if openrgb and not openrgb["connected"]:
recovery.append(
{
"area": "sdk",
"message": "Controleer openrgb.log en bevestig dat OpenRGB op 127.0.0.1:6742 gestart is.",
}
)
report = {
"storage": storage,
"openrgb": openrgb,
"connectors": connector_health,
"usb_devices": usb,
"i2c_devices": i2c,
"serial_devices": serial,
"recovery": recovery,
"checked_at": utc_now(),
}
with self.database.connection() as conn:
conn.execute(
"UPDATE setup_state SET current_step='review', report_json=?, updated_at=? WHERE singleton=1",
(__import__("json").dumps(report, separators=(",", ":")), utc_now()),
)
return report
def complete(self, appdata_confirmed: bool, backup_location_confirmed: bool) -> dict[str, Any]:
if not appdata_confirmed or not backup_location_confirmed:
raise ValueError("Bevestig zowel appdata als back-uplocatie om setup af te ronden")
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"UPDATE setup_state SET completed=1, current_step='complete', completed_at=?, updated_at=? "
"WHERE singleton=1",
(now, now),
)
return self.state()
@staticmethod
def _path_status(path: Path) -> dict[str, Any]:
path.mkdir(parents=True, exist_ok=True)
return {
"path": str(path),
"exists": path.exists(),
"readable": os.access(path, os.R_OK),
"writable": os.access(path, os.W_OK),
}
@staticmethod
def _device_nodes(root: Path, pattern: str) -> list[dict[str, Any]]:
if not root.exists():
return []
return [
{
"path": str(path),
"readable": os.access(path, os.R_OK),
"writable": os.access(path, os.W_OK),
}
for path in sorted(root.glob(pattern))[:4096]
if path.is_file() or path.is_char_device()
]
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from lumaops_backend.config import Settings
from lumaops_backend.main import create_app
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
LUMAOPS_ENV="test",
connector_mode="mock",
auth_enabled=False,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
static_dir=tmp_path / "static",
database_url=f"sqlite:///{(tmp_path / 'data' / 'lumaops.db').as_posix()}",
log_level="ERROR",
)
@pytest.fixture
def client(settings: Settings) -> Iterator[TestClient]:
with TestClient(create_app(settings)) as test_client:
yield test_client
+403
View File
@@ -0,0 +1,403 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.mock import MockOpenRGBAdapter
from lumaops_backend.main import create_app
from lumaops_backend.services.commands import CommandService
def test_direct_mode_without_explicit_color_preserves_last_nonzero_color() -> None:
color = RGBColor(red=86, green=96, blue=255)
effective = CommandService._effective_state(
{
"desired_state": DeviceState(power=True, colors=[color]).model_dump(mode="json"),
"modes": [{"index": 0, "name": "Direct"}],
},
DeviceState(power=True, mode_index=0),
)
assert effective.colors == [color]
assert effective.mode_index == 0
def test_main_device_command_flow(client: TestClient) -> None:
discovery = client.post("/api/v1/discovery")
assert discovery.status_code == 200
assert discovery.json()["found_count"] == 2
devices_response = client.get("/api/v1/devices")
assert devices_response.status_code == 200
devices = devices_response.json()["items"]
assert len(devices) == 2
device = next(item for item in devices if item["fingerprint"] == "mock-mainboard")
request = {"state": {"power": True, "colors": [{"red": 20, "green": 40, "blue": 60}]}}
command = client.post(
f"/api/v1/devices/{device['id']}/state",
json=request,
headers={"Idempotency-Key": "main-flow-1"},
)
assert command.status_code == 200
assert command.json()["status"] == "succeeded"
duplicate = client.post(
f"/api/v1/devices/{device['id']}/state",
json=request,
headers={"Idempotency-Key": "main-flow-1"},
)
assert duplicate.json()["id"] == command.json()["id"]
detail = client.get(f"/api/v1/devices/{device['id']}").json()
assert detail["state"]["colors"][0] == {"red": 20, "green": 40, "blue": 60}
resized = client.put(
f"/api/v1/devices/{device['id']}/zones/1",
json={"led_count": 24},
)
assert resized.status_code == 200
assert resized.json()["zones"][1]["led_count"] == 24
assert resized.json()["led_count"] == 32
zone_color = {"red": 10, "green": 80, "blue": 160}
zone_command = client.post(
f"/api/v1/devices/{device['id']}/zones/1/state",
json={"state": {"colors": [zone_color]}},
)
assert zone_command.status_code == 200
assert zone_command.json()["zone_index"] == 1
assert zone_command.json()["led_count"] == 120
assert client.get(f"/api/v1/devices/{device['id']}").json()["zones"][1]["led_count"] == 120
commands = client.get("/api/v1/commands").json()
audit = client.get("/api/v1/audit").json()
assert commands["total"] == 2
assert audit["total"] >= 1
assert any(item["action"] == "device.resize_zone" for item in audit["items"])
def test_desired_state_survives_connector_restart_and_power_cycle(client: TestClient) -> None:
client.post("/api/v1/discovery")
device = next(
item
for item in client.get("/api/v1/devices").json()["items"]
if item["fingerprint"] == "mock-mainboard"
)
target = f"/api/v1/devices/{device['id']}"
color = {"red": 81, "green": 109, "blue": 245}
applied = client.post(
f"{target}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
detail = client.get(target).json()
assert detail["desired_state"]["colors"][0] == color
connector = client.app.state.context.registry.get("openrgb-mock")
assert isinstance(connector, MockOpenRGBAdapter)
connector._devices["mock-mainboard"].state = DeviceState( # noqa: SLF001
power=False,
brightness=72,
colors=[RGBColor(red=0, green=0, blue=0)] * 12,
mode="Direct",
mode_index=0,
speed=5,
)
commands_before = client.get("/api/v1/commands").json()["total"]
discovery = client.post("/api/v1/discovery")
assert discovery.status_code == 200
restored = client.get(target).json()
assert restored["state"]["power"] is True
assert restored["state"]["colors"]
assert all(entry == color for entry in restored["state"]["colors"])
commands_after = client.get("/api/v1/commands").json()
assert commands_after["total"] == commands_before + 1
assert commands_after["items"][0]["actor"] == "system-restore"
assert client.post("/api/v1/discovery").status_code == 200
assert client.get("/api/v1/commands").json()["total"] == commands_after["total"]
assert client.post(f"{target}/state", json={"state": {"power": False}}).status_code == 200
powered_off = client.get(target).json()
assert powered_off["state"]["power"] is False
assert powered_off["desired_state"]["colors"]
assert all(entry == color for entry in powered_off["desired_state"]["colors"])
assert client.post(f"{target}/state", json={"state": {"power": True}}).status_code == 200
powered_on = client.get(target).json()
assert powered_on["state"]["power"] is True
assert powered_on["state"]["colors"]
assert all(entry == color for entry in powered_on["state"]["colors"])
def test_explicit_colorless_effect_is_not_replaced_by_power_restore(
client: TestClient,
) -> None:
device = client.get("/api/v1/devices").json()["items"][0]
client.post(
f"/api/v1/devices/{device['id']}/state",
json={"state": {"power": True, "colors": [{"red": 24, "green": 48, "blue": 96}]}},
)
effect = client.post(
f"/api/v1/devices/{device['id']}/state",
json={"state": {"power": True, "mode_index": 2}},
)
assert effect.status_code == 200
assert effect.json()["state"]["mode_index"] == 2
def test_startup_reconciles_desired_state_once_before_ready(settings) -> None:
color = {"red": 24, "green": 96, "blue": 192}
device_id = ""
with TestClient(create_app(settings)) as first_client:
device = next(
item
for item in first_client.get("/api/v1/devices").json()["items"]
if item["fingerprint"] == "mock-mainboard"
)
device_id = device["id"]
applied = first_client.post(
f"/api/v1/devices/{device_id}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
with TestClient(create_app(settings)) as restarted_client:
assert restarted_client.get("/health/ready").json() == {"status": "ready"}
restored = restarted_client.get(f"/api/v1/devices/{device_id}").json()
assert restored["state"]["colors"]
assert all(entry == color for entry in restored["state"]["colors"])
commands = restarted_client.get("/api/v1/commands?limit=100").json()["items"]
restores = [
command
for command in commands
if command["actor"] == "system-restore" and command["target_id"] == device_id
]
assert len(restores) == 1
assert restores[0]["status"] == "succeeded"
def test_rooms_groups_and_scenes(client: TestClient) -> None:
client.post("/api/v1/discovery")
devices = client.get("/api/v1/devices").json()["items"]
room = client.post("/api/v1/rooms", json={"name": "Werkplek", "sort_order": 1})
assert room.status_code == 201
first = devices[0]
assert (
client.patch(
f"/api/v1/devices/{first['id']}", json={"room_id": room.json()["id"]}
).status_code
== 200
)
group = client.post(
"/api/v1/groups",
json={"name": "Bureau", "device_ids": [item["id"] for item in devices]},
)
assert group.status_code == 201
tagged = client.patch(f"/api/v1/devices/{devices[1]['id']}", json={"tags": ["bureau", "rgb"]})
assert tagged.status_code == 200
assert tagged.json()["tags"] == ["bureau", "rgb"]
dynamic_group = client.post(
"/api/v1/groups",
json={"name": "Met tag", "dynamic_query": {"tags": ["bureau"], "match": "all"}},
)
assert dynamic_group.status_code == 201
assert [item["id"] for item in dynamic_group.json()["devices"]] == [devices[1]["id"]]
assert client.get("/api/v1/tags").json()[0]["device_count"] == 1
scene = client.post(
"/api/v1/scenes",
json={
"name": "Focus",
"favorite": True,
"items": [
{
"target_type": "group",
"target_id": group.json()["id"],
"state": {"power": True, "colors": [{"red": 42, "green": 80, "blue": 180}]},
}
],
},
)
assert scene.status_code == 201
preview = client.get(f"/api/v1/scenes/{scene.json()['id']}/preview").json()
assert preview["device_count"] == 2
applied = client.post(
f"/api/v1/scenes/{scene.json()['id']}/apply", json={"rollback_on_failure": True}
)
assert applied.status_code == 200
assert applied.json()["status"] == "succeeded"
duplicate = client.post(f"/api/v1/scenes/{scene.json()['id']}/duplicate", json={})
assert duplicate.status_code == 201
assert duplicate.json()["name"] == "Focus (kopie)"
assert len(duplicate.json()["items"]) == 1
exported = client.get(f"/api/v1/scenes/{scene.json()['id']}/export")
assert exported.status_code == 200
assert exported.json()["format"] == "lumaops-scene-v1"
imported = client.post("/api/v1/scenes/import", json=exported.json())
assert imported.status_code == 201
assert imported.json()["name"] == "Focus"
assert client.get("/api/v1/scenes").json()["total"] == 3
def test_physical_dram_family_groups_modules_and_supports_group_commands(
client: TestClient,
) -> None:
devices = client.get("/api/v1/devices").json()["items"]
device_ids = [device["id"] for device in devices]
with client.app.state.context.database.connection() as conn:
conn.execute(
"UPDATE devices SET device_type='dram', name='Corsair Vengeance RGB Pro SL DDR4', "
"vendor='Corsair', model='Corsair DRAM RGB Device', state_json=(SELECT state_json "
"FROM devices ORDER BY controller_index LIMIT 1) WHERE id IN (?, ?)",
tuple(device_ids),
)
groups = client.get("/api/v1/device-groups")
assert groups.status_code == 200
assert len(groups.json()) == 1
group = groups.json()[0]
assert group["device_type"] == "dram"
assert group["module_count"] == 2
assert group["mixed"] is False
assert [device["controller_index"] for device in group["devices"]] == [0, 1]
color = {"red": 34, "green": 102, "blue": 204}
applied = client.post(
f"/api/v1/device-groups/{group['id']}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
assert applied.json()["status"] == "succeeded"
assert [result["status"] for result in applied.json()["results"]] == [
"succeeded",
"succeeded",
]
for device_id in device_ids:
detail = client.get(f"/api/v1/devices/{device_id}").json()
assert detail["desired_state"]["colors"][0] == color
def test_setup_health_backup_and_diagnostics(client: TestClient) -> None:
setup = client.post("/api/v1/setup/inspect")
assert setup.status_code == 200
assert "storage" in setup.json()
complete = client.post(
"/api/v1/setup/complete",
json={"appdata_confirmed": True, "backup_location_confirmed": True},
)
assert complete.json()["completed"] is True
assert client.get("/health/live").status_code == 200
assert client.get("/health/ready").status_code == 200
health = client.get("/api/v1/health")
assert health.status_code == 200
backup = client.post("/api/v1/backups")
assert backup.status_code == 201
assert backup.json()["name"].endswith(".db")
diagnostic = client.get("/api/v1/diagnostics/export")
assert diagnostic.status_code == 200
assert diagnostic.content.startswith(b"PK")
def test_setup_complete_accepts_legacy_backup_field(client: TestClient) -> None:
complete = client.post(
"/api/v1/setup/complete",
json={"appdata_confirmed": True, "backup_confirmed": True},
)
assert complete.status_code == 200
assert complete.json()["completed"] is True
def test_resource_lifecycle_group_capabilities_and_automation_history(
client: TestClient,
) -> None:
client.post("/api/v1/discovery")
devices = client.get("/api/v1/devices").json()["items"]
room = client.post("/api/v1/rooms", json={"name": "Studio", "description": "Boven"}).json()
updated_room = client.put(
f"/api/v1/rooms/{room['id']}",
json={"name": "Werkstudio", "description": "Bovenverdieping"},
)
assert updated_room.status_code == 200
assert updated_room.json()["description"] == "Bovenverdieping"
client.patch(f"/api/v1/devices/{devices[0]['id']}", json={"room_id": room["id"]})
assert client.delete(f"/api/v1/rooms/{room['id']}").status_code == 204
assert client.get(f"/api/v1/devices/{devices[0]['id']}").json()["room_id"] is None
group = client.post(
"/api/v1/groups",
json={"name": "Alles", "device_ids": [device["id"] for device in devices]},
).json()
group_result = client.post(
f"/api/v1/groups/{group['id']}/state",
json={"state": {"speed": 3}},
)
assert group_result.status_code == 200
assert {item["status"] for item in group_result.json()["results"]} == {
"succeeded",
"skipped",
}
scene = client.post("/api/v1/scenes", json={"name": "Leeg", "items": []}).json()
automation = client.post(
"/api/v1/automations",
json={
"name": "Avond",
"description": "Dagelijkse rustige scène",
"trigger": {"type": "time", "at": "20:00", "weekdays": [0, 2, 4]},
"actions": [{"type": "scene", "scene_id": scene["id"]}],
"timezone": "Europe/Brussels",
"cooldown_seconds": 0,
"conflict_key": "woonkamer",
},
)
assert automation.status_code == 201
assert automation.json()["description"] == "Dagelijkse rustige scène"
run = client.post(f"/api/v1/automations/{automation.json()['id']}/run")
assert run.status_code == 200
assert run.json()["status"] == "succeeded"
history = client.get(f"/api/v1/automations/{automation.json()['id']}/runs").json()
assert history["total"] == 1
assert history["items"][0]["status"] == "succeeded"
audit = client.get("/api/v1/audit?limit=500").json()["items"]
assert any(item["action"] == "api.create_room" for item in audit)
assert any(item["action"] == "api.create_automation" for item in audit)
def test_automation_requests_reject_invalid_schedules_and_actions(client: TestClient) -> None:
base = {
"name": "Ongeldig",
"description": "Validatiecontrole",
"trigger": {"type": "time", "at": "20:00", "weekdays": [0]},
"actions": [{"type": "scene", "scene_id": "scene-id"}],
"timezone": "Europe/Brussels",
}
invalid_time = client.post(
"/api/v1/automations",
json={**base, "trigger": {"type": "time", "at": "25:90", "weekdays": [0]}},
)
assert invalid_time.status_code == 422
assert invalid_time.json()["error"]["code"] == "validation_error"
missing_scene = client.post(
"/api/v1/automations",
json={**base, "actions": [{"type": "scene", "scene_id": None}]},
)
assert missing_scene.status_code == 422
invalid_timezone = client.post(
"/api/v1/automations",
json={**base, "timezone": "Mars/Olympus_Mons"},
)
assert invalid_timezone.status_code == 422
@@ -0,0 +1,25 @@
from __future__ import annotations
import pytest
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.mock import MockOpenRGBAdapter
@pytest.mark.asyncio
async def test_mock_connector_contract() -> None:
connector = MockOpenRGBAdapter()
await connector.start()
health = await connector.test_connection()
assert health.connected is True
devices = await connector.discover()
assert devices
device = devices[0]
before = await connector.get_state(device.external_id)
assert before.colors
after = await connector.set_state(
device.external_id,
DeviceState(power=True, colors=[RGBColor(red=10, green=20, blue=30)]),
)
assert after.colors == [RGBColor(red=10, green=20, blue=30)]
await connector.stop()
@@ -0,0 +1,95 @@
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from lumaops_backend.config import Settings
from lumaops_backend.database import Database
from lumaops_backend.logging_config import JsonFormatter
from lumaops_backend.main import create_app
from lumaops_backend.secrets import SecretStore
def test_production_lan_bind_requires_authentication() -> None:
with pytest.raises(ValidationError, match="niet-lokale productiebind"):
Settings(auth_enabled=False)
def test_authentication_rejects_placeholder_token() -> None:
with pytest.raises(ValidationError, match="minstens 32 tekens"):
Settings(LUMAOPS_ADMIN_TOKEN="replace-with-a-long-random-token") # noqa: S106
def test_migration_is_repeatable(settings: Settings) -> None:
database = Database(settings)
database.initialize()
database.initialize()
with database.connection() as conn:
versions = conn.execute("SELECT version FROM schema_migrations").fetchall()
foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0]
journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert [row["version"] for row in versions] == [
"0001_initial",
"0002_automation_descriptions",
"0003_device_desired_state",
"0004_device_classification",
]
assert foreign_keys == 1
assert journal_mode == "wal"
def test_secrets_are_encrypted_and_key_is_persistent(settings: Settings) -> None:
first = SecretStore(settings)
ciphertext = first.encrypt("very-secret-token")
assert b"very-secret-token" not in ciphertext
second = SecretStore(settings)
assert second.decrypt(ciphertext) == "very-secret-token"
assert (settings.config_dir / "secret.key").exists()
def test_json_logger_redacts_secret_fields() -> None:
formatter = JsonFormatter()
record = logging.LogRecord(
"test",
logging.INFO,
__file__,
1,
"payload %s",
({"api_key": "secret", "device": "ok"},),
None,
)
rendered = formatter.format(record)
assert "[REDACTED]" in rendered
assert "secret" not in rendered
def test_auth_cookie_requires_csrf(tmp_path: Path) -> None:
settings = Settings(
LUMAOPS_ENV="test",
connector_mode="mock",
auth_enabled=True,
LUMAOPS_ADMIN_TOKEN="correct-horse-battery-staple-test-token", # noqa: S106 - test-only token
secure_cookies=False,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
static_dir=tmp_path / "static",
database_url=f"sqlite:///{(tmp_path / 'data' / 'auth.db').as_posix()}",
log_level="ERROR",
)
with TestClient(create_app(settings)) as client:
assert client.get("/api/v1/system").status_code == 401
login = client.post(
"/api/v1/auth/login", json={"token": "correct-horse-battery-staple-test-token"}
)
assert login.status_code == 200
assert client.get("/api/v1/system").status_code == 200
without_csrf = client.post("/api/v1/discovery")
assert without_csrf.status_code == 403
csrf = login.json()["csrf_token"]
assert client.post("/api/v1/discovery", headers={"X-CSRF-Token": csrf}).status_code == 200
@@ -0,0 +1,240 @@
from __future__ import annotations
import asyncio
import struct
from pathlib import Path
import pytest
from lumaops_backend.config import Settings
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.openrgb.adapter import OpenRGBAdapter, expand_colors
from lumaops_backend.connectors.openrgb.protocol import (
HEADER,
ModeFlag,
PacketId,
pack_color,
pack_header,
pack_string,
parse_header,
)
def controller_packet(
*,
active_mode: int = 0,
controller_colors: list[RGBColor] | None = None,
static_color: RGBColor | None = None,
) -> bytes:
color = static_color or RGBColor(red=16, green=32, blue=48)
led_colors = controller_colors or [RGBColor(red=16, green=32, blue=48)] * 2
direct_mode = (
pack_string("Direct")
+ struct.pack("<iI", 0, int(ModeFlag.HAS_PER_LED_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)
+ struct.pack("<H", 0)
)
static_mode = (
pack_string("Static")
+ struct.pack("<iI", 7, int(ModeFlag.HAS_BRIGHTNESS | ModeFlag.HAS_MODE_SPECIFIC_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 255, 1, 1, 0, 128, 0, 1)
+ struct.pack("<H", 1)
+ pack_color(color)
)
custom_mode = (
pack_string("Custom")
+ struct.pack("<iI", 1, int(ModeFlag.HAS_PER_LED_COLOR | ModeFlag.AUTOMATIC_SAVE))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)
+ struct.pack("<H", 0)
)
zone = (
pack_string("Main")
+ struct.pack("<iIIIH", 1, 0, 120, 2, 0)
+ struct.pack("<H", 0)
+ struct.pack("<I", 0)
)
leds = pack_string("LED 1") + struct.pack("<I", 1) + pack_string("LED 2") + struct.pack("<I", 2)
body = (
struct.pack("<i", 0)
+ pack_string("SDK Fixture")
+ pack_string("LumaOps")
+ pack_string("Integration test")
+ pack_string("1.2.3")
+ pack_string("SERIAL-1")
+ pack_string("usb:1-2")
+ struct.pack("<H", 3)
+ struct.pack("<i", active_mode)
+ direct_mode
+ custom_mode
+ static_mode
+ struct.pack("<H", 1)
+ zone
+ struct.pack("<H", 2)
+ leds
+ struct.pack("<H", 2)
+ b"".join(pack_color(item) for item in led_colors)
+ struct.pack("<H", 0)
+ struct.pack("<I", 1)
)
return struct.pack("<I", len(body) + 4) + body
def test_uniform_color_state_survives_a_changed_led_count() -> None:
color = RGBColor(red=86, green=96, blue=255)
assert expand_colors([color] * 6, 23) == [color] * 23
@pytest.mark.asyncio
async def test_real_adapter_handshake_inventory_and_write(tmp_path: Path) -> None:
seen: list[tuple[int, bytes]] = []
update_received = asyncio.Event()
effect_received = asyncio.Event()
resize_received = asyncio.Event()
active_mode = 0
fixture_colors = [RGBColor(red=16, green=32, blue=48)] * 2
fixture_static_color = RGBColor(red=16, green=32, blue=48)
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
nonlocal active_mode, fixture_colors, fixture_static_color
try:
while True:
header = parse_header(await reader.readexactly(HEADER.size))
payload = await reader.readexactly(header.payload_size)
seen.append((header.packet_id, payload))
if header.packet_id == PacketId.UPDATE_LEDS:
count = struct.unpack_from("<H", payload, 4)[0]
fixture_colors = []
for index in range(count):
red, green, blue = struct.unpack_from("<BBB", payload, 6 + index * 4)
fixture_colors.append(RGBColor(red=red, green=green, blue=blue))
update_received.set()
elif header.packet_id == PacketId.UPDATE_ZONE_LEDS:
zone_index, count = struct.unpack_from("<IH", payload, 4)
assert zone_index == 0
fixture_colors = []
for index in range(count):
red, green, blue = struct.unpack_from("<BBB", payload, 10 + index * 4)
fixture_colors.append(RGBColor(red=red, green=green, blue=blue))
update_received.set()
elif header.packet_id == PacketId.UPDATE_MODE:
active_mode = struct.unpack_from("<i", payload, 4)[0]
if active_mode == 2:
red, green, blue = struct.unpack_from("<BBB", payload, len(payload) - 4)
fixture_static_color = RGBColor(red=red, green=green, blue=blue)
effect_received.set()
elif header.packet_id == PacketId.SET_CUSTOM_MODE:
active_mode = 0
elif header.packet_id == PacketId.RESIZE_ZONE:
resize_received.set()
response: bytes | None = None
if header.packet_id == PacketId.REQUEST_PROTOCOL_VERSION:
response = struct.pack("<I", 5)
elif header.packet_id == PacketId.REQUEST_CONTROLLER_COUNT:
response = struct.pack("<I", 1)
elif header.packet_id == PacketId.REQUEST_CONTROLLER_DATA:
response = controller_packet(
active_mode=active_mode,
controller_colors=fixture_colors,
static_color=fixture_static_color,
)
if response is not None:
writer.write(
pack_header(header.device_index, header.packet_id, len(response)) + response
)
await writer.drain()
except asyncio.IncompleteReadError:
pass
finally:
writer.close()
await writer.wait_closed()
server = await asyncio.start_server(handle, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
settings = Settings(
LUMAOPS_ENV="test",
auth_enabled=False,
openrgb_host="127.0.0.1",
openrgb_port=port,
openrgb_connect_timeout=1,
openrgb_command_timeout=1,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
database_url=f"sqlite:///{tmp_path / 'test.db'}",
)
adapter = OpenRGBAdapter(settings)
try:
devices = await adapter.inventory()
assert len(devices) == 1
assert devices[0].name == "SDK Fixture"
state = await adapter.set_state(
devices[0].external_id,
DeviceState(
colors=[RGBColor(red=2, green=4, blue=8)],
brightness=100,
mode_index=1,
),
)
assert state.colors == [RGBColor(red=2, green=4, blue=8)] * 2
assert state.mode == "Direct"
assert state.mode_index == 0
assert state.brightness is None
await adapter.resize_zone(devices[0].external_id, 0, 24)
await asyncio.wait_for(resize_received.wait(), timeout=1)
assert [packet_id for packet_id, _payload in seen[:4]] == [
PacketId.REQUEST_PROTOCOL_VERSION,
PacketId.SET_CLIENT_NAME,
PacketId.REQUEST_CONTROLLER_COUNT,
PacketId.REQUEST_CONTROLLER_DATA,
]
assert any(packet_id == PacketId.SET_CUSTOM_MODE for packet_id, _payload in seen)
assert any(packet_id == PacketId.UPDATE_LEDS for packet_id, _payload in seen)
assert (PacketId.RESIZE_ZONE, struct.pack("<ii", 0, 24)) in seen
zone_start = len(seen)
zone_color = RGBColor(red=9, green=18, blue=27)
zone_state = await adapter.set_state(
devices[0].external_id,
DeviceState(colors=[zone_color], zone_index=0),
)
zone_packets = [packet_id for packet_id, _payload in seen[zone_start:]]
assert zone_state.colors == [zone_color] * 2
assert PacketId.UPDATE_ZONE_LEDS in zone_packets
assert PacketId.SET_CUSTOM_MODE not in zone_packets
effect_start = len(seen)
effect_received.clear()
effect_state = await adapter.set_state(
devices[0].external_id,
DeviceState(
colors=[RGBColor(red=90, green=45, blue=180)],
brightness=80,
mode_index=2,
),
)
await asyncio.wait_for(effect_received.wait(), timeout=1)
effect_packets = [packet_id for packet_id, _payload in seen[effect_start:]]
assert effect_state.mode == "Static"
assert effect_state.mode_index == 2
assert effect_state.colors == [RGBColor(red=90, green=45, blue=180)]
assert PacketId.UPDATE_MODE in effect_packets
assert PacketId.SET_CUSTOM_MODE not in effect_packets
assert PacketId.UPDATE_LEDS not in effect_packets
direct_start = len(seen)
direct_color = RGBColor(red=24, green=48, blue=96)
direct_state = await adapter.set_state(
devices[0].external_id,
DeviceState(colors=[direct_color]),
)
direct_packets = [packet_id for packet_id, _payload in seen[direct_start:]]
assert direct_state.mode == "Direct"
assert direct_state.colors == [direct_color] * 2
assert PacketId.SET_CUSTOM_MODE in direct_packets
assert PacketId.UPDATE_LEDS in direct_packets
finally:
await adapter.stop()
server.close()
await server.wait_closed()
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
import struct
import pytest
from lumaops_backend.connectors.base import RGBColor
from lumaops_backend.connectors.openrgb.protocol import (
HEADER,
MAGIC,
Mode,
ModeFlag,
PacketId,
ProtocolError,
pack_color,
pack_header,
pack_mode,
pack_string,
pack_update_leds,
parse_controller,
parse_header,
)
def controller_packet() -> bytes:
color = RGBColor(red=16, green=32, blue=48)
mode = (
pack_string("Static")
+ struct.pack("<i", 7)
+ struct.pack("<I", int(ModeFlag.HAS_BRIGHTNESS | ModeFlag.HAS_PER_LED_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 255, 1, 1, 0, 128, 0, 1)
+ struct.pack("<H", 1)
+ pack_color(color)
)
zone = (
pack_string("Main")
+ struct.pack("<iIIIH", 1, 2, 2, 2, 0)
+ struct.pack("<H", 1)
+ pack_string("Segment A")
+ struct.pack("<iII", 1, 0, 2)
+ struct.pack("<I", 0)
)
leds = pack_string("LED 1") + struct.pack("<I", 1) + pack_string("LED 2") + struct.pack("<I", 2)
body = (
struct.pack("<i", 0)
+ pack_string("Test Controller")
+ pack_string("LumaOps")
+ pack_string("Fixture")
+ pack_string("1.2.3")
+ pack_string("SERIAL-1")
+ pack_string("usb:1-2")
+ struct.pack("<H", 1)
+ struct.pack("<i", 0)
+ mode
+ struct.pack("<H", 1)
+ zone
+ struct.pack("<H", 2)
+ leds
+ struct.pack("<H", 2)
+ pack_color(color)
+ pack_color(color)
+ struct.pack("<H", 2)
+ pack_string("A")
+ pack_string("B")
+ struct.pack("<I", 1)
)
return struct.pack("<I", len(body) + 4) + body
def test_header_roundtrip_and_limit() -> None:
raw = pack_header(3, PacketId.UPDATE_LEDS, 42)
assert len(raw) == HEADER.size
parsed = parse_header(raw)
assert parsed.device_index == 3
assert parsed.packet_id == PacketId.UPDATE_LEDS
assert parsed.payload_size == 42
oversized = HEADER.pack(MAGIC, 0, 0, 4097)
with pytest.raises(ProtocolError):
parse_header(oversized, max_packet_size=4096)
def test_parse_protocol_v5_controller() -> None:
controller = parse_controller(controller_packet(), 4)
assert controller.name == "Test Controller"
assert controller.vendor == "LumaOps"
assert controller.firmware_version == "1.2.3"
assert len(controller.leds) == 2
assert controller.led_alt_names == ["A", "B"]
assert controller.zones[0].segments[0].name == "Segment A"
assert controller.capabilities.brightness is True
assert controller.capabilities.per_led is True
def test_parser_rejects_truncation_and_bad_declared_size() -> None:
packet = controller_packet()
with pytest.raises(ProtocolError):
parse_controller(packet[:-1], 0)
corrupted = struct.pack("<I", len(packet) + 100) + packet[4:]
with pytest.raises(ProtocolError):
parse_controller(corrupted, 0)
def test_led_updates_require_exact_count() -> None:
color = RGBColor(red=1, green=2, blue=3)
payload = pack_update_leds([color, color], 2)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
with pytest.raises(ProtocolError):
pack_update_leds([color], 2)
def test_mode_serializer_validates_ranges() -> None:
mode = Mode(
index=0,
name="Pulse",
value=1,
flags=int(ModeFlag.HAS_SPEED | ModeFlag.HAS_BRIGHTNESS),
speed_min=1,
speed_max=10,
brightness_min=0,
brightness_max=255,
colors_min=0,
colors_max=0,
speed=5,
brightness=128,
direction=0,
color_mode=0,
)
payload = pack_mode(mode, 0, brightness_percent=50, speed=7)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
with pytest.raises(ProtocolError):
pack_mode(mode, 0, speed=11)
def test_mode_serializer_carries_effect_colors_and_direction() -> None:
mode = Mode(
index=4,
name="Color Wave",
value=4,
flags=int(
ModeFlag.HAS_SPEED
| ModeFlag.HAS_DIRECTION_LR
| ModeFlag.HAS_BRIGHTNESS
| ModeFlag.HAS_MODE_SPECIFIC_COLOR
),
speed_min=0,
speed_max=2,
brightness_min=0,
brightness_max=255,
colors_min=2,
colors_max=2,
speed=1,
brightness=128,
direction=0,
color_mode=1,
)
colors = [RGBColor(red=12, green=34, blue=56), RGBColor(red=78, green=90, blue=123)]
payload = pack_mode(
mode,
mode.index,
brightness_percent=75,
speed=2,
direction=1,
colors=colors,
)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
assert payload.endswith(pack_color(colors[0]) + pack_color(colors[1]))
with pytest.raises(ProtocolError):
pack_mode(mode, mode.index, colors=colors[:1])