Publish curated LumaOps source from c85ee9ef8f1454f5c305398902afbb9e6738de40
Managed validation / full (push) Failing after 8s

This commit is contained in:
Public Source Publisher committed 2026-09-30 00:26:45 +00:00
1 parent 91f8b3e3c1
commit def7920856
2271 files changed
+501225 -501178

No files matched your search

+6 -6
View File
@@ -1,6 +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.
# 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 -56
View File
@@ -1,56 +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"]
[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"]
@@ -1,3 +1,3 @@
"""LumaOps backend package."""
__version__ = "0.1.0"
"""LumaOps backend package."""
__version__ = "0.1.0"
@@ -1 +1 @@
"""LumaOps API package."""
"""LumaOps API package."""
File diff suppressed because it is too large. Load diff
+100 -100
View File
@@ -1,100 +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),
)
"""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 -106
View File
@@ -1,106 +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
"""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
+117 -117
View File
@@ -1,117 +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()
"""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()
@@ -1 +1 @@
"""Connector implementations and normalized contracts."""
"""Connector implementations and normalized contracts."""
@@ -1,165 +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: ...
"""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: ...
@@ -1,214 +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.",
}
"""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.",
}
@@ -1,6 +1,6 @@
"""OpenRGB SDK protocol 5 connector."""
from .adapter import OpenRGBAdapter
__all__ = ["OpenRGBAdapter"]
"""OpenRGB SDK protocol 5 connector."""
from .adapter import OpenRGBAdapter
__all__ = ["OpenRGBAdapter"]
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,51 +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
"""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
+112 -112
View File
@@ -1,112 +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]
"""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]
+48 -48
View File
@@ -1,48 +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)
"""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)
+35 -35
View File
@@ -1,35 +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)
"""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)
@@ -1,57 +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)
"""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 -260
View File
@@ -1,260 +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()
"""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()
@@ -1,287 +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'));
-- 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'));
@@ -1,4 +1,4 @@
-- Persist optional automation descriptions accepted by the public schema.
-- destructive: false
ALTER TABLE automations ADD COLUMN description TEXT;
-- Persist optional automation descriptions accepted by the public schema.
-- destructive: false
ALTER TABLE automations ADD COLUMN description TEXT;
@@ -1,16 +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
);
-- 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
);
@@ -1,7 +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;
-- 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;
+159 -159
View File
@@ -1,159 +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
"""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
+177 -177
View File
@@ -1,177 +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)
"""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)
+71 -71
View File
@@ -1,71 +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)
"""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)
@@ -1 +1 @@
"""Application service layer."""
"""Application service layer."""
@@ -1,219 +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
"""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
@@ -1,34 +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}
"""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}
@@ -1,445 +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
"""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
@@ -1,84 +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))
"""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))
@@ -1,94 +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
"""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
@@ -1,446 +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"]]
"""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"]]
@@ -1,178 +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)
"""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)
@@ -1,221 +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())
"""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())
@@ -1,129 +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()
]
"""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 -32
View File
@@ -1,32 +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
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 -403
View File
@@ -1,403 +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
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
@@ -1,25 +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()
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()
@@ -1,95 +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
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
+240 -240
View File
@@ -1,240 +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()
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 -169
View File
@@ -1,169 +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])
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])