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