Publish LumaOps source

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

After

Width:  |  Height:  |  Size: 589 B

+113
View File
@@ -0,0 +1,113 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api/client";
import type { SetupState } from "./api/types";
import { AppShell } from "./components/AppShell";
type AuthStatus = { auth_enabled: boolean; authenticated: boolean };
const AboutPage = lazy(() => import("./pages/AboutPage").then((module) => ({ default: module.AboutPage })));
const ActivityPage = lazy(() => import("./pages/ActivityPage").then((module) => ({ default: module.ActivityPage })));
const AutomationsPage = lazy(() => import("./pages/AutomationsPage").then((module) => ({ default: module.AutomationsPage })));
const BackupsPage = lazy(() => import("./pages/BackupsPage").then((module) => ({ default: module.BackupsPage })));
const ConnectorsPage = lazy(() => import("./pages/ConnectorsPage").then((module) => ({ default: module.ConnectorsPage })));
const DeviceDetailPage = lazy(() => import("./pages/DeviceDetailPage").then((module) => ({ default: module.DeviceDetailPage })));
const DeviceGroupPage = lazy(() => import("./pages/DeviceGroupPage").then((module) => ({ default: module.DeviceGroupPage })));
const DevicesPage = lazy(() => import("./pages/DevicesPage").then((module) => ({ default: module.DevicesPage })));
const DiagnosticsPage = lazy(() => import("./pages/DiagnosticsPage").then((module) => ({ default: module.DiagnosticsPage })));
const DiscoveryPage = lazy(() => import("./pages/DiscoveryPage").then((module) => ({ default: module.DiscoveryPage })));
const NetworkPage = lazy(() => import("./pages/NetworkPage").then((module) => ({ default: module.NetworkPage })));
const OverviewPage = lazy(() => import("./pages/OverviewPage").then((module) => ({ default: module.OverviewPage })));
const ScenesPage = lazy(() => import("./pages/ScenesPage").then((module) => ({ default: module.ScenesPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then((module) => ({ default: module.SettingsPage })));
const SetupPage = lazy(() => import("./pages/SetupPage").then((module) => ({ default: module.SetupPage })));
const SpacesPage = lazy(() => import("./pages/SpacesPage").then((module) => ({ default: module.SpacesPage })));
export function App() {
return (
<AuthGate>
<Suspense fallback={<div className="app-loading"><span className="brand__mark" /><p>Pagina laden</p></div>}>
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route element={<SetupGate />}>
<Route index element={<OverviewPage />} />
<Route path="devices" element={<DevicesPage />} />
<Route path="devices/:deviceId" element={<DeviceDetailPage />} />
<Route path="device-groups/:groupId" element={<DeviceGroupPage />} />
<Route path="spaces" element={<SpacesPage />} />
<Route path="scenes" element={<ScenesPage />} />
<Route path="automations" element={<AutomationsPage />} />
<Route path="network" element={<NetworkPage />} />
<Route path="connectors" element={<ConnectorsPage />} />
<Route path="discovery" element={<DiscoveryPage />} />
<Route path="activity" element={<ActivityPage mode="activity" />} />
<Route path="audit" element={<ActivityPage mode="audit" />} />
<Route path="diagnostics" element={<DiagnosticsPage />} />
<Route path="backups" element={<BackupsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="about" element={<AboutPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</AuthGate>
);
}
export function AuthGate({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [token, setToken] = useState("");
const status = useQuery({
queryKey: ["auth-status"],
queryFn: () => api<AuthStatus>("/api/v1/auth/status"),
staleTime: 30_000,
});
const login = useMutation({
mutationFn: () => api<AuthStatus>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify({ token }),
}),
onSuccess: async () => {
setToken("");
await queryClient.invalidateQueries({ queryKey: ["auth-status"] });
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
login.mutate();
}
if (status.isLoading) {
return <div className="app-loading"><span className="brand__mark" /><p>Beveiligde sessie controleren</p></div>;
}
if (status.isError) {
return <main className="login-shell"><section className="login-panel" role="alert"><h1>LumaOps is niet bereikbaar</h1><p>Controleer de server en vernieuw deze pagina.</p></section></main>;
}
if (status.data?.auth_enabled && !status.data.authenticated) {
return (
<main className="login-shell">
<section className="login-panel">
<span className="brand__mark" aria-hidden />
<div><h1>Aanmelden bij LumaOps</h1><p>Voer de beheertoken van deze installatie in.</p></div>
<form onSubmit={submit}>
<label htmlFor="admin-token">Beheertoken</label>
<input id="admin-token" type="password" autoComplete="current-password" value={token} onChange={(event) => setToken(event.target.value)} minLength={32} required autoFocus />
{login.isError ? <p className="login-error" role="alert">Aanmelden mislukt. Controleer de token.</p> : null}
<button className="button button--primary" type="submit" disabled={login.isPending}>{login.isPending ? "Aanmelden…" : "Aanmelden"}</button>
</form>
</section>
</main>
);
}
return children;
}
function SetupGate() {
const location = useLocation();
const setup = useQuery({ queryKey: ["setup"], queryFn: () => api<SetupState>("/api/v1/setup"), staleTime: 30_000 });
if (setup.isLoading) return <div className="app-loading"><span className="brand__mark" /><p>LumaOps voorbereiden</p></div>;
if (setup.data && !setup.data.completed) return <Navigate to="/setup" replace state={{ from: location.pathname }} />;
return <AppShell />;
}
+36
View File
@@ -0,0 +1,36 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, test, vi } from "vitest";
import { AuthGate } from "./App";
import { jsonResponse, renderApp, requestJson, requestUrl } from "./test/render";
afterEach(() => vi.mocked(globalThis.fetch).mockRestore());
test("shows protected content when authentication is disabled", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ auth_enabled: false, authenticated: true }),
);
renderApp(<AuthGate><div>protected content</div></AuthGate>);
expect(await screen.findByText("protected content")).toBeInTheDocument();
});
test("requires the admin token before rendering protected content", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
const url = requestUrl(input);
if (url.endsWith("/auth/login")) {
expect(requestJson(init)).toEqual({ token: "a".repeat(32) });
return Promise.resolve(jsonResponse({ auth_enabled: true, authenticated: true }));
}
return Promise.resolve(jsonResponse({
auth_enabled: true,
authenticated: fetchMock.mock.calls.some(([, request]) => request?.method === "POST"),
}));
});
const user = userEvent.setup();
renderApp(<AuthGate><div>protected content</div></AuthGate>);
await user.type(await screen.findByLabelText("Beheertoken"), "a".repeat(32));
await user.click(screen.getByRole("button", { name: "Aanmelden" }));
await waitFor(() => expect(screen.getByText("protected content")).toBeInTheDocument());
});
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mutationId } from "./client";
const originalCrypto = globalThis.crypto;
afterEach(() => {
Object.defineProperty(globalThis, "crypto", { configurable: true, value: originalCrypto });
});
describe("mutationId", () => {
it("uses randomUUID when the browser exposes it", () => {
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: { randomUUID: () => "11111111-2222-4333-8444-555555555555" },
});
expect(mutationId()).toBe("11111111-2222-4333-8444-555555555555");
});
it("creates a UUID v4 when randomUUID is unavailable on an HTTP LAN origin", () => {
const getRandomValues = vi.fn((bytes: Uint8Array) => {
bytes.fill(0);
return bytes;
});
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: { getRandomValues },
});
expect(mutationId()).toBe("00000000-0000-4000-8000-000000000000");
expect(getRandomValues).toHaveBeenCalledOnce();
});
it("keeps the UUID format when Web Crypto is entirely unavailable", () => {
Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined });
vi.spyOn(Math, "random").mockReturnValue(0);
expect(mutationId()).toBe("00000000-0000-4000-8000-000000000000");
});
});
+79
View File
@@ -0,0 +1,79 @@
import type { ErrorEnvelope } from "./types";
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly requestId?: string;
readonly recovery: string[];
constructor(status: number, payload: ErrorEnvelope) {
super(payload.error.message);
this.name = "ApiError";
this.status = status;
this.code = payload.error.code;
this.requestId = payload.error.request_id;
this.recovery = payload.error.recovery;
}
}
function csrfToken(): string | undefined {
return document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith("lumaops_csrf="))
?.split("=")[1];
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (init.body && !(init.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
const csrf = csrfToken();
if (csrf && !["GET", "HEAD", "OPTIONS"].includes(init.method ?? "GET")) {
headers.set("X-CSRF-Token", decodeURIComponent(csrf));
}
const response = await fetch(path, { ...init, headers, credentials: "same-origin" });
if (!response.ok) {
const fallback: ErrorEnvelope = {
error: {
code: "http_error",
message: `HTTP ${response.status}`,
request_id: response.headers.get("X-Request-ID") ?? "",
details: {},
recovery: [],
},
};
let payload = fallback;
try {
payload = (await response.json()) as ErrorEnvelope;
} catch {
// The stable fallback keeps non-JSON proxy errors usable.
}
throw new ApiError(response.status, payload);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
export function mutationId(): string {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === "function") {
return cryptoApi.randomUUID();
}
const bytes = new Uint8Array(16);
if (typeof cryptoApi?.getRandomValues === "function") {
cryptoApi.getRandomValues(bytes);
} else {
// Idempotency keys are identifiers rather than secrets. This final fallback
// keeps device control usable in older or restricted LAN browsers.
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
export function useRealtimeUpdates(): void {
const queryClient = useQueryClient();
useEffect(() => {
const events = new EventSource("/api/v1/events");
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["system"] });
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["activity"] });
};
events.addEventListener("inventory.updated", refresh);
events.addEventListener("command.completed", refresh);
events.onerror = () => {
// EventSource reconnects automatically. REST remains fully functional.
};
return () => events.close();
}, [queryClient]);
}
+261
View File
@@ -0,0 +1,261 @@
export type HealthStatus = "healthy" | "degraded" | "unhealthy" | "unknown";
export interface ErrorEnvelope {
error: {
code: string;
message: string;
request_id: string;
details: Record<string, unknown>;
recovery: string[];
};
}
export interface Page<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface RGBColor {
red: number;
green: number;
blue: number;
}
export interface DeviceState {
power?: boolean | null;
brightness?: number | null;
colors?: RGBColor[] | null;
mode?: string | null;
mode_index?: number | null;
speed?: number | null;
direction?: number | null;
zone_index?: number | null;
led_index?: number | null;
}
export interface Capabilities {
power: boolean;
restore: boolean;
rgb: boolean;
brightness: boolean;
color_temperature: boolean;
effect: boolean;
speed: boolean;
direction: boolean;
multiple_colors: boolean;
per_zone: boolean;
per_segment: boolean;
per_led: boolean;
profiles: boolean;
readable_state: boolean;
max_leds: number;
min_brightness: number;
max_brightness: number;
min_speed: number | null;
max_speed: number | null;
}
export interface DeviceZone {
index: number;
name: string;
type: number;
led_count: number;
leds_min: number;
leds_max: number;
start_index?: number;
resizable_effects_only?: boolean;
segments?: Array<Record<string, unknown>>;
}
export interface DeviceMode {
index: number;
name: string;
flags: number;
speed_min: number | null;
speed_max: number | null;
brightness: boolean;
colors_min: number;
colors_max: number;
}
export interface Device {
id: string;
connector_id: string;
external_id: string;
fingerprint: string;
source: string;
device_type: string;
owner: string;
name: string;
alias: string | null;
vendor: string | null;
model: string | null;
serial: string | null;
location: string | null;
ip_address: string | null;
firmware_version: string | null;
controller_index: number | null;
capabilities: Capabilities;
state: DeviceState;
zones: DeviceZone[];
modes: DeviceMode[];
metadata: Record<string, unknown>;
led_count: number;
online: boolean;
hidden: boolean;
favorite: boolean;
exclude_global: boolean;
read_only: boolean;
blocked: boolean;
experimental: boolean;
room_id: string | null;
room_name?: string | null;
tags: string[];
error_status: string | null;
last_detected_at: string | null;
last_command_at: string | null;
}
export interface DeviceGroup {
id: string;
kind: "device-family";
device_type: string;
connector_id: string;
name: string;
vendor: string | null;
model: string | null;
online: boolean;
online_count: number;
module_count: number;
led_count: number;
capabilities: Capabilities;
state: DeviceState;
modes: DeviceMode[];
mixed: boolean;
devices: Device[];
}
export interface ComponentHealth {
status: HealthStatus;
message: string;
connected?: boolean;
details?: Record<string, unknown>;
}
export interface SystemStatus {
name: string;
version: string;
openrgb_version: string;
sdk_protocol: number;
environment: string;
mock_mode: boolean;
health: {
status: HealthStatus;
components: Record<string, ComponentHealth>;
checked_at: string;
};
devices: { total: number; online: number; offline: number };
active_scene: Scene | null;
recent_commands: Command[];
warnings: Activity[];
emergency_stop: boolean;
}
export interface Room {
id: string;
name: string;
description: string | null;
sort_order: number;
device_count: number;
}
export interface Group {
id: string;
name: string;
description: string | null;
dynamic_query: { tags?: string[]; match?: "all" | "any" } | null;
sort_order: number;
device_count: number;
devices?: Device[];
}
export interface SceneItem {
id: string;
target_type: "device" | "group";
target_id: string;
state: DeviceState;
required: boolean;
sort_order: number;
}
export interface Scene {
id: string;
name: string;
description: string | null;
favorite: boolean;
version: number;
item_count?: number;
items?: SceneItem[];
last_applied_at: string | null;
}
export interface Automation {
id: string;
name: string;
description: string | null;
enabled: boolean;
trigger: Record<string, unknown>;
actions: Array<Record<string, unknown>>;
timezone: string;
cooldown_seconds: number;
conflict_key: string | null;
last_run_at: string | null;
next_run_at: string | null;
last_error: string | null;
}
export interface AutomationRun {
id: string;
automation_id: string;
status: string;
trigger: Record<string, unknown>;
result: unknown;
error: string | null;
started_at: string;
finished_at: string | null;
}
export interface SceneApplyResult {
scene_id: string;
status: "succeeded" | "failed";
applied: Array<{ device_id: string; status: string }>;
failed: Array<{ device_id: string; error: string }>;
skipped: Array<{ device_id: string; reason: string }>;
rolled_back: Array<{ device_id: string; status: string }>;
}
export interface Command {
id: string;
target_id: string;
status: string;
action: string;
created_at: string;
finished_at: string | null;
}
export interface Activity {
id: string;
category: string;
severity: string;
title: string;
message: string;
created_at: string;
}
export interface SetupState {
completed: boolean;
current_step: string;
report: Record<string, unknown>;
}
@@ -0,0 +1,116 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
ArchiveRestore,
Blocks,
Cable,
ChevronLeft,
CircleGauge,
ClipboardList,
Compass,
Cpu,
FlaskConical,
Info,
Lightbulb,
Menu,
Moon,
Network,
Orbit,
Settings,
Sun,
WandSparkles,
X,
} from "lucide-react";
import { useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { api } from "../api/client";
import { useRealtimeUpdates } from "../api/hooks";
import type { SystemStatus } from "../api/types";
import { useI18n } from "../lib/i18n";
import { useTheme } from "../lib/theme";
import { cn } from "../lib/utils";
import { Badge, IconButton, StatusBadge } from "./ui";
const navigation = [
{ to: "/", key: "overview", icon: CircleGauge },
{ to: "/devices", key: "devices", icon: Cpu },
{ to: "/spaces", key: "spaces", icon: Blocks },
{ to: "/scenes", key: "scenes", icon: WandSparkles },
{ to: "/automations", key: "automations", icon: Orbit },
{ to: "/network", key: "network", icon: Network },
{ to: "/connectors", key: "connectors", icon: Cable },
{ to: "/discovery", key: "discovery", icon: Compass },
] as const;
const systemNavigation = [
{ to: "/activity", key: "activity", icon: Activity },
{ to: "/audit", key: "audit", icon: ClipboardList },
{ to: "/diagnostics", key: "diagnostics", icon: FlaskConical },
{ to: "/backups", key: "backups", icon: ArchiveRestore },
{ to: "/settings", key: "settings", icon: Settings },
{ to: "/about", key: "about", icon: Info },
] as const;
export function AppShell() {
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const { t } = useI18n();
const { resolved, setTheme } = useTheme();
const system = useQuery({
queryKey: ["system"],
queryFn: () => api<SystemStatus>("/api/v1/system"),
refetchInterval: 30_000,
});
useRealtimeUpdates();
const nav = (items: typeof navigation | typeof systemNavigation) => items.map(({ to, key, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) => cn("nav-link", isActive && "nav-link--active")}
onClick={() => setOpen(false)}
title={collapsed ? t(key) : undefined}
>
<Icon size={18} aria-hidden />
<span>{t(key)}</span>
</NavLink>
));
return (
<div className={cn("app-shell", collapsed && "app-shell--collapsed")}>
<aside className={cn("sidebar", open && "sidebar--open")}>
<div className="brand">
<span className="brand__mark"><Lightbulb size={20} aria-hidden /></span>
<div className="brand__text"><strong>LumaOps</strong><span>OpenRGB control plane</span></div>
<IconButton className="sidebar__close" label={t("closeMenu")} onClick={() => setOpen(false)}><X size={18} /></IconButton>
</div>
<nav aria-label="Hoofdnavigatie" className="sidebar__nav">
<span className="nav-label">Beheer</span>
{nav(navigation)}
<span className="nav-label">Systeem</span>
{nav(systemNavigation)}
</nav>
<button className="sidebar__collapse" onClick={() => setCollapsed((value) => !value)}>
<ChevronLeft size={16} aria-hidden /><span>Navigatie inklappen</span>
</button>
</aside>
{open ? <button className="sidebar-backdrop" aria-label={t("closeMenu")} onClick={() => setOpen(false)} /> : null}
<div className="app-main">
<header className="topbar">
<IconButton className="mobile-menu" label={t("openMenu")} onClick={() => setOpen(true)}><Menu size={20} /></IconButton>
<div className="topbar__status">
{system.data ? <StatusBadge status={system.data.health.status} /> : <Badge>Laden</Badge>}
<span className="topbar__device-count">{system.data?.devices.online ?? 0} apparaten online</span>
</div>
<IconButton label={resolved === "dark" ? "Licht thema" : "Donker thema"} onClick={() => setTheme(resolved === "dark" ? "light" : "dark")}>
{resolved === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</IconButton>
</header>
{system.data?.mock_mode ? <div className="mock-banner"><FlaskConical size={16} /> {t("mockWarning")}</div> : null}
<main className="page"><Outlet /></main>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { Save, X } from "lucide-react";
import { useState, type FormEvent } from "react";
import type { Automation, Page, Scene } from "../api/types";
import { useI18n } from "../lib/i18n";
import { formValue } from "../lib/utils";
import { Button, Card, CardHeader, Field, Input, Select } from "./ui";
export interface AutomationPayload {
name: string;
description: string | null;
enabled: boolean;
trigger: { type: "time"; at: string; weekdays: number[] };
actions: Array<{ type: "scene"; scene_id: string }>;
timezone: string;
cooldown_seconds: number;
conflict_key: string | null;
}
interface AutomationEditorProps {
automation?: Automation;
scenes?: Page<Scene>;
busy: boolean;
onSubmit: (payload: AutomationPayload) => void;
onClose: () => void;
}
const weekdays = [
[0, "Ma", "Mon"],
[1, "Di", "Tue"],
[2, "Wo", "Wed"],
[3, "Do", "Thu"],
[4, "Vr", "Fri"],
[5, "Za", "Sat"],
[6, "Zo", "Sun"],
] as const;
export function AutomationEditor({ automation, scenes, busy, onSubmit, onClose }: AutomationEditorProps) {
const { text } = useI18n();
const selectedWeekdays = Array.isArray(automation?.trigger.weekdays) ? automation.trigger.weekdays.map(Number) : [0, 1, 2, 3, 4, 5, 6];
const [selectedDays, setSelectedDays] = useState(selectedWeekdays);
const sceneId = typeof automation?.actions[0]?.scene_id === "string" ? automation.actions[0].scene_id : "";
const at = typeof automation?.trigger.at === "string" ? automation.trigger.at : "20:00";
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
onSubmit({
name: formValue(data, "name").trim(),
description: formValue(data, "description").trim() || null,
enabled: data.get("enabled") === "on",
trigger: { type: "time", at: formValue(data, "at"), weekdays: selectedDays },
actions: [{ type: "scene", scene_id: formValue(data, "scene") }],
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
cooldown_seconds: Number(data.get("cooldown")),
conflict_key: formValue(data, "conflict_key").trim() || null,
});
};
return (
<Card className="resource-editor">
<CardHeader title={automation ? text("Automation bewerken", "Edit automation") : text("Automation aanmaken", "Create automation")} description={text("Plan een scène op geselecteerde weekdagen en voorkom conflicterende uitvoeringen.", "Schedule a scene on selected weekdays and prevent conflicting runs.")} action={<Button type="button" variant="ghost" onClick={onClose}><X size={16} /> {text("Sluiten", "Close")}</Button>} />
<form onSubmit={submit} className="form-stack">
<div className="form-grid form-grid--two"><Field label={text("Naam", "Name")}><Input name="name" required autoFocus defaultValue={automation?.name ?? ""} placeholder={text("Avondverlichting", "Evening lights")} /></Field><Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={automation?.description ?? ""} /></Field></div>
<div className="form-grid form-grid--three"><Field label={text("Tijdstip", "Time")}><Input name="at" type="time" required defaultValue={at} /></Field><Field label={text("Scène", "Scene")}><Select name="scene" required defaultValue={sceneId}><option value="">{text("Selecteer een scène", "Select a scene")}</option>{scenes?.items.map((scene) => <option key={scene.id} value={scene.id}>{scene.name}</option>)}</Select></Field><Field label={text("Cooldown (seconden)", "Cooldown (seconds)")}><Input name="cooldown" type="number" min="0" max="604800" defaultValue={automation?.cooldown_seconds ?? 60} /></Field></div>
<fieldset className="weekday-grid"><legend>{text("Weekdagen", "Weekdays")}</legend>{weekdays.map(([value, dutch, english]) => <label key={value}><input type="checkbox" name="weekdays" value={value} checked={selectedDays.includes(value)} onChange={(event) => setSelectedDays((current) => event.target.checked ? [...current, value].sort() : current.filter((day) => day !== value))} /> <span>{text(dutch, english)}</span></label>)}</fieldset>
{!selectedDays.length ? <p className="field-error" role="alert">{text("Selecteer minstens één weekdag.", "Select at least one weekday.")}</p> : null}
<Field label={text("Conflictgroep (optioneel)", "Conflict group (optional)")} hint={text("Regels met dezelfde sleutel worden nooit gelijktijdig uitgevoerd.", "Rules with the same key never run concurrently.")}><Input name="conflict_key" defaultValue={automation?.conflict_key ?? ""} placeholder="woonkamer" /></Field>
<label className="checkbox-row"><input name="enabled" type="checkbox" defaultChecked={automation?.enabled ?? true} /> {text("Automation is actief", "Automation is enabled")}</label>
<div className="form-actions"><Button variant="ghost" type="button" onClick={onClose}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={busy} disabled={!selectedDays.length}><Save size={16} /> {automation ? text("Wijzigingen opslaan", "Save changes") : text("Aanmaken", "Create")}</Button></div>
</form>
</Card>
);
}
@@ -0,0 +1,44 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
import { Button } from "./ui";
export function ConfirmDialog({
open,
title,
description,
confirmLabel,
danger = false,
busy = false,
confirmDisabled = false,
onConfirm,
onClose,
children,
}: {
open: boolean;
title: string;
description: string;
confirmLabel: string;
danger?: boolean;
busy?: boolean;
confirmDisabled?: boolean;
onConfirm: () => void;
onClose: () => void;
children?: ReactNode;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
if (open && !dialog.current?.open) dialog.current?.showModal();
if (!open && dialog.current?.open) dialog.current.close();
}, [open]);
return (
<dialog ref={dialog} className="dialog" aria-labelledby={titleId} onCancel={onClose} onClose={onClose}>
<h2 id={titleId}>{title}</h2>
<p>{description}</p>
{children}
<div className="dialog__actions">
<Button variant="ghost" onClick={onClose}>Annuleren</Button>
<Button variant={danger ? "danger" : "primary"} onClick={onConfirm} busy={busy} disabled={confirmDisabled}>{confirmLabel}</Button>
</div>
</dialog>
);
}
@@ -0,0 +1,27 @@
import { Cpu, MemoryStick, Star, WifiOff } from "lucide-react";
import type { CSSProperties } from "react";
import { Link } from "react-router-dom";
import type { Device } from "../api/types";
import { colorToHex, formatDate } from "../lib/utils";
import { Badge } from "./ui";
interface DeviceCardProps {
device: Device;
list?: boolean;
moduleLabel?: string;
}
export function DeviceCard({ device, list = false, moduleLabel }: DeviceCardProps) {
const color = colorToHex(device.state.colors?.[0]);
const Icon = device.device_type === "dram" ? MemoryStick : Cpu;
return <Link to={`/devices/${device.id}`} className={list ? "device-row" : "device-card"}>
<div className="device-card__visual" style={{ "--device-color": color } as CSSProperties}>
<span className="device-glow" /><Icon size={list ? 22 : 30} /><span className={`presence ${device.online ? "presence--online" : ""}`} />
</div>
<div className="device-card__content">
<div className="device-card__title"><div><h2>{moduleLabel || device.alias || device.name}</h2><p>{[device.vendor, device.model].filter(Boolean).join(" · ") || device.source}</p></div>{device.favorite ? <Star size={16} className="favorite" fill="currentColor" /> : null}</div>
<div className="device-card__meta"><Badge tone={device.online ? "success" : "neutral"}>{device.online ? "Online" : <><WifiOff size={12} /> Offline</>}</Badge>{device.room_name ? <Badge>{device.room_name}</Badge> : null}{device.read_only ? <Badge tone="warning">Alleen lezen</Badge> : null}{device.blocked ? <Badge tone="danger">Geblokkeerd</Badge> : null}</div>
{!list ? <div className="device-card__footer"><span><i style={{ background: color }} />{device.state.mode ?? "Onbekende modus"}</span><span>{device.led_count} leds</span></div> : <div className="device-row__extra"><span>{device.state.brightness ?? "—"}%</span><span>{formatDate(device.last_detected_at)}</span></div>}
</div>
</Link>;
}
@@ -0,0 +1,25 @@
import { MemoryStick } from "lucide-react";
import type { CSSProperties } from "react";
import { Link } from "react-router-dom";
import type { DeviceGroup } from "../api/types";
import { colorToHex } from "../lib/utils";
import { Badge } from "./ui";
interface DeviceGroupCardProps {
group: DeviceGroup;
list?: boolean;
}
export function DeviceGroupCard({ group, list = false }: DeviceGroupCardProps) {
const color = colorToHex(group.state.colors?.[0]);
return <Link to={`/device-groups/${group.id}`} className={`${list ? "device-row" : "device-card"} device-card--group`}>
<div className="device-card__visual" style={{ "--device-color": color } as CSSProperties}>
<span className="device-glow" /><MemoryStick size={list ? 22 : 30} /><span className={`presence ${group.online_count ? "presence--online" : ""}`} />
</div>
<div className="device-card__content">
<div className="device-card__title"><div><h2>{group.name}</h2><p>{group.vendor} · gegroepeerd RAM-apparaat</p></div><Badge tone="accent">Groep</Badge></div>
<div className="device-card__meta"><Badge tone={group.online ? "success" : "warning"}>{group.online_count}/{group.module_count} online</Badge>{group.mixed ? <Badge tone="warning">Gemengde toestand</Badge> : null}</div>
<div className={list ? "device-row__extra" : "device-card__footer"}><span>{group.state.mode ?? "Gemengd"}</span><span>{group.module_count} modules · {group.led_count} leds</span></div>
</div>
</Link>;
}
@@ -0,0 +1,165 @@
import { Power } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { Capabilities, DeviceMode, DeviceState } from "../api/types";
import { hexToColor, colorToHex } from "../lib/utils";
import { Button, Card, CardHeader, Field, Input, Select } from "./ui";
const DIRECTION_FLAGS = 2 | 4 | 8;
interface RgbControlPanelProps {
targetKey: string;
capabilities: Capabilities;
state: DeviceState;
modes: DeviceMode[];
pending: boolean;
disabled: boolean;
onApply: (state: DeviceState) => void;
}
export function RgbControlPanel({
targetKey,
capabilities,
state,
modes,
pending,
disabled,
onApply,
}: RgbControlPanelProps) {
const defaultColorMode = findColorMode(modes);
const [modeIndex, setModeIndex] = useState<number | undefined>(
state.mode_index ?? defaultColorMode?.index,
);
const [colors, setColors] = useState<string[]>(() => stateColors(state, modes));
const [brightness, setBrightness] = useState(state.brightness ?? 100);
const [speed, setSpeed] = useState(state.speed ?? 0);
const [direction, setDirection] = useState(state.direction ?? 0);
const synchronizedTarget = useRef("");
const dirty = useRef(false);
useEffect(() => {
const changedTarget = synchronizedTarget.current !== targetKey;
if (!changedTarget && dirty.current) return;
synchronizedTarget.current = targetKey;
dirty.current = false;
const nextModeIndex = state.mode_index ?? findColorMode(modes)?.index;
setModeIndex(nextModeIndex);
setColors(stateColors({ ...state, mode_index: nextModeIndex }, modes));
setBrightness(state.brightness ?? 100);
setSpeed(state.speed ?? modes.find((mode) => mode.index === nextModeIndex)?.speed_min ?? 0);
setDirection(state.direction ?? 0);
}, [modes, state, targetKey]);
const selectedMode = modes.find((mode) => mode.index === modeIndex);
const colorSlots = modeColorSlots(selectedMode, capabilities.rgb);
const applyColorSlots = colorSlots;
const supportsDirection = Boolean((selectedMode?.flags ?? 0) & DIRECTION_FLAGS);
const supportsSpeed = selectedMode?.speed_min != null && selectedMode.speed_max != null;
const selectMode = (nextIndex: number) => {
dirty.current = true;
const nextMode = modes.find((mode) => mode.index === nextIndex);
setModeIndex(nextIndex);
setColors(resizeColors(colors, modeColorSlots(nextMode, capabilities.rgb)));
setSpeed(clamp(state.speed ?? nextMode?.speed_min ?? 0, nextMode?.speed_min, nextMode?.speed_max));
setBrightness(state.brightness ?? 100);
setDirection(state.direction ?? 0);
};
const updateColor = (index: number, value: string) => {
dirty.current = true;
const next = resizeColors(colors, Math.max(colorSlots, index + 1));
next[index] = value;
setColors(next);
if (selectedMode?.name.toLocaleLowerCase() === "direct" && defaultColorMode) {
setModeIndex(defaultColorMode.index);
}
};
const apply = () => {
const command: DeviceState = { power: true };
if (modeIndex != null) command.mode_index = modeIndex;
if (applyColorSlots) command.colors = colors.slice(0, applyColorSlots).map(hexToColor);
if (selectedMode?.brightness) command.brightness = brightness;
if (supportsSpeed) command.speed = speed;
if (supportsDirection) command.direction = direction;
dirty.current = false;
onApply(command);
};
return <Card className="control-panel">
<CardHeader title="Bediening" description="Elke modus gebruikt uitsluitend de parameters die de hardware ondersteunt." />
<div className="power-actions">
<Button onClick={() => onApply({ power: true })} disabled={!capabilities.power || disabled}>
<Power size={16} /> Aan
</Button>
<Button variant="secondary" onClick={() => onApply({ power: false })} disabled={!capabilities.power || disabled}>
Uit
</Button>
</div>
{capabilities.effect && modes.length ? <Field label="Effectmodus">
<Select aria-label="Effectmodus" value={modeIndex ?? ""} onChange={(event) => selectMode(Number(event.target.value))}>
{modes.map((mode) => <option key={mode.index} value={mode.index}>{mode.name}</option>)}
</Select>
</Field> : null}
{colorSlots ? <Field label={colorSlots > 1 ? "Effectkleuren" : "Statische kleur"}>
<div className="effect-colors">
{resizeColors(colors, colorSlots).map((color, index) => <div className="effect-color" key={`${targetKey}-color-${index}`}>
{colorSlots > 1 ? <span>Kleur {index + 1}</span> : null}
<div className="large-color">
<input aria-label={`Kleur ${index + 1} kiezen`} type="color" value={validColor(color)} onChange={(event) => updateColor(index, event.target.value)} />
<Input aria-label={`Kleur ${index + 1}`} value={color.toUpperCase()} onChange={(event) => updateColor(index, event.target.value)} />
</div>
</div>)}
</div>
</Field> : <p className="mode-hint">Deze hardwaremodus genereert zijn kleuren automatisch.</p>}
{selectedMode?.brightness ? <Field label={`Helderheid · ${brightness}%`}>
<input aria-label="Helderheid" className="range" type="range" min="0" max="100" value={brightness} onChange={(event) => { dirty.current = true; setBrightness(Number(event.target.value)); }} />
</Field> : null}
{supportsSpeed ? <Field label={`Snelheid · ${speed}`}>
<input aria-label="Snelheid" className="range" type="range" min={selectedMode.speed_min ?? 0} max={selectedMode.speed_max ?? 0} value={speed} onChange={(event) => { dirty.current = true; setSpeed(Number(event.target.value)); }} />
</Field> : null}
{supportsDirection ? <Field label="Richting">
<Select aria-label="Richting" value={direction} onChange={(event) => { dirty.current = true; setDirection(Number(event.target.value)); }}>
<option value={0}>Vooruit</option>
<option value={1}>Achteruit</option>
</Select>
</Field> : null}
<Button className="full-width" onClick={apply} busy={pending} disabled={disabled}>
Instellingen toepassen
</Button>
</Card>;
}
function findColorMode(modes: DeviceMode[]) {
return modes.find((mode) => mode.name.toLocaleLowerCase() === "custom")
?? modes.find((mode) => mode.name.toLocaleLowerCase() === "static")
?? modes.find((mode) => mode.name.toLocaleLowerCase() === "direct");
}
function modeColorSlots(mode: DeviceMode | undefined, rgb: boolean) {
if (!rgb) return 0;
if (!mode) return 1;
if (mode.colors_min > 0) return mode.colors_min;
return ["direct", "custom"].includes(mode.name.toLocaleLowerCase()) ? 1 : 0;
}
function stateColors(state: DeviceState, modes: DeviceMode[]) {
const mode = modes.find((candidate) => candidate.index === state.mode_index);
const count = modeColorSlots(mode, true);
const values = (state.colors ?? []).map(colorToHex);
return resizeColors(values.length ? values : ["#5660ff"], count || 1);
}
function resizeColors(colors: string[], count: number) {
if (!count) return [];
const seed = colors[0] ?? "#5660ff";
return Array.from({ length: count }, (_, index) => colors[index] ?? seed);
}
function validColor(value: string) {
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000";
}
function clamp(value: number, minimum: number | null | undefined, maximum: number | null | undefined) {
return Math.max(minimum ?? value, Math.min(maximum ?? value, value));
}
@@ -0,0 +1,116 @@
import { Fan } from "lucide-react";
import { useEffect, useState } from "react";
import type { DeviceState, DeviceZone, RGBColor } from "../api/types";
import { colorToHex, hexToColor } from "../lib/utils";
import { Badge, Button, Card, Field, Input, Select } from "./ui";
type ZonePattern = "static" | "rainbow" | "alternating" | "off";
interface ZoneDraft {
pattern: ZonePattern;
primary: string;
secondary: string;
}
interface RgbZoneControlsProps {
targetKey: string;
zones: DeviceZone[];
state: DeviceState;
pendingZone?: number;
disabled: boolean;
onApply: (zoneIndex: number, state: DeviceState) => void;
}
export function RgbZoneControls({
targetKey,
zones,
state,
pendingZone,
disabled,
onApply,
}: RgbZoneControlsProps) {
const [drafts, setDrafts] = useState<Record<number, ZoneDraft>>({});
useEffect(() => {
setDrafts(Object.fromEntries(zones.map((zone) => [zone.index, {
pattern: "static",
primary: zoneColor(state, zone),
secondary: "#00c2a8",
}])));
}, [state, targetKey, zones]);
const update = (zoneIndex: number, patch: Partial<ZoneDraft>) => {
setDrafts((current) => ({
...current,
[zoneIndex]: {
...(current[zoneIndex] ?? { pattern: "static", primary: "#5660ff", secondary: "#00c2a8" }),
...patch,
},
}));
};
return <div className="argb-zone-list">
{zones.map((zone) => {
const draft = drafts[zone.index] ?? { pattern: "static", primary: "#5660ff", secondary: "#00c2a8" };
const automaticLedCount = zone.leds_min === zone.leds_max ? zone.led_count : zone.leds_max;
const colors = patternColors(draft, automaticLedCount);
return <Card className="argb-zone-card" data-testid={`argb-zone-${zone.index}`} key={zone.index}>
<div className="argb-zone-card__heading">
<span><Fan size={19} /></span>
<div><h3>{zone.name}</h3><p>Individuele Aura-zone</p></div>
<Badge tone="success">Alle {automaticLedCount} leds</Badge>
</div>
<Field label={`Modus voor ${zone.name}`}>
<Select aria-label={`Modus voor ${zone.name}`} value={draft.pattern} onChange={(event) => update(zone.index, { pattern: event.target.value as ZonePattern })}>
<option value="static">Vaste kleur</option>
<option value="rainbow">Regenboogpatroon</option>
<option value="alternating">Afwisselende kleuren</option>
<option value="off">Uit</option>
</Select>
</Field>
{draft.pattern !== "off" && draft.pattern !== "rainbow" ? <Field label="Hoofdkleur">
<div className="large-color">
<input aria-label={`Hoofdkleur voor ${zone.name} kiezen`} type="color" value={validColor(draft.primary)} onChange={(event) => update(zone.index, { primary: event.target.value })} />
<Input aria-label={`Hoofdkleur voor ${zone.name}`} value={draft.primary.toUpperCase()} onChange={(event) => update(zone.index, { primary: event.target.value })} />
</div>
</Field> : null}
{draft.pattern === "alternating" ? <Field label="Tweede kleur">
<div className="large-color">
<input aria-label={`Tweede kleur voor ${zone.name} kiezen`} type="color" value={validColor(draft.secondary)} onChange={(event) => update(zone.index, { secondary: event.target.value })} />
<Input aria-label={`Tweede kleur voor ${zone.name}`} value={draft.secondary.toUpperCase()} onChange={(event) => update(zone.index, { secondary: event.target.value })} />
</div>
</Field> : null}
<p className="mode-hint">De volledige header wordt automatisch gebruikt; een LED-aantal ingeven is niet nodig.</p>
<Button className="full-width" busy={pendingZone === zone.index} disabled={disabled || pendingZone != null} onClick={() => onApply(zone.index, { power: true, colors })}>
Alleen {zone.name} toepassen
</Button>
</Card>;
})}
</div>;
}
function zoneColor(state: DeviceState, zone: DeviceZone) {
return colorToHex(state.colors?.[zone.start_index ?? 0] ?? state.colors?.[0]);
}
function patternColors(draft: ZoneDraft, count: number): RGBColor[] {
if (draft.pattern === "off") return [{ red: 0, green: 0, blue: 0 }];
if (draft.pattern === "static") return [hexToColor(validColor(draft.primary))];
if (draft.pattern === "alternating") {
const colors = [hexToColor(validColor(draft.primary)), hexToColor(validColor(draft.secondary))];
return Array.from({ length: count }, (_, index) => colors[index % colors.length]!);
}
return Array.from({ length: count }, (_, index) => hslToRgb(index / Math.max(1, count)));
}
function hslToRgb(hue: number): RGBColor {
const channel = (offset: number) => {
const value = (offset + hue * 6) % 6;
return Math.round(255 * (1 - Math.max(0, Math.min(value, 4 - value, 1))));
};
return { red: channel(5), green: channel(3), blue: channel(1) };
}
function validColor(value: string) {
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000";
}
@@ -0,0 +1,139 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Save, Trash2, X } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import { api } from "../api/client";
import type { Device, DeviceState, Group, Page, Scene, SceneItem } from "../api/types";
import { useI18n } from "../lib/i18n";
import { formValue, hexToColor } from "../lib/utils";
import { useToast } from "./Toast";
import { Button, Card, CardHeader, ErrorPanel, Field, Input, LoadingGrid, Select } from "./ui";
interface SceneEditorProps {
sceneId: string;
onClose: () => void;
}
type EditableItem = Omit<SceneItem, "id"> & { id?: string };
export function SceneEditor({ sceneId, onClose }: SceneEditorProps) {
const { text } = useI18n();
const { notify } = useToast();
const queryClient = useQueryClient();
const scene = useQuery({ queryKey: ["scene", sceneId], queryFn: () => api<Scene>(`/api/v1/scenes/${sceneId}`) });
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const groups = useQuery({ queryKey: ["groups"], queryFn: () => api<Group[]>("/api/v1/groups") });
const [items, setItems] = useState<EditableItem[]>([]);
useEffect(() => {
if (scene.data?.items) setItems(scene.data.items);
}, [scene.data]);
const save = useMutation({
mutationFn: (payload: Record<string, unknown>) => api<Scene>(`/api/v1/scenes/${sceneId}`, { method: "PUT", body: JSON.stringify(payload) }),
onSuccess: () => {
notify(text("Scène opgeslagen.", "Scene saved."));
void queryClient.invalidateQueries({ queryKey: ["scenes"] });
void queryClient.invalidateQueries({ queryKey: ["scene", sceneId] });
onClose();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
});
if (scene.isLoading || devices.isLoading || groups.isLoading) return <LoadingGrid count={2} />;
if (scene.error || devices.error || groups.error || !scene.data) {
return <ErrorPanel error={scene.error ?? devices.error ?? groups.error} retry={() => void Promise.all([scene.refetch(), devices.refetch(), groups.refetch()])} />;
}
const targetName = (item: EditableItem) => {
if (item.target_type === "device") {
const device = devices.data?.items.find((candidate) => candidate.id === item.target_id);
return device?.alias || device?.name || item.target_id;
}
return groups.data?.find((candidate) => candidate.id === item.target_id)?.name || item.target_id;
};
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
save.mutate({
name: data.get("name"),
description: data.get("description") || null,
favorite: data.get("favorite") === "on",
items: items.map((item, index) => ({ ...item, sort_order: index })),
});
};
const addItem = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const target = formValue(data, "target");
if (!target.includes(":")) return;
const [targetType, targetId] = target.split(":", 2) as ["device" | "group", string];
const state: DeviceState = {};
const power = data.get("power");
if (power === "unchanged" && data.get("include_color") !== "on" && data.get("include_brightness") !== "on") {
notify(text("Selecteer minstens één eigenschap voor dit doel.", "Select at least one property for this target."), "danger");
return;
}
if (power === "on") state.power = true;
if (power === "off") state.power = false;
if (data.get("include_color") === "on") state.colors = [hexToColor(formValue(data, "color"))];
if (data.get("include_brightness") === "on") state.brightness = Number(data.get("brightness"));
setItems((current) => [
...current.filter((item) => !(item.target_type === targetType && item.target_id === targetId)),
{ target_type: targetType, target_id: targetId, state, required: true, sort_order: current.length },
]);
event.currentTarget.reset();
};
return (
<Card className="resource-editor">
<CardHeader
title={text("Scène bewerken", "Edit scene")}
description={text("Stel doelen en alleen de gewenste eigenschappen in. Niet aangevinkte eigenschappen blijven ongemoeid.", "Configure targets and only the desired properties. Unchecked properties remain unchanged.")}
action={<Button type="button" variant="ghost" onClick={onClose}><X size={16} /> {text("Sluiten", "Close")}</Button>}
/>
<form id="scene-metadata" className="form-stack" onSubmit={submit}>
<div className="form-grid form-grid--two">
<Field label={text("Naam", "Name")}><Input name="name" required defaultValue={scene.data.name} /></Field>
<Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={scene.data.description ?? ""} /></Field>
</div>
<label className="checkbox-row"><input name="favorite" type="checkbox" defaultChecked={scene.data.favorite} /> {text("Toon als favoriete scène", "Show as favorite scene")}</label>
</form>
<div className="resource-editor__section">
<h3>{text("Doeltoestanden", "Target states")}</h3>
{!items.length ? <p className="muted">{text("Deze scène bevat nog geen doelen.", "This scene has no targets yet.")}</p> : (
<div className="scene-item-list">
{items.map((item) => (
<div className="scene-item" key={`${item.target_type}:${item.target_id}`}>
<div><strong>{targetName(item)}</strong><span>{item.target_type === "group" ? text("Groep", "Group") : text("Apparaat", "Device")} · {describeState(item.state, text)}</span></div>
<Button type="button" variant="ghost" title={text("Verwijderen", "Remove")} aria-label={text(`Doel ${targetName(item)} verwijderen`, `Remove target ${targetName(item)}`)} onClick={() => setItems((current) => current.filter((candidate) => candidate !== item))}><Trash2 size={16} /></Button>
</div>
))}
</div>
)}
</div>
<form className="scene-target-form" onSubmit={addItem}>
<Field label={text("Doel", "Target")}>
<Select name="target" required defaultValue="">
<option value="" disabled>{text("Selecteer apparaat of groep", "Select device or group")}</option>
<optgroup label={text("Apparaten", "Devices")}>{devices.data?.items.map((device) => <option key={device.id} value={`device:${device.id}`}>{device.alias || device.name}</option>)}</optgroup>
<optgroup label={text("Groepen", "Groups")}>{groups.data?.map((group) => <option key={group.id} value={`group:${group.id}`}>{group.name}</option>)}</optgroup>
</Select>
</Field>
<Field label={text("Voeding", "Power")}><Select name="power" defaultValue="unchanged"><option value="unchanged">{text("Niet wijzigen", "Unchanged")}</option><option value="on">{text("Aan", "On")}</option><option value="off">{text("Uit", "Off")}</option></Select></Field>
<Field label={text("Kleur", "Color")}><div className="option-control"><input name="include_color" type="checkbox" aria-label={text("Kleur opnemen", "Include color")} /><input name="color" type="color" defaultValue="#5660ff" /></div></Field>
<Field label={text("Helderheid", "Brightness")}><div className="option-control"><input name="include_brightness" type="checkbox" aria-label={text("Helderheid opnemen", "Include brightness")} /><Input name="brightness" type="number" min="0" max="100" defaultValue="100" /></div></Field>
<Button type="submit" variant="secondary"><Plus size={16} /> {text("Doel toevoegen", "Add target")}</Button>
</form>
<div className="resource-editor__actions"><Button type="button" variant="ghost" onClick={onClose}>{text("Annuleren", "Cancel")}</Button><Button type="submit" form="scene-metadata" busy={save.isPending}><Save size={16} /> {text("Scène opslaan", "Save scene")}</Button></div>
</Card>
);
}
function describeState(state: DeviceState, text: (dutch: string, english: string) => string): string {
const values: string[] = [];
if (state.power !== undefined && state.power !== null) values.push(state.power ? text("aan", "on") : text("uit", "off"));
const color = state.colors?.[0];
if (color) values.push(`rgb(${color.red}, ${color.green}, ${color.blue})`);
if (state.brightness !== undefined && state.brightness !== null) values.push(`${state.brightness}%`);
return values.join(" · ") || text("geen eigenschappen", "no properties");
}
+36
View File
@@ -0,0 +1,36 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { CheckCircle2, X, XCircle } from "lucide-react";
import { IconButton } from "./ui";
interface ToastItem { id: string; message: string; tone: "success" | "danger" }
interface ToastValue { notify: (message: string, tone?: ToastItem["tone"]) => void }
const ToastContext = createContext<ToastValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const dismiss = useCallback((id: string) => setItems((current) => current.filter((item) => item.id !== id)), []);
const value = useMemo<ToastValue>(() => ({ notify: (message, tone = "success") => {
const id = crypto.randomUUID();
setItems((current) => [...current.slice(-3), { id, message, tone }]);
window.setTimeout(() => dismiss(id), 4500);
} }), [dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toasts" aria-live="polite">
{items.map((item) => <div key={item.id} className={`toast toast--${item.tone}`}>
{item.tone === "success" ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
<span>{item.message}</span>
<IconButton label="Sluiten" onClick={() => dismiss(item.id)}><X size={15} /></IconButton>
</div>)}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastValue {
const value = useContext(ToastContext);
if (!value) throw new Error("useToast moet binnen ToastProvider gebruikt worden");
return value;
}
@@ -0,0 +1,21 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "./ui";
import { renderApp } from "../test/render";
import { useI18n } from "../lib/i18n";
function LanguageFixture() {
const { setLanguage } = useI18n();
return <><StatusBadge status="degraded" /><button onClick={() => setLanguage("en")}>English</button></>;
}
describe("local component layer", () => {
it("renders status accessibly and switches language", async () => {
renderApp(<LanguageFixture />);
expect(screen.getByText("Beperkt")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "English" }));
expect(screen.getByText("Degraded")).toBeInTheDocument();
});
});
+209
View File
@@ -0,0 +1,209 @@
import {
AlertTriangle,
CheckCircle2,
CircleHelp,
Info,
LoaderCircle,
RefreshCw,
XCircle,
type LucideIcon,
} from "lucide-react";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
} from "react";
import type { HealthStatus } from "../api/types";
import { useI18n } from "../lib/i18n";
import { cn } from "../lib/utils";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export function Button({
variant = "primary",
className,
children,
busy,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; busy?: boolean }) {
return (
<button className={cn("button", `button--${variant}`, className)} disabled={busy || props.disabled} {...props}>
{busy ? <LoaderCircle className="spin" size={16} aria-hidden /> : null}
{children}
</button>
);
}
export function IconButton({ label, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
return (
<button className="icon-button" aria-label={label} title={label} {...props}>
{children}
</button>
);
}
export function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("card", className)} {...props}>
{children}
</div>
);
}
export function CardHeader({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="card__header">
<div>
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
</div>
{action}
</div>
);
}
export function Badge({
tone = "neutral",
children,
}: {
tone?: "neutral" | "success" | "warning" | "danger" | "accent";
children: ReactNode;
}) {
return <span className={cn("badge", `badge--${tone}`)}>{children}</span>;
}
export function StatusBadge({ status }: { status: HealthStatus }) {
const { t } = useI18n();
const mapping: Record<HealthStatus, { icon: LucideIcon; tone: "success" | "warning" | "danger" | "neutral" }> = {
healthy: { icon: CheckCircle2, tone: "success" },
degraded: { icon: AlertTriangle, tone: "warning" },
unhealthy: { icon: XCircle, tone: "danger" },
unknown: { icon: CircleHelp, tone: "neutral" },
};
const item = mapping[status];
const Icon = item.icon;
return (
<Badge tone={item.tone}>
<Icon size={13} aria-hidden /> {t(status)}
</Badge>
);
}
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn("input", props.className)} {...props} />;
}
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
return <select className={cn("input", props.className)} {...props} />;
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="field">
<span className="field__label">{label}</span>
{children}
{hint ? <span className="field__hint">{hint}</span> : null}
</label>
);
}
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow?: string;
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div>
{eyebrow ? <span className="eyebrow">{eyebrow}</span> : null}
<h1>{title}</h1>
{description ? <p>{description}</p> : null}
</div>
{actions ? <div className="page-header__actions">{actions}</div> : null}
</header>
);
}
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("skeleton", className)} aria-hidden />;
}
export function LoadingGrid({ count = 4 }: { count?: number }) {
return (
<div className="grid grid--cards" aria-label="Laden">
{Array.from({ length: count }, (_, index) => (
<Card key={index} className="skeleton-card">
<Skeleton className="skeleton--short" />
<Skeleton />
<Skeleton className="skeleton--medium" />
</Card>
))}
</div>
);
}
export function EmptyState({
title,
description,
action,
icon: Icon = Info,
}: {
title: string;
description: string;
action?: ReactNode;
icon?: LucideIcon;
}) {
return (
<div className="empty-state">
<span className="empty-state__icon"><Icon size={24} aria-hidden /></span>
<h2>{title}</h2>
<p>{description}</p>
{action}
</div>
);
}
export function ErrorPanel({ error, retry }: { error: unknown; retry?: () => void }) {
const { t } = useI18n();
const message = error instanceof Error ? error.message : "Onbekende fout";
return (
<div className="alert alert--danger" role="alert">
<XCircle size={20} aria-hidden />
<div>
<strong>Deze gegevens konden niet worden geladen.</strong>
<p>{message}</p>
</div>
{retry ? (
<Button variant="secondary" onClick={retry}>
<RefreshCw size={16} aria-hidden /> {t("retry")}
</Button>
) : null}
</div>
);
}
export function Stat({ label, value, detail, icon: Icon }: { label: string; value: ReactNode; detail?: string; icon: LucideIcon }) {
return (
<Card className="stat">
<span className="stat__icon"><Icon size={20} aria-hidden /></span>
<div><span className="stat__label">{label}</span><strong>{value}</strong>{detail ? <small>{detail}</small> : null}</div>
</Card>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
type Language = "nl" | "en";
const messages = {
nl: {
overview: "Overzicht",
devices: "Apparaten",
spaces: "Kamers & groepen",
scenes: "Scènes",
automations: "Automations",
network: "Netwerkapparaten",
connectors: "Connectoren",
discovery: "Discovery",
activity: "Activiteit",
audit: "Auditlog",
diagnostics: "Diagnostiek",
backups: "Back-up & herstel",
settings: "Instellingen",
about: "Over LumaOps",
online: "Online",
offline: "Offline",
healthy: "Gezond",
degraded: "Beperkt",
unhealthy: "Ongezond",
unknown: "Onbekend",
loading: "Laden…",
retry: "Opnieuw proberen",
apply: "Toepassen",
cancel: "Annuleren",
save: "Opslaan",
create: "Aanmaken",
search: "Zoeken",
noResults: "Geen resultaten gevonden",
allOff: "Alles uit",
emergency: "Noodstop",
rescan: "Opnieuw scannen",
mockWarning: "Testmodus actief — opdrachten bereiken geen echte hardware.",
openMenu: "Navigatie openen",
closeMenu: "Navigatie sluiten",
},
en: {
overview: "Overview",
devices: "Devices",
spaces: "Rooms & groups",
scenes: "Scenes",
automations: "Automations",
network: "Network devices",
connectors: "Connectors",
discovery: "Discovery",
activity: "Activity",
audit: "Audit log",
diagnostics: "Diagnostics",
backups: "Backup & restore",
settings: "Settings",
about: "About LumaOps",
online: "Online",
offline: "Offline",
healthy: "Healthy",
degraded: "Degraded",
unhealthy: "Unhealthy",
unknown: "Unknown",
loading: "Loading…",
retry: "Try again",
apply: "Apply",
cancel: "Cancel",
save: "Save",
create: "Create",
search: "Search",
noResults: "No results found",
allOff: "All off",
emergency: "Emergency stop",
rescan: "Rescan",
mockWarning: "Test mode is active — commands do not reach real hardware.",
openMenu: "Open navigation",
closeMenu: "Close navigation",
},
} as const;
type MessageKey = keyof (typeof messages)["nl"];
interface I18nValue {
language: Language;
setLanguage: (language: Language) => void;
t: (key: MessageKey) => string;
text: (dutch: string, english: string) => string;
}
const I18nContext = createContext<I18nValue | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => {
const saved = localStorage.getItem("lumaops.language");
return saved === "en" ? "en" : "nl";
});
const value = useMemo<I18nValue>(
() => ({
language,
setLanguage: (next) => {
localStorage.setItem("lumaops.language", next);
document.documentElement.lang = next;
setLanguageState(next);
},
t: (key) => messages[language][key],
text: (dutch, english) => (language === "nl" ? dutch : english),
}),
[language],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nValue {
const value = useContext(I18nContext);
if (!value) throw new Error("useI18n moet binnen I18nProvider gebruikt worden");
return value;
}
+57
View File
@@ -0,0 +1,57 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export type Theme = "light" | "dark" | "system";
interface ThemeValue {
theme: Theme;
resolved: "light" | "dark";
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeValue | null>(null);
function resolve(theme: Theme): "light" | "dark" {
if (theme !== "system") return theme;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const saved = localStorage.getItem("lumaops.theme");
return saved === "light" || saved === "dark" ? saved : "system";
});
const [resolved, setResolved] = useState(() => resolve(theme));
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => setResolved(resolve(theme));
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [theme]);
useEffect(() => {
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
}, [resolved]);
const value = useMemo(
() => ({
theme,
resolved,
setTheme: (next: Theme) => {
localStorage.setItem("lumaops.theme", next);
setThemeState(next);
},
}),
[resolved, theme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeValue {
const value = useContext(ThemeContext);
if (!value) throw new Error("useTheme moet binnen ThemeProvider gebruikt worden");
return value;
}
+31
View File
@@ -0,0 +1,31 @@
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export function formatDate(value?: string | null): string {
if (!value) return "—";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(value),
);
}
export function colorToHex(color?: { red: number; green: number; blue: number }): string {
if (!color) return "#5660ff";
return `#${[color.red, color.green, color.blue]
.map((part) => part.toString(16).padStart(2, "0"))
.join("")}`;
}
export function hexToColor(hex: string): { red: number; green: number; blue: number } {
const value = hex.replace("#", "");
return {
red: Number.parseInt(value.slice(0, 2), 16),
green: Number.parseInt(value.slice(2, 4), 16),
blue: Number.parseInt(value.slice(4, 6), 16),
};
}
export function formValue(data: FormData, key: string): string {
const value = data.get(key);
return typeof value === "string" ? value : "";
}
+33
View File
@@ -0,0 +1,33 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { ToastProvider } from "./components/Toast";
import { I18nProvider } from "./lib/i18n";
import { ThemeProvider } from "./lib/theme";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10_000, retry: 1, refetchOnWindowFocus: false },
mutations: { retry: 0 },
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
);
+13
View File
@@ -0,0 +1,13 @@
import { BookOpen, Box, Code2, GitBranch, Github, Lightbulb, ShieldCheck } from "lucide-react";
import { Card, CardHeader, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
export function AboutPage() {
const { text } = useI18n();
return <>
<PageHeader eyebrow={text("Lokaal · open source · hardware-first", "Local · open source · hardware-first")} title={text("Over LumaOps", "About LumaOps")} description={text("Een persoonlijk beheerplatform dat OpenRGB als echte hardware-engine behoudt.", "A personal control platform that keeps OpenRGB as its real hardware engine.")} />
<div className="about-hero"><span><Lightbulb size={34} /></span><div><h2>LumaOps 0.1.0</h2><p>Gebouwd rond OpenRGB 1.0rc3, SDK protocol 5 en Plugin API 4.</p></div></div>
<div className="grid grid--cards"><Card><CardHeader title="Architectuur" /><ul className="icon-list"><li><Box size={16} /> Eén productiecontainer</li><li><Code2 size={16} /> FastAPI + React + TypeScript</li><li><GitBranch size={16} /> OpenRGB-bron als onderhoudbare upstream</li></ul></Card><Card><CardHeader title="Licentie" /><ul className="icon-list"><li><ShieldCheck size={16} /> GPL-2.0-or-later gecombineerd werk</li><li><Github size={16} /> Bron en buildinstructies mee distribueren</li><li><BookOpen size={16} /> Bestaande copyright- en SPDX-headers behouden</li></ul></Card></div>
<Card className="credits"><CardHeader title="Dank aan OpenRGB" description="LumaOps vervangt OpenRGB niet. De volwassen controller- en detectorlaag blijft verantwoordelijk voor hardwarecommunicatie." /><p>De LumaOps-servicelaag spreekt uitsluitend via de lokale SDK-v5-grens, met aanvullende identiteit-, capability- en veiligheidscontroles.</p></Card>
</>;
}
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { Activity, ClipboardCheck, Search } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import type { Activity as ActivityRecord, Page } from "../api/types";
import { Badge, Card, EmptyState, Input, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
interface AuditRecord { id: string; actor: string; action: string; resource_type: string; resource_id: string | null; outcome: string; created_at: string; request_id: string }
export function ActivityPage({ mode }: { mode: "activity" | "audit" }) {
const { text } = useI18n();
const [search, setSearch] = useState("");
const query = useQuery({ queryKey: [mode], queryFn: () => api<Page<ActivityRecord | AuditRecord>>(`/api/v1/${mode}?limit=300`) });
const filtered = query.data?.items.filter((record) => JSON.stringify(record).toLowerCase().includes(search.toLowerCase())) ?? [];
const isAudit = mode === "audit";
return <>
<PageHeader eyebrow={isAudit ? text("Onveranderbare handelingenhistoriek", "Immutable action history") : text("Systeemgebeurtenissen", "System events")} title={isAudit ? text("Auditlog", "Audit log") : text("Activiteit", "Activity")} description={isAudit ? text("Wie wijzigde wat, op welk doel en met welk resultaat.", "Who changed what, on which target, and with which result.") : text("Recente opdrachten, discovery, automations en systeemmeldingen.", "Recent commands, discovery, automations and system messages.")} />
<div className="toolbar"><div className="search"><Search size={17} /><Input aria-label="Log doorzoeken" placeholder="Doorzoek gebeurtenissen…" value={search} onChange={(event) => setSearch(event.target.value)} /></div></div>
<Card>{filtered.length ? <div className="event-list">{filtered.map((record) => isAudit ? <AuditRow key={record.id} record={record as AuditRecord} /> : <ActivityRow key={record.id} record={record as ActivityRecord} />)}</div> : <EmptyState icon={isAudit ? ClipboardCheck : Activity} title="Geen gebeurtenissen" description="Nieuwe acties verschijnen hier automatisch." />}</Card>
</>;
}
function ActivityRow({ record }: { record: ActivityRecord }) { return <div className="event-row"><span className={`event-row__icon event-row__icon--${record.severity}`}><Activity size={16} /></span><div><strong>{record.title}</strong><p>{record.message}</p></div><div><Badge>{record.category}</Badge><span>{formatDate(record.created_at)}</span></div></div>; }
function AuditRow({ record }: { record: AuditRecord }) { return <div className="event-row"><span className={`event-row__icon event-row__icon--${record.outcome}`}><ClipboardCheck size={16} /></span><div><strong>{record.action}</strong><p>{record.actor} · {record.resource_type} {record.resource_id?.slice(0, 8) ?? ""}</p></div><div><Badge tone={record.outcome === "succeeded" ? "success" : "danger"}>{record.outcome}</Badge><span>{formatDate(record.created_at)}</span><small title={record.request_id}>req {record.request_id.slice(0, 8)}</small></div></div>; }
@@ -0,0 +1,48 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
import { AutomationsPage } from "./AutomationsPage";
const automation = {
id: "automation-1",
name: "Avond",
description: "Rustige verlichting",
enabled: true,
trigger: { type: "time", at: "20:00", weekdays: [0, 1, 2, 3, 4] },
actions: [{ type: "scene", scene_id: "scene-1" }],
timezone: "Europe/Brussels",
cooldown_seconds: 60,
conflict_key: "woonkamer",
last_run_at: null,
next_run_at: "2026-07-15T18:00:00Z",
last_error: null,
};
describe("AutomationsPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = requestUrl(input);
if (url.includes("/runs")) return Promise.resolve(jsonResponse({ items: [{ id: "run-1", automation_id: automation.id, status: "succeeded", trigger: { type: "manual" }, result: {}, error: null, started_at: "2026-07-15T12:00:00Z", finished_at: "2026-07-15T12:00:01Z" }], total: 1, limit: 25, offset: 0 }));
if (url.endsWith(`/automations/${automation.id}`) && init?.method === "PUT") return Promise.resolve(jsonResponse({ ...automation, enabled: false }));
if (url.includes("/automations")) return Promise.resolve(jsonResponse({ items: [automation], total: 1, limit: 200, offset: 0 }));
if (url.includes("/scenes")) return Promise.resolve(jsonResponse({ items: [{ id: "scene-1", name: "Avondrust" }], total: 1, limit: 200, offset: 0 }));
return Promise.resolve(jsonResponse({}));
}));
});
it("toggles an automation and exposes its run history", async () => {
const user = userEvent.setup();
renderApp(<AutomationsPage />, "/automations");
expect(await screen.findByText("Avond")).toBeInTheDocument();
await user.click(screen.getByTitle("Uitschakelen"));
await waitFor(() => {
const update = vi.mocked(fetch).mock.calls.find(([input, init]) => requestUrl(input).endsWith(`/automations/${automation.id}`) && init?.method === "PUT");
expect(update).toBeDefined();
expect(requestJson(update?.[1])).toMatchObject({ enabled: false, conflict_key: "woonkamer" });
});
await user.click(screen.getByTitle("Historiek"));
expect(await screen.findByText("succeeded")).toBeInTheDocument();
expect(screen.getByText("manual")).toBeInTheDocument();
});
});
@@ -0,0 +1,77 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarClock, History, Pause, Pencil, Play, Plus, Timer, Trash2, X, Zap } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import type { Automation, AutomationRun, Page, Scene } from "../api/types";
import { AutomationEditor, type AutomationPayload } from "../components/AutomationEditor";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, EmptyState, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
export function AutomationsPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [editor, setEditor] = useState<Automation | "new" | null>(null);
const [deleteAutomation, setDeleteAutomation] = useState<Automation | null>(null);
const [historyAutomation, setHistoryAutomation] = useState<Automation | null>(null);
const automations = useQuery({ queryKey: ["automations"], queryFn: () => api<Page<Automation>>("/api/v1/automations?limit=200") });
const scenes = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=200") });
const history = useQuery({ queryKey: ["automation-runs", historyAutomation?.id], queryFn: () => api<Page<AutomationRun>>(`/api/v1/automations/${historyAutomation?.id}/runs?limit=25`), enabled: Boolean(historyAutomation) });
const refresh = () => void queryClient.invalidateQueries({ queryKey: ["automations"] });
const save = useMutation({
mutationFn: ({ id, payload }: { id?: string; payload: AutomationPayload }) => api<Automation>(`/api/v1/automations${id ? `/${id}` : ""}`, { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }),
onSuccess: (_, variables) => { notify(variables.id ? text("Automation bijgewerkt.", "Automation updated.") : text("Automation aangemaakt.", "Automation created.")); setEditor(null); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
});
const run = useMutation({
mutationFn: (id: string) => api<{ status: string }>(`/api/v1/automations/${id}/run`, { method: "POST" }),
onSuccess: (result) => { notify(result.status === "succeeded" ? text("Automation uitgevoerd.", "Automation executed.") : text(`Uitvoering: ${result.status}`, `Run: ${result.status}`), result.status === "failed" ? "danger" : "success"); refresh(); void queryClient.invalidateQueries({ queryKey: ["automation-runs"] }); },
onError: (error) => notify(error instanceof Error ? error.message : text("Uitvoering mislukt.", "Run failed."), "danger"),
});
const toggle = useMutation({
mutationFn: (automation: Automation) => api<Automation>(`/api/v1/automations/${automation.id}`, { method: "PUT", body: JSON.stringify(toPayload(automation, !automation.enabled)) }),
onSuccess: (automation) => { notify(automation.enabled ? text("Automation ingeschakeld.", "Automation enabled.") : text("Automation uitgeschakeld.", "Automation disabled.")); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Status wijzigen mislukt.", "Status update failed."), "danger"),
});
const remove = useMutation({
mutationFn: (id: string) => api(`/api/v1/automations/${id}`, { method: "DELETE" }),
onSuccess: () => { notify(text("Automation verwijderd.", "Automation deleted.")); setDeleteAutomation(null); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
});
const error = automations.error ?? scenes.error;
return <>
<PageHeader eyebrow={text("Regelengine", "Rules engine")} title="Automations" description={text("Plan scènes per weekdag met cooldown, conflictpreventie en volledige uitvoeringshistoriek.", "Schedule scenes by weekday with cooldown, conflict prevention, and complete run history.")} actions={<Button onClick={() => setEditor("new")}><Plus size={16} /> {text("Nieuwe automation", "New automation")}</Button>} />
{editor ? <AutomationEditor key={editor === "new" ? "new" : editor.id} automation={editor === "new" ? undefined : editor} scenes={scenes.data} busy={save.isPending} onClose={() => setEditor(null)} onSubmit={(payload) => save.mutate({ id: editor === "new" ? undefined : editor.id, payload })} /> : null}
{historyAutomation ? <Card className="resource-editor"><CardHeader title={text(`Uitvoeringshistoriek · ${historyAutomation.name}`, `Run history · ${historyAutomation.name}`)} description={text("De 25 meest recente handmatige en geplande uitvoeringen.", "The 25 most recent manual and scheduled runs.")} action={<Button variant="ghost" onClick={() => setHistoryAutomation(null)}><X size={16} /> {text("Sluiten", "Close")}</Button>} />{history.isLoading ? <LoadingGrid count={2} /> : history.error ? <ErrorPanel error={history.error} retry={() => void history.refetch()} /> : !history.data?.items.length ? <EmptyState icon={History} title={text("Nog geen uitvoeringen", "No runs yet")} description={text("Test de automation om de eerste uitvoering vast te leggen.", "Test the automation to record its first run.")} /> : <div className="run-list">{history.data.items.map((item) => <div className="run-row" key={item.id}><Badge tone={item.status === "succeeded" ? "success" : item.status === "failed" ? "danger" : "neutral"}>{item.status}</Badge><span>{formatDate(item.started_at)}</span><span>{typeof item.trigger.type === "string" ? item.trigger.type : "manual"}</span><span className="run-row__error">{item.error || "—"}</span></div>)}</div>}</Card> : null}
{error ? <ErrorPanel error={error} retry={() => void Promise.all([automations.refetch(), scenes.refetch()])} /> : automations.isLoading ? <LoadingGrid /> : !automations.data?.items.length ? <EmptyState icon={CalendarClock} title={text("Nog geen automations", "No automations yet")} description={text("Plan een scène op een tijdstip en geselecteerde weekdagen.", "Schedule a scene at a time on selected weekdays.")} action={<Button onClick={() => setEditor("new")}>{text("Eerste automation maken", "Create first automation")}</Button>} /> : <div className="automation-list">{automations.data.items.map((automation) => <Card key={automation.id} className="automation-row"><span className="automation-row__icon"><Zap size={20} /></span><div className="automation-row__main"><div><h2>{automation.name}</h2><p><Timer size={14} /> {typeof automation.trigger.at === "string" ? automation.trigger.at : text("handmatig", "manual")} · {weekdaySummary(automation.trigger.weekdays, text)}</p></div><div className="automation-row__meta"><Badge tone={automation.enabled ? "success" : "neutral"}>{automation.enabled ? text("Actief", "Enabled") : text("Uitgeschakeld", "Disabled")}</Badge><span>{text("Volgende", "Next")}: {formatDate(automation.next_run_at)}</span><span>{text("Laatste", "Last")}: {formatDate(automation.last_run_at)}</span>{automation.last_error ? <Badge tone="danger">{automation.last_error}</Badge> : null}</div></div><div className="automation-row__actions"><Button variant="secondary" onClick={() => run.mutate(automation.id)} busy={run.isPending}><Play size={15} /> {text("Test", "Test")}</Button><Button variant="ghost" title={automation.enabled ? text("Uitschakelen", "Disable") : text("Inschakelen", "Enable")} onClick={() => toggle.mutate(automation)} busy={toggle.isPending}>{automation.enabled ? <Pause size={15} /> : <Play size={15} />}</Button><Button variant="ghost" title={text("Historiek", "History")} onClick={() => setHistoryAutomation(automation)}><History size={15} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} onClick={() => setEditor(automation)}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteAutomation(automation)}><Trash2 size={15} /></Button></div></Card>)}</div>}
<ConfirmDialog open={Boolean(deleteAutomation)} title={text("Automation verwijderen?", "Delete automation?")} description={text(`${deleteAutomation?.name ?? ""}” wordt uitgeschakeld en verwijderd. Bestaande uitvoeringshistoriek blijft bewaard.`, `${deleteAutomation?.name ?? ""}” will be disabled and deleted. Existing run history is preserved.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteAutomation(null)} onConfirm={() => deleteAutomation && remove.mutate(deleteAutomation.id)} />
</>;
}
function toPayload(automation: Automation, enabled: boolean): AutomationPayload {
const sceneId = automation.actions[0]?.scene_id;
return {
name: automation.name,
description: automation.description,
enabled,
trigger: {
type: "time",
at: typeof automation.trigger.at === "string" ? automation.trigger.at : "20:00",
weekdays: Array.isArray(automation.trigger.weekdays) ? automation.trigger.weekdays.map(Number) : [0, 1, 2, 3, 4, 5, 6],
},
actions: [{ type: "scene", scene_id: typeof sceneId === "string" ? sceneId : "" }],
timezone: automation.timezone,
cooldown_seconds: automation.cooldown_seconds,
conflict_key: automation.conflict_key,
};
}
function weekdaySummary(value: unknown, text: (dutch: string, english: string) => string): string {
if (!Array.isArray(value) || value.length === 7) return text("elke dag", "every day");
const labels = text("ma,di,wo,do,vr,za,zo", "Mon,Tue,Wed,Thu,Fri,Sat,Sun").split(",");
return value.map(Number).map((day) => labels[day]).filter(Boolean).join(", ");
}
@@ -0,0 +1,26 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, ArchiveRestore, DatabaseBackup, Plus } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
interface Backup { name: string; size: number; modified_at: number }
export function BackupsPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [restore, setRestore] = useState<string | null>(null);
const query = useQuery({ queryKey: ["backups"], queryFn: () => api<Backup[]>("/api/v1/backups") });
const create = useMutation({ mutationFn: () => api<Backup>("/api/v1/backups", { method: "POST" }), onSuccess: () => { notify("Databaseback-up aangemaakt."); void queryClient.invalidateQueries({ queryKey: ["backups"] }); } });
const restoreMutation = useMutation({ mutationFn: (name: string) => api<{ safety_backup: string }>("/api/v1/backups/restore", { method: "POST", body: JSON.stringify({ name }) }), onSuccess: (result) => { notify(`Hersteld. Veiligheidskopie: ${result.safety_backup}`); setRestore(null); void queryClient.invalidateQueries(); }, onError: (error) => notify(error instanceof Error ? error.message : "Herstel mislukt.", "danger") });
return <>
<PageHeader eyebrow={text("Appdata-bescherming", "Appdata protection")} title={text("Back-up & herstel", "Backup & restore")} description={text("SQLite-back-ups zijn atomair; bewaar ook /config/openrgb en secret.key buiten de server.", "SQLite backups are atomic; also store /config/openrgb and secret.key outside the server.")} actions={<Button onClick={() => create.mutate()} busy={create.isPending}><Plus size={16} /> {text("Nieuwe back-up", "New backup")}</Button>} />
<div className="alert alert--info"><DatabaseBackup size={20} /><div><strong>Een volledige back-up bestaat uit drie delen</strong><p>Database, /config/openrgb en /config/lumaops/secret.key. Zonder de sleutel zijn connectorsecrets niet herstelbaar.</p></div></div>
<Card><CardHeader title="Beheerde databaseback-ups" description="Voor destructieve migraties en restores wordt automatisch een extra veiligheidskopie gemaakt." />{query.data?.length ? <div className="backup-list">{query.data.map((backup) => <div className="backup-row" key={backup.name}><span><Archive size={19} /></span><div><strong>{backup.name}</strong><p>{new Intl.NumberFormat(undefined, { style: "unit", unit: "megabyte", maximumFractionDigits: 2 }).format(backup.size / 1_048_576)} · {new Date(backup.modified_at * 1000).toLocaleString()}</p></div><Badge>SQLite</Badge><Button variant="secondary" onClick={() => setRestore(backup.name)}><ArchiveRestore size={15} /> Herstellen</Button></div>)}</div> : <EmptyState icon={Archive} title="Nog geen back-ups" description="Maak een eerste herstelpunt voordat je hardware en scènes configureert." />}</Card>
<ConfirmDialog open={restore !== null} title="Database herstellen?" description={`Alle huidige LumaOps-data wordt vervangen door ${restore ?? "deze back-up"}. Eerst wordt automatisch een veiligheidskopie gemaakt.`} confirmLabel="Herstellen" danger onClose={() => setRestore(null)} onConfirm={() => restore && restoreMutation.mutate(restore)} />
</>;
}
@@ -0,0 +1,21 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Cable, Check, PlugZap, RefreshCw, ShieldAlert } from "lucide-react";
import { api } from "../api/client";
import type { ComponentHealth } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, LoadingGrid, PageHeader, StatusBadge } from "../components/ui";
import { useI18n } from "../lib/i18n";
interface Connector { id: string; kind: string; configuration_schema: { warning?: string }; health: ComponentHealth }
export function ConnectorsPage() {
const { text } = useI18n();
const { notify } = useToast();
const query = useQuery({ queryKey: ["connectors"], queryFn: () => api<Connector[]>("/api/v1/connectors") });
const test = useMutation({ mutationFn: (id: string) => api<ComponentHealth>(`/api/v1/connectors/${id}/test`, { method: "POST" }), onSuccess: (health) => notify(health.connected ? "Verbindingstest geslaagd." : health.message, health.connected ? "success" : "danger") });
return <>
<PageHeader eyebrow={text("Adapterlaag", "Adapter layer")} title={text("Connectoren", "Connectors")} description={text("Eén capabilitymodel voor OpenRGB en toekomstige WLED-, Home Assistant- en MQTT-integraties.", "One capability model for OpenRGB and future WLED, Home Assistant and MQTT integrations.")} />
{query.isLoading ? <LoadingGrid count={2} /> : <div className="grid grid--connectors">{query.data?.map((connector) => <Card key={connector.id} className="connector-card"><CardHeader title={connector.id === "openrgb-local" ? "OpenRGB Core" : connector.kind} description={connector.kind === "openrgb" ? "Primaire hardware-engine · SDK protocol 5" : "Expliciete testadapter"} action={<StatusBadge status={connector.health.status} />} /><div className="connector-hero"><span><PlugZap size={27} /></span><div><strong>{connector.health.connected ? "Verbonden" : "Niet verbonden"}</strong><p>{connector.health.message}</p></div></div><div className="connector-meta"><div><span>Type</span><strong>{connector.kind}</strong></div><div><span>Authenticatie</span><strong>{connector.kind === "openrgb" ? "Loopback" : "Niet vereist"}</strong></div></div>{connector.configuration_schema.warning ? <div className="mini-warning"><ShieldAlert size={15} />{connector.configuration_schema.warning}</div> : null}<Button variant="secondary" onClick={() => test.mutate(connector.id)} busy={test.isPending}><RefreshCw size={15} /> Verbinding testen</Button></Card>)}</div>}
<section className="section-block"><div className="section-heading"><div><h2>Uitbreidingsfase</h2><p>Connectorcontract is voorbereid; native integraties blijven standaard uit.</p></div></div><div className="grid grid--cards">{[["WLED", "JSON API, segmenten, effecten en presets"], ["Home Assistant", "REST, WebSocket en geselecteerde light-entiteiten"], ["MQTT", "Generieke toekomstige event- en statebridge"]].map(([name, description]) => <Card key={name} className="planned-connector"><span><Cable size={19} /></span><div><h3>{name}</h3><p>{description}</p></div><Badge><Check size={12} /> Architectuur klaar</Badge></Card>)}</div></section>
</>;
}
@@ -0,0 +1,232 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { focusManager } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Route, Routes } from "react-router-dom";
import { DeviceDetailPage } from "./DeviceDetailPage";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
const device = {
id: "device-1", connector_id: "openrgb", external_id: "asus", fingerprint: "asus", source: "openrgb", device_type: "motherboard", owner: "openrgb",
name: "ASUS Aura Mainboard", alias: null, vendor: "ASUS", model: "Aura", serial: null, location: "HID: /dev/hidraw0",
ip_address: null, firmware_version: null, controller_index: 0,
capabilities: { power: true, restore: true, rgb: true, brightness: false, color_temperature: false, effect: true, speed: false,
direction: false, multiple_colors: true, per_zone: true, per_segment: false, per_led: true, profiles: false, readable_state: true,
max_leds: 126, min_brightness: 0, max_brightness: 100, min_speed: null, max_speed: null },
state: { power: true, colors: [{ red: 32, green: 64, blue: 128 }], mode: "Direct", mode_index: 0 },
zones: [
{ index: 0, name: "Aura Mainboard", type: 1, led_count: 6, leds_min: 6, leds_max: 6 },
{ index: 1, name: "Aura Addressable 1", type: 1, led_count: 0, leds_min: 0, leds_max: 120 },
],
modes: [
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 1, name: "Off", flags: 256, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 2, name: "Static", flags: 320, speed_min: null, speed_max: null, brightness: false, colors_min: 1, colors_max: 1 },
], metadata: {}, led_count: 6, online: true, hidden: false, favorite: false, exclude_global: false,
read_only: false, blocked: false, experimental: false, room_id: null, tags: [], error_status: null,
last_detected_at: "2026-07-15T02:00:00Z", last_command_at: null,
};
const ramDevice = {
...device,
id: "ram-1",
device_type: "dram",
external_id: "corsair-ram",
fingerprint: "corsair-ram",
name: "Corsair Vengeance RGB Pro SL DDR4",
vendor: "Corsair",
model: "Corsair DRAM RGB Device",
location: "I2C: /dev/i2c-0, address 0x58",
capabilities: { ...device.capabilities, brightness: true, speed: true, direction: true, max_leds: 10 },
state: { power: false, brightness: null, colors: Array.from({ length: 10 }, () => ({ red: 0, green: 0, blue: 0 })), mode: "Direct", mode_index: 0 },
zones: [{ index: 0, name: "Corsair DRAM", type: 1, led_count: 10, leds_min: 10, leds_max: 10 }],
modes: [
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 1, name: "Custom", flags: 544, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 2, name: "Color Pulse", flags: 721, speed_min: 0, speed_max: 2, brightness: true, colors_min: 2, colors_max: 2 },
{ index: 5, name: "Color Wave", flags: 727, speed_min: 0, speed_max: 2, brightness: true, colors_min: 2, colors_max: 2 },
{ index: 9, name: "Rainbow", flags: 513, speed_min: 0, speed_max: 2, brightness: false, colors_min: 0, colors_max: 0 },
],
led_count: 10,
};
describe("DeviceDetailPage", () => {
beforeEach(() => {
vi.mocked(window.matchMedia).mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
if (requestUrl(input).endsWith("/zones/1/state") && init?.method === "POST") {
return Promise.resolve(jsonResponse({ id: "zone-command", status: "succeeded", zone_index: 1, led_count: 120 }));
}
return Promise.resolve(jsonResponse(device));
}));
});
it("uses every addressable LED without asking for a count", async () => {
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
const input = await screen.findByRole("textbox", { name: "Hoofdkleur voor Aura Addressable 1" });
await user.clear(input);
await user.type(input, "#336699");
expect(screen.queryByText(/Aantal leds voor/)).not.toBeInTheDocument();
expect(screen.getByText("Alle 120 leds")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Alleen Aura Addressable 1 toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/device-1/zones/1/state",
expect.objectContaining({ method: "POST" }),
));
const zoneCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/zones/1/state"));
expect(requestJson(zoneCall?.[1])).toEqual({
state: { power: true, colors: [{ red: 51, green: 102, blue: 153 }] },
});
});
it("applies an individual rainbow pattern to an addressable fan header", async () => {
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
await user.selectOptions(await screen.findByRole("combobox", { name: "Modus voor Aura Addressable 1" }), "rainbow");
await user.click(screen.getByRole("button", { name: "Alleen Aura Addressable 1 toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/device-1/zones/1/state",
expect.objectContaining({ method: "POST" }),
));
const zoneCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/zones/1/state"));
const body = requestJson(zoneCall?.[1]) as { state: { colors: unknown[] } };
expect(body.state.colors).toHaveLength(120);
expect(new Set(body.state.colors.map((color) => JSON.stringify(color))).size).toBeGreaterThan(6);
});
it("keeps Direct as a hardware mode when no color is changed", async () => {
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
await screen.findByRole("combobox", { name: "Effectmodus" });
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
const stateCall = await waitFor(() => {
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(call).toBeDefined();
return call;
});
expect(requestJson(stateCall?.[1])).toEqual({
state: { power: true, mode_index: 0, colors: [{ red: 32, green: 64, blue: 128 }] },
});
});
it("keeps a RAM color stable and omits unsupported brightness", async () => {
let currentRam: unknown = ramDevice;
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
if (requestUrl(input).endsWith("/state") && init?.method === "POST") {
currentRam = {
...ramDevice,
last_command_at: "2026-07-16T00:00:01Z",
state: {
power: true,
brightness: null,
colors: Array.from({ length: 10 }, () => ({ red: 136, green: 68, blue: 204 })),
mode: "Custom",
mode_index: 1,
},
};
return Promise.resolve(jsonResponse({ id: "command-1", status: "succeeded" }));
}
return Promise.resolve(jsonResponse(currentRam));
}));
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
const colorInput = await screen.findByRole("textbox", { name: "Kleur 1" });
expect(screen.queryByText(/Helderheid/)).not.toBeInTheDocument();
await user.clear(colorInput);
await user.type(colorInput, "#8844cc");
expect(screen.getByRole("combobox", { name: "Effectmodus" })).toHaveValue("1");
const readsBeforeRefetch = vi.mocked(fetch).mock.calls.filter(
([input, init]) => requestUrl(input).endsWith("/ram-1") && !init?.method,
).length;
currentRam = { ...ramDevice, last_command_at: "2026-07-16T00:00:00Z" };
focusManager.setFocused(false);
focusManager.setFocused(true);
await waitFor(() => expect(vi.mocked(fetch).mock.calls.filter(
([input, init]) => requestUrl(input).endsWith("/ram-1") && !init?.method,
).length).toBeGreaterThan(readsBeforeRefetch));
expect(colorInput).toHaveValue("#8844CC");
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/ram-1/state",
expect.objectContaining({ method: "POST" }),
));
const stateCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(requestJson(stateCall?.[1])).toEqual({
state: { power: true, colors: [{ red: 136, green: 68, blue: 204 }], mode_index: 1 },
});
await waitFor(() => expect(colorInput).toHaveValue("#8844CC"));
focusManager.setFocused(undefined);
});
it("sends every parameter required by a two-color RAM effect", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(ramDevice)));
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
await user.selectOptions(await screen.findByRole("combobox", { name: "Effectmodus" }), "5");
const firstColor = screen.getByRole("textbox", { name: "Kleur 1" });
const secondColor = screen.getByRole("textbox", { name: "Kleur 2" });
await user.clear(firstColor);
await user.type(firstColor, "#112233");
await user.clear(secondColor);
await user.type(secondColor, "#aabbcc");
fireEvent.change(screen.getByLabelText("Helderheid"), { target: { value: "75" } });
fireEvent.change(screen.getByLabelText("Snelheid"), { target: { value: "2" } });
await user.selectOptions(screen.getByRole("combobox", { name: "Richting" }), "1");
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/ram-1/state",
expect.objectContaining({ method: "POST" }),
));
const stateCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(requestJson(stateCall?.[1])).toEqual({
state: {
power: true,
mode_index: 5,
colors: [{ red: 17, green: 34, blue: 51 }, { red: 170, green: 187, blue: 204 }],
brightness: 75,
speed: 2,
direction: 1,
},
});
});
it("does not overwrite a self-colored hardware effect with a static color", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(ramDevice)));
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
await user.selectOptions(await screen.findByRole("combobox", { name: "Effectmodus" }), "9");
expect(screen.queryByRole("textbox", { name: "Kleur 1" })).not.toBeInTheDocument();
expect(screen.getByText("Deze hardwaremodus genereert zijn kleuren automatisch.")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Snelheid"), { target: { value: "2" } });
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
const stateCall = await waitFor(() => {
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(call).toBeDefined();
return call;
});
expect(requestJson(stateCall?.[1])).toEqual({ state: { power: true, mode_index: 9, speed: 2 } });
});
});
@@ -0,0 +1,122 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Eye, Lightbulb, MapPin, ScanLine, Shield, Star, Tag, Wifi } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { api, mutationId } from "../api/client";
import type { Device, DeviceState } from "../api/types";
import { RgbControlPanel } from "../components/RgbControlPanel";
import { RgbZoneControls } from "../components/RgbZoneControls";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
export function DeviceDetailPage() {
const { text } = useI18n();
const { deviceId = "" } = useParams();
const queryClient = useQueryClient();
const { notify } = useToast();
const query = useQuery({
queryKey: ["device", deviceId],
queryFn: () => api<Device>(`/api/v1/devices/${deviceId}`),
});
const command = useMutation({
mutationFn: (state: DeviceState) => api(`/api/v1/devices/${deviceId}/state`, {
method: "POST",
headers: { "Idempotency-Key": mutationId() },
body: JSON.stringify({ state }),
}),
onSuccess: () => {
notify("Apparaat bijgewerkt.");
void queryClient.invalidateQueries({ queryKey: ["device", deviceId] });
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
onError: (error) => notify(error instanceof Error ? error.message : "Opdracht mislukt.", "danger"),
});
const patch = useMutation({
mutationFn: (value: Record<string, unknown>) => api<Device>(`/api/v1/devices/${deviceId}`, {
method: "PATCH",
body: JSON.stringify(value),
}),
onSuccess: (data) => {
queryClient.setQueryData(["device", deviceId], data);
notify("Apparaatinstelling opgeslagen.");
},
});
const identify = useMutation({
mutationFn: () => api(`/api/v1/devices/${deviceId}/identify`, { method: "POST" }),
onSuccess: () => notify("Identificatie afgerond."),
});
const zoneCommand = useMutation({
mutationFn: ({ zoneIndex: targetZone, state }: { zoneIndex: number; state: DeviceState }) =>
api(`/api/v1/devices/${deviceId}/zones/${targetZone}/state`, {
method: "POST",
headers: { "Idempotency-Key": mutationId() },
body: JSON.stringify({ state }),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["device", deviceId] });
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
notify("Aura-zone bijgewerkt.");
},
onError: (error) => notify(error instanceof Error ? error.message : "Aura-zone kon niet worden bijgewerkt.", "danger"),
});
if (query.isLoading) return <LoadingGrid count={3} />;
if (query.error || !query.data) return <ErrorPanel error={query.error} retry={() => void query.refetch()} />;
const device = query.data;
return <>
<Link className="back-link" to="/devices"><ArrowLeft size={16} /> Terug naar apparaten</Link>
<PageHeader
eyebrow={device.source}
title={device.alias || device.name}
description={[device.vendor, device.model, device.location].filter(Boolean).join(" · ")}
actions={<>
<Button variant="secondary" onClick={() => identify.mutate()} busy={identify.isPending}><ScanLine size={16} /> {text("Identificeren", "Identify")}</Button>
<Button variant="ghost" onClick={() => patch.mutate({ favorite: !device.favorite })}><Star size={16} fill={device.favorite ? "currentColor" : "none"} /> {text("Favoriet", "Favorite")}</Button>
</>}
/>
<div className="detail-status">
<Badge tone={device.online ? "success" : "danger"}><Wifi size={13} /> {device.online ? "Online" : "Offline"}</Badge>
{device.read_only ? <Badge tone="warning">Alleen lezen</Badge> : null}
{device.experimental ? <Badge tone="warning">Experimenteel</Badge> : null}
<span>Laatst gezien {formatDate(device.last_detected_at)}</span>
</div>
<div className="detail-grid">
<RgbControlPanel
targetKey={device.id}
capabilities={device.capabilities}
state={device.state}
modes={device.modes}
pending={command.isPending}
disabled={!device.online || device.read_only || device.blocked}
onApply={(state) => command.mutate(state)}
/>
<div className="detail-side">
<Card><CardHeader title="Eigenschappen" /><dl className="properties"><div><dt><Shield size={15} /> Beheerder</dt><dd>{device.owner}</dd></div><div><dt><MapPin size={15} /> Locatie</dt><dd>{device.location || "Niet bekend"}</dd></div><div><dt><Lightbulb size={15} /> Leds</dt><dd>{device.led_count}</dd></div><div><dt><Tag size={15} /> Serienummer</dt><dd>{device.serial || "Niet gemeld"}</dd></div></dl></Card>
<Card><CardHeader title="Beheerbeleid" description="Voorkom onbedoelde globale of wijzigende acties." /><div className="switch-list"><Toggle label="Favoriet" checked={device.favorite} onChange={(value) => patch.mutate({ favorite: value })} /><Toggle label="Verbergen" checked={device.hidden} onChange={(value) => patch.mutate({ hidden: value })} /><Toggle label="Alleen lezen" checked={device.read_only} onChange={(value) => patch.mutate({ read_only: value })} /><Toggle label="Blokkeren" checked={device.blocked} onChange={(value) => patch.mutate({ blocked: value })} danger /></div></Card>
</div>
</div>
{device.capabilities.per_zone && device.zones.length ? <section className="section-block">
<div className="section-heading"><div><h2>Moederbord en Aura-addressables</h2><p>Bedien elke zone afzonderlijk. LumaOps gebruikt automatisch de volledige header; hardware-effecten in de algemene bediening blijven controllerbreed.</p></div></div>
<RgbZoneControls
targetKey={device.id}
zones={device.zones}
state={device.state}
pendingZone={zoneCommand.isPending ? zoneCommand.variables?.zoneIndex : undefined}
disabled={!device.online || device.read_only || device.blocked}
onApply={(zoneIndex, state) => zoneCommand.mutate({ zoneIndex, state })}
/>
</section> : null}
<section className="section-block"><div className="section-heading"><div><h2>Zones en capabilities</h2><p>OpenRGB SDK-v5 inventory zonder write-side effects.</p></div></div><div className="grid grid--cards">{device.zones.map((zone) => <Card key={zone.index}><div className="zone-card"><span><Eye size={18} /></span><div><h3>{zone.name}</h3><p>{zone.led_count} leds · type {zone.type}</p></div></div></Card>)}{!device.zones.length ? <Card><p className="muted">Dit apparaat rapporteert geen zones.</p></Card> : null}</div></section>
</>;
}
function Toggle({ label, checked, onChange, danger = false }: { label: string; checked: boolean; onChange: (value: boolean) => void; danger?: boolean }) {
return <label className={`switch-row ${danger ? "switch-row--danger" : ""}`}><span>{label}</span><input type="checkbox" role="switch" checked={checked} onChange={(event) => onChange(event.target.checked)} /><i /></label>;
}
@@ -0,0 +1,63 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route, Routes } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { DeviceGroupPage } from "./DeviceGroupPage";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
const capabilities = {
power: true, restore: true, rgb: true, brightness: true, color_temperature: false, effect: true, speed: true,
direction: true, multiple_colors: true, per_zone: true, per_segment: false, per_led: true, profiles: true,
readable_state: true, max_leds: 40, min_brightness: 0, max_brightness: 100, min_speed: 0, max_speed: 2,
};
const modes = [
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 1, name: "Custom", flags: 544, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 9, name: "Rainbow", flags: 513, speed_min: 0, speed_max: 2, brightness: false, colors_min: 0, colors_max: 0 },
];
const modules = [0, 1, 2, 3].map((index) => ({
id: `ram-${index}`, connector_id: "openrgb-local", external_id: `ram-${index}`, fingerprint: `ram-${index}`,
source: "openrgb", device_type: "dram", owner: "openrgb", name: "Corsair Vengeance RGB Pro SL DDR4", alias: null,
vendor: "Corsair", model: "Corsair DRAM RGB Device", serial: null, location: `I2C: SMBus, address 0x5${8 + index}`,
ip_address: null, firmware_version: null, controller_index: index, capabilities: { ...capabilities, max_leds: 10 },
state: { power: true, colors: Array.from({ length: 10 }, () => ({ red: 86, green: 96, blue: 255 })), mode: "Custom", mode_index: 1 },
zones: [], modes, metadata: {}, led_count: 10, online: true, hidden: false, favorite: false, exclude_global: false,
read_only: false, blocked: false, experimental: false, room_id: null, tags: [], error_status: null,
last_detected_at: "2026-07-16T00:00:00Z", last_command_at: null,
}));
const group = {
id: "dram-family", kind: "device-family", device_type: "dram", connector_id: "openrgb-local",
name: "Corsair Vengeance RGB Pro SL DDR4", vendor: "Corsair", model: "Corsair DRAM RGB Device",
online: true, online_count: 4, module_count: 4, led_count: 40, capabilities,
state: { power: true, colors: [{ red: 86, green: 96, blue: 255 }], mode: "Custom", mode_index: 1 },
modes, mixed: false, devices: modules,
};
describe("DeviceGroupPage", () => {
it("controls the RAM family and links every physical module", async () => {
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
if (requestUrl(input).endsWith("/state") && init?.method === "POST") {
return Promise.resolve(jsonResponse({ status: "succeeded", results: modules.map(() => ({ status: "succeeded" })) }));
}
return Promise.resolve(jsonResponse(group));
}));
const user = userEvent.setup();
renderApp(<Routes><Route path="/device-groups/:groupId" element={<DeviceGroupPage />} /></Routes>, "/device-groups/dram-family");
expect(await screen.findByText("4 fysieke modules", { exact: false })).toBeInTheDocument();
expect(screen.getAllByText(/RAM-module [1-4]/)).toHaveLength(4);
const color = screen.getByRole("textbox", { name: "Kleur 1" });
await user.clear(color);
await user.type(color, "#2244aa");
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
const stateCall = await waitFor(() => {
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(call).toBeDefined();
return call;
});
expect(requestJson(stateCall?.[1])).toEqual({
state: { power: true, mode_index: 1, colors: [{ red: 34, green: 68, blue: 170 }] },
});
});
});
@@ -0,0 +1,80 @@
import { ArrowLeft, Layers3, MemoryStick, Wifi } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { DeviceGroup, DeviceState } from "../api/types";
import { DeviceCard } from "../components/DeviceCard";
import { RgbControlPanel } from "../components/RgbControlPanel";
import { useToast } from "../components/Toast";
import { Badge, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
export function DeviceGroupPage() {
const { groupId = "" } = useParams();
const queryClient = useQueryClient();
const { notify } = useToast();
const query = useQuery({
queryKey: ["device-group", groupId],
queryFn: () => api<DeviceGroup>(`/api/v1/device-groups/${groupId}`),
});
const command = useMutation({
mutationFn: (state: DeviceState) => api<{ status: string; results: Array<{ status: string }> }>(`/api/v1/device-groups/${groupId}/state`, {
method: "POST",
body: JSON.stringify({ state }),
}),
onSuccess: (result) => {
const failed = result.results.filter((item) => item.status === "failed").length;
notify(failed ? `${failed} RAM-modules konden niet worden bijgewerkt.` : "RAM-groep bijgewerkt.", failed ? "danger" : "success");
void queryClient.invalidateQueries({ queryKey: ["device-group", groupId] });
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
onError: (error) => notify(error instanceof Error ? error.message : "RAM-groepsopdracht mislukt.", "danger"),
});
if (query.isLoading) return <LoadingGrid count={3} />;
if (query.error || !query.data) return <ErrorPanel error={query.error} retry={() => void query.refetch()} />;
const group = query.data;
const disabled = group.online_count === 0 || group.devices.every((device) => device.read_only || device.blocked);
return <>
<Link className="back-link" to="/devices"><ArrowLeft size={16} /> Terug naar apparaten</Link>
<PageHeader
eyebrow="Gegroepeerd apparaat"
title={group.name}
description={`${group.vendor ?? "RAM"} · ${group.module_count} fysieke modules · ${group.led_count} leds`}
/>
<div className="detail-status">
<Badge tone={group.online ? "success" : "warning"}><Wifi size={13} /> {group.online_count}/{group.module_count} online</Badge>
<Badge tone="accent"><Layers3 size={13} /> Als groep bedienbaar</Badge>
{group.mixed ? <Badge tone="warning">Gemengde toestand</Badge> : null}
</div>
<div className="detail-grid device-group-overview">
<RgbControlPanel
targetKey={group.id}
capabilities={group.capabilities}
state={group.state}
modes={group.modes}
pending={command.isPending}
disabled={disabled}
onApply={(state) => command.mutate(state)}
/>
<div className="group-summary">
<MemoryStick size={34} />
<strong>{group.module_count} modules</strong>
<span>Groepsopdrachten worden afzonderlijk bevestigd en blijvend opgeslagen per fysieke module.</span>
</div>
</div>
<section className="section-block">
<div className="section-heading"><div><h2>Individuele RAM-modules</h2><p>Open een module om kleur, effect en overige mogelijkheden uitsluitend daarop toe te passen.</p></div></div>
<div className="grid grid--devices">
{group.devices.map((device, index) => <DeviceCard device={device} moduleLabel={`RAM-module ${index + 1} · ${moduleAddress(device.location)}`} key={device.id} />)}
</div>
</section>
</>;
}
function moduleAddress(location: string | null) {
const address = location?.match(/address (0x[0-9a-f]+)/i)?.[1];
return address?.toUpperCase() ?? "onbekend adres";
}
@@ -0,0 +1,63 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DevicesPage } from "./DevicesPage";
import { jsonResponse, renderApp, requestUrl } from "../test/render";
const devices = {
items: [
{
id: "device-1", connector_id: "openrgb-mock", external_id: "one", fingerprint: "one", source: "mock", owner: "openrgb",
name: "Aurora Mainboard", alias: null, vendor: "LumaOps Lab", model: "Virtual RGB", serial: "MOCK-1", location: "mock://one",
ip_address: null, firmware_version: "1.0", controller_index: 0, capabilities: { power: true, restore: true, rgb: true, brightness: true,
color_temperature: false, effect: true, speed: true, direction: false, multiple_colors: true, per_zone: true, per_segment: false,
per_led: true, profiles: true, readable_state: true, max_leds: 12, min_brightness: 0, max_brightness: 100, min_speed: 1, max_speed: 10 },
state: { power: true, brightness: 70, colors: [{ red: 86, green: 96, blue: 255 }], mode: "Static" }, zones: [], modes: [], led_count: 12,
online: true, hidden: false, favorite: true, exclude_global: false, read_only: false, blocked: false, experimental: false, room_id: null,
error_status: null, last_detected_at: "2026-07-14T12:00:00Z", last_command_at: null,
},
], total: 1, limit: 500, offset: 0,
};
describe("DevicesPage", () => {
beforeEach(() => {
vi.stubGlobal("matchMedia", vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})));
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL) => Promise.resolve(
jsonResponse(requestUrl(input).endsWith("/device-groups") ? [] : devices),
)));
});
it("shows and filters normalized inventory", async () => {
renderApp(<DevicesPage />, "/devices");
expect(await screen.findByText("Aurora Mainboard")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Apparaten zoeken"), "niet-bestaand");
expect(screen.getByText("Geen apparaten in deze selectie")).toBeInTheDocument();
});
it("shows identical RAM modules as one grouped device", async () => {
const module = { ...devices.items[0], device_type: "dram", name: "Corsair Vengeance RGB Pro SL DDR4", vendor: "Corsair", model: "Corsair DRAM RGB Device", metadata: {}, favorite: false, led_count: 10 };
const modules = [0, 1, 2, 3].map((index) => ({ ...module, id: `ram-${index}`, external_id: `ram-${index}`, fingerprint: `ram-${index}`, controller_index: index }));
const group = {
id: "dram-family", kind: "device-family", device_type: "dram", connector_id: "openrgb-local",
name: module.name, vendor: module.vendor, model: module.model, online: true, online_count: 4, module_count: 4,
led_count: 40, capabilities: module.capabilities, state: module.state, modes: [], mixed: false, devices: modules,
};
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL) => Promise.resolve(
jsonResponse(requestUrl(input).endsWith("/device-groups") ? [group] : { items: modules, total: 4, limit: 500, offset: 0 }),
)));
renderApp(<DevicesPage />, "/devices");
expect(await screen.findByText("Corsair Vengeance RGB Pro SL DDR4")).toBeInTheDocument();
expect(screen.getByText("4 modules · 40 leds")).toBeInTheDocument();
expect(screen.getByText("gegroepeerd RAM-apparaat", { exact: false })).toBeInTheDocument();
expect(screen.queryByText("RAM-module 1", { exact: false })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,44 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Cpu, Grid2X2, List, RefreshCw, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "../api/client";
import type { Device, DeviceGroup, Page } from "../api/types";
import { DeviceCard } from "../components/DeviceCard";
import { DeviceGroupCard } from "../components/DeviceGroupCard";
import { useToast } from "../components/Toast";
import { Button, EmptyState, ErrorPanel, Input, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
type InventoryEntry = { kind: "device"; device: Device } | { kind: "group"; group: DeviceGroup };
export function DevicesPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [search, setSearch] = useState("");
const [status, setStatus] = useState<"all" | "online" | "offline">("all");
const [view, setView] = useState<"grid" | "list">("grid");
const query = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const groups = useQuery({ queryKey: ["device-groups"], queryFn: () => api<DeviceGroup[]>("/api/v1/device-groups") });
const rescan = useMutation({ mutationFn: () => api("/api/v1/devices/rescan", { method: "POST" }), onSuccess: () => { notify("Apparaatscan afgerond."); void queryClient.invalidateQueries({ queryKey: ["devices"] }); void queryClient.invalidateQueries({ queryKey: ["device-groups"] }); }, onError: (error) => notify(error instanceof Error ? error.message : "Scan mislukt.", "danger") });
const filtered = useMemo(() => {
const groupedIds = new Set((groups.data ?? []).flatMap((group) => group.devices.map((device) => device.id)));
const entries: InventoryEntry[] = [
...(groups.data ?? []).map((group): InventoryEntry => ({ kind: "group", group })),
...(query.data?.items ?? []).filter((device) => !groupedIds.has(device.id)).map((device): InventoryEntry => ({ kind: "device", device })),
];
const searchTerm = search.toLocaleLowerCase();
return entries.filter((entry) => {
const item = entry.kind === "device" ? entry.device : entry.group;
const searchable = `${entry.kind === "device" ? entry.device.alias ?? "" : "RAM geheugen modules"} ${item.name} ${item.vendor ?? ""} ${item.model ?? ""}`.toLocaleLowerCase();
return searchable.includes(searchTerm) && (status === "all" || item.online === (status === "online"));
});
}, [groups.data, query.data, search, status]);
const error = query.error ?? groups.error;
return <>
<PageHeader eyebrow={text("Uniforme inventaris", "Unified inventory")} title={text("Apparaten", "Devices")} description={text("Lokale OpenRGB-controllers, netwerklichten en toekomstige agents met één stabiele identiteit.", "Local OpenRGB controllers, network lights and future agents with one stable identity.")} actions={<Button onClick={() => rescan.mutate()} busy={rescan.isPending}><RefreshCw size={16} /> {text("Opnieuw scannen", "Rescan")}</Button>} />
<div className="toolbar"><div className="search"><Search size={17} /><Input aria-label="Apparaten zoeken" placeholder="Zoek op naam, merk of model…" value={search} onChange={(event) => setSearch(event.target.value)} /></div><div className="segmented" aria-label="Statusfilter">{(["all", "online", "offline"] as const).map((item) => <button className={status === item ? "active" : ""} key={item} onClick={() => setStatus(item)}>{item === "all" ? "Alle" : item === "online" ? "Online" : "Offline"}</button>)}</div><div className="view-toggle"><button className={view === "grid" ? "active" : ""} aria-label="Rasterweergave" onClick={() => setView("grid")}><Grid2X2 size={17} /></button><button className={view === "list" ? "active" : ""} aria-label="Lijstweergave" onClick={() => setView("list")}><List size={17} /></button></div></div>
{query.isLoading || groups.isLoading ? <LoadingGrid count={6} /> : error ? <ErrorPanel error={error} retry={() => void Promise.all([query.refetch(), groups.refetch()])} /> : !filtered.length ? <EmptyState icon={Cpu} title="Geen apparaten in deze selectie" description="Voer een nieuwe scan uit of pas de filters aan." action={<Button onClick={() => rescan.mutate()}>Scannen</Button>} /> : <div className={view === "grid" ? "grid grid--devices" : "device-list"}>{filtered.map((entry) => entry.kind === "group" ? <DeviceGroupCard group={entry.group} list={view === "list"} key={entry.group.id} /> : <DeviceCard device={entry.device} list={view === "list"} key={entry.device.id} />)}</div>}
</>;
}
@@ -0,0 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Download, FlaskConical, RotateCcw, ShieldAlert, TerminalSquare } from "lucide-react";
import { api } from "../api/client";
import type { ComponentHealth, HealthStatus } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Card, CardHeader, PageHeader, StatusBadge } from "../components/ui";
import { useI18n } from "../lib/i18n";
interface Health { status: HealthStatus; components: Record<string, ComponentHealth>; checked_at: string }
export function DiagnosticsPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const health = useQuery({ queryKey: ["health"], queryFn: () => api<Health>("/api/v1/health"), refetchInterval: 15_000 });
const clearStop = useMutation({ mutationFn: () => api("/api/v1/commands/emergency-stop", { method: "DELETE" }), onSuccess: () => { notify("Noodstop vrijgegeven."); void queryClient.invalidateQueries(); } });
return <>
<PageHeader eyebrow={text("Herstel zonder giswerk", "Recovery without guesswork")} title={text("Diagnostiek", "Diagnostics")} description={text("Proces-, SDK-, database- en opslagstatus met geredigeerde export voor probleemonderzoek.", "Process, SDK, database and storage status with a redacted troubleshooting export.")} actions={<a className="button button--secondary" href="/api/v1/diagnostics/export"><Download size={16} /> {text("Diagnostiekspakket", "Diagnostics bundle")}</a>} />
<div className="grid grid--health">{Object.entries(health.data?.components ?? {}).map(([name, component]) => <Card key={name} className="health-card"><div className="health-card__top"><span><TerminalSquare size={19} /></span><StatusBadge status={component.status} /></div><h2>{componentName(name)}</h2><p>{component.message}</p>{component.connected !== undefined ? <Badge tone={component.connected ? "success" : "warning"}>{component.connected ? "Verbonden" : "Niet verbonden"}</Badge> : null}</Card>)}</div>
<div className="dashboard-grid"><Card><CardHeader title="Veilige herstelacties" description="Geen van deze acties wijzigt hostdrivers of firmware." /><div className="action-list"><button onClick={() => void health.refetch()}><span><RotateCcw size={18} /></span><div><strong>Health opnieuw controleren</strong><p>Ververs processen, SDK en opslagstatus.</p></div></button><button onClick={() => clearStop.mutate()}><span><ShieldAlert size={18} /></span><div><strong>Noodstop vrijgeven</strong><p>Sta nieuwe gevalideerde hardwareopdrachten toe.</p></div></button><a href="/api/v1/diagnostics/export"><span><FlaskConical size={18} /></span><div><strong>Geredigeerde bundle exporteren</strong><p>Zonder tokens, secrets of volledige environment dump.</p></div></a></div></Card>
<Card><CardHeader title="Veiligheidsgrenzen" /><ul className="check-list"><li><CheckCircle2 size={16} /> SDK uitsluitend op 127.0.0.1:6742</li><li><CheckCircle2 size={16} /> Per-device locks en rate limiting</li><li><CheckCircle2 size={16} /> Identiteitscontrole vóór iedere write</li><li><CheckCircle2 size={16} /> Secrets uit logs en bundles verwijderd</li><li><CheckCircle2 size={16} /> Inventory zonder schrijfopdrachten</li></ul></Card></div>
</>;
}
function componentName(name: string): string { return ({ database: "SQLite-database", storage: "Persistente opslag", openrgb_process: "OpenRGB-proces", "openrgb-local": "OpenRGB SDK", "openrgb-mock": "Mockadapter" } as Record<string, string>)[name] ?? name; }
@@ -0,0 +1,23 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Compass, Radar, RefreshCw, Usb } from "lucide-react";
import { api } from "../api/client";
import type { Page } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
interface DiscoveryRun { id: string; status: string; found_count: number; inaccessible_count: number; started_at: string; finished_at: string | null }
export function DiscoveryPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const history = useQuery({ queryKey: ["discovery"], queryFn: () => api<Page<DiscoveryRun>>("/api/v1/discovery?limit=50") });
const discover = useMutation({ mutationFn: () => api<{ found_count: number }>("/api/v1/discovery", { method: "POST" }), onSuccess: (result) => { notify(`${result.found_count} apparaten gesynchroniseerd.`); void queryClient.invalidateQueries({ queryKey: ["discovery"] }); void queryClient.invalidateQueries({ queryKey: ["devices"] }); }, onError: (error) => notify(error instanceof Error ? error.message : "Discovery mislukt.", "danger") });
return <>
<PageHeader eyebrow={text("Alleen-lezen-inventaris", "Read-only inventory")} title="Discovery" description={text("Zoek apparaten zonder impliciete schrijfopdrachten of agressieve SMBus-probes.", "Find devices without implicit writes or aggressive SMBus probes.")} actions={<Button onClick={() => discover.mutate()} busy={discover.isPending}><Radar size={16} /> {text("Discovery starten", "Start discovery")}</Button>} />
<div className="grid grid--stats"><Card className="discovery-mode"><span><Usb size={21} /></span><div><strong>USB & hidraw</strong><p>Via expliciete device mapping</p></div></Card><Card className="discovery-mode"><span><Compass size={21} /></span><div><strong>Netwerk</strong><p>Broadcast/multicast indien ingeschakeld</p></div></Card><Card className="discovery-mode"><span><RefreshCw size={21} /></span><div><strong>SDK rescan</strong><p>OpenRGB protocol 5</p></div></Card></div>
<Card><CardHeader title="Scanhistoriek" description="Resultaten en niet-toegankelijke doelen blijven controleerbaar." />{history.data?.items.length ? <div className="table-wrap"><table><thead><tr><th>Gestart</th><th>Status</th><th>Gevonden</th><th>Niet toegankelijk</th><th>Duur</th></tr></thead><tbody>{history.data.items.map((run) => <tr key={run.id}><td>{formatDate(run.started_at)}</td><td><Badge tone={run.status === "completed" ? "success" : run.status === "failed" ? "danger" : "warning"}>{run.status}</Badge></td><td>{run.found_count}</td><td>{run.inaccessible_count}</td><td>{run.finished_at ? `${Math.max(0, (Date.parse(run.finished_at) - Date.parse(run.started_at)) / 1000).toFixed(1)} s` : "—"}</td></tr>)}</tbody></table></div> : <EmptyState icon={Radar} title="Nog geen scans" description="Start discovery om de lokale OpenRGB-inventory te synchroniseren." />}</Card>
</>;
}
@@ -0,0 +1,27 @@
import { useQuery } from "@tanstack/react-query";
import { Globe2, Network, RadioTower, ShieldCheck } from "lucide-react";
import { api } from "../api/client";
import type { Device, Page } from "../api/types";
import { Badge, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
const protocols = [
["DDP", "UDP 4048", "Handmatig"], ["E1.31 / sACN", "UDP 5568", "Unicast / multicast"],
["Philips Hue", "HTTP + entertainment", "Bridge discovery"], ["Philips WiZ", "UDP 38899", "LAN discovery"],
["Nanoleaf", "HTTP + UDP", "Discovery / token"], ["LIFX", "UDP 56700", "LAN discovery"],
["Govee", "UDP 40014003", "Multicast"], ["Kasa", "TCP 9999", "LAN"],
["Yeelight", "TCP 55443", "LAN discovery"], ["Espurna", "HTTP/TCP", "Handmatig + API-key"],
["Elgato", "HTTP 9123", "mDNS / LAN"],
];
export function NetworkPage() {
const { text } = useI18n();
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const networkDevices = devices.data?.items.filter((device) => device.ip_address || device.source !== "openrgb") ?? [];
return <>
<PageHeader eyebrow={text("LAN-verlichting", "LAN lighting")} title={text("Netwerkapparaten", "Network devices")} description={text("OpenRGB-netwerkcontrollers en toekomstige native connectorapparaten met expliciet eigenaarschap.", "OpenRGB network controllers and future native connector devices with explicit ownership.")} />
<div className="alert alert--info"><ShieldCheck size={20} /><div><strong>Dubbele aansturing wordt voorkomen</strong><p>Kies per fysiek apparaat één eigenaar: OpenRGB, native connector, Home Assistant of onbeheerd.</p></div></div>
<section className="section-block"><div className="section-heading"><div><h2>Gevonden netwerkapparaten</h2><p>Apparaten met een IP-adres of externe connectorbron.</p></div></div>{networkDevices.length ? <div className="grid grid--cards">{networkDevices.map((device) => <Card key={device.id} className="network-device"><span><Globe2 size={20} /></span><div><h3>{device.alias || device.name}</h3><p>{device.ip_address || device.source}</p></div><Badge tone={device.online ? "success" : "neutral"}>{device.owner}</Badge></Card>)}</div> : <EmptyState icon={Network} title="Nog geen netwerkapparaten" description="Activeer discovery of voeg een ondersteund doel handmatig toe via OpenRGB." />}</section>
<Card><CardHeader title="Ondersteund door OpenRGB 1.0rc3" description="Discovery kan host networking nodig hebben; de SDK blijft altijd loopback-only." /><div className="table-wrap"><table><thead><tr><th>Familie</th><th>Transport</th><th>Toevoegen</th></tr></thead><tbody>{protocols.map(([name, transport, discovery]) => <tr key={name}><td><RadioTower size={15} /> {name}</td><td>{transport}</td><td>{discovery}</td></tr>)}</tbody></table></div></Card>
</>;
}
@@ -0,0 +1,27 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { OverviewPage } from "./OverviewPage";
import { jsonResponse, renderApp } from "../test/render";
describe("OverviewPage command flow", () => {
it("sends a quick color through the versioned API", async () => {
const stateBodies: string[] = [];
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
if (url.includes("/api/v1/system")) return Promise.resolve(jsonResponse({ name: "LumaOps", version: "0.1.0", openrgb_version: "1.0rc3", sdk_protocol: 5,
environment: "test", mock_mode: true, health: { status: "healthy", checked_at: "2026-07-14T12:00:00Z", components: { "openrgb-mock": { status: "healthy", message: "ok", connected: true } } },
devices: { total: 1, online: 1, offline: 0 }, active_scene: null, recent_commands: [], warnings: [], emergency_stop: false }));
if (url.includes("/api/v1/devices") && (init?.method ?? "GET") === "GET") return Promise.resolve(jsonResponse({ items: [{ id: "device-1", capabilities: { rgb: true }, read_only: false, blocked: false }], total: 1, limit: 500, offset: 0 }));
if (url.includes("/api/v1/scenes")) return Promise.resolve(jsonResponse({ items: [], total: 0, limit: 6, offset: 0 }));
if (url.includes("/state")) { if (typeof init?.body === "string") stateBodies.push(init.body); return Promise.resolve(jsonResponse({ id: "command-1", status: "succeeded" })); }
return Promise.resolve(jsonResponse({}));
});
vi.stubGlobal("fetch", fetchMock);
renderApp(<OverviewPage />);
await screen.findByText("Snelle bediening");
await userEvent.click(screen.getByRole("button", { name: /Kleur toepassen/i }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/api/v1/devices/device-1/state"), expect.objectContaining({ method: "POST" })));
expect(stateBodies[0]).toContain('"colors"');
});
});
@@ -0,0 +1,97 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, Cable, CirclePower, Cpu, Lightbulb, ShieldAlert, Sparkles, Wifi } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { api, mutationId } from "../api/client";
import type { Device, Page, Scene, SystemStatus } from "../api/types";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, ErrorPanel, LoadingGrid, PageHeader, Stat, StatusBadge } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate, hexToColor } from "../lib/utils";
export function OverviewPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [color, setColor] = useState("#5660ff");
const [confirmEmergency, setConfirmEmergency] = useState(false);
const system = useQuery({ queryKey: ["system"], queryFn: () => api<SystemStatus>("/api/v1/system") });
const devices = useQuery({ queryKey: ["devices", "online"], queryFn: () => api<Page<Device>>("/api/v1/devices?online=true&limit=500") });
const scenes = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=6") });
const quickColor = useMutation({
mutationFn: async () => {
const targets = devices.data?.items.filter((device) => device.capabilities.rgb && !device.read_only && !device.blocked) ?? [];
return Promise.allSettled(targets.map((device) => api(`/api/v1/devices/${device.id}/state`, {
method: "POST",
headers: { "Idempotency-Key": mutationId() },
body: JSON.stringify({ state: { power: true, colors: [hexToColor(color)] } }),
})));
},
onSuccess: (results) => {
const failed = results.filter((result) => result.status === "rejected").length;
notify(failed ? `${results.length - failed} apparaten bijgewerkt; ${failed} mislukt.` : "Kleur toegepast op alle geschikte apparaten.", failed ? "danger" : "success");
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
});
const allOff = useMutation({
mutationFn: (emergency: boolean) => api(`/api/v1/commands/${emergency ? "emergency-stop" : "all-off"}`, { method: "POST" }),
onSuccess: (_, emergency) => { notify(emergency ? "Noodstop geactiveerd." : "Alles-uitopdracht voltooid."); void queryClient.invalidateQueries(); },
});
const applyScene = useMutation({
mutationFn: (id: string) => api(`/api/v1/scenes/${id}/apply`, { method: "POST", body: JSON.stringify({ rollback_on_failure: true }) }),
onSuccess: () => { notify("Scène toegepast."); void queryClient.invalidateQueries(); },
onError: (error) => notify(error instanceof Error ? error.message : "Scène mislukt.", "danger"),
});
if (system.isLoading) return <><PageHeader title={text("Overzicht", "Overview")} /><LoadingGrid /></>;
if (system.error || !system.data) return <ErrorPanel error={system.error} retry={() => void system.refetch()} />;
const status = system.data;
const openrgb = status.health.components["openrgb-local"] ?? status.health.components["openrgb-mock"];
return (
<>
<PageHeader eyebrow={text("Lokaal controlecentrum", "Local control center")} title={text("Goedenavond, Jens", "Good evening, Jens")} description={text("Je verlichting en OpenRGB-hardware in één rustig overzicht.", "Your lighting and OpenRGB hardware in one calm overview.")} actions={<><Button variant="secondary" onClick={() => allOff.mutate(false)} busy={allOff.isPending}><CirclePower size={16} /> {text("Alles uit", "All off")}</Button><Button variant="danger" onClick={() => setConfirmEmergency(true)}><ShieldAlert size={16} /> {text("Noodstop", "Emergency stop")}</Button></>} />
{status.emergency_stop ? <div className="alert alert--danger"><ShieldAlert size={20} /><div><strong>Globale noodstop actief</strong><p>Nieuwe hardwareopdrachten worden geblokkeerd totdat je de noodstop in Diagnostiek vrijgeeft.</p></div></div> : null}
<section className="grid grid--stats" aria-label="Systeemstatistieken">
<Stat label="Systeemstatus" value={<StatusBadge status={status.health.status} />} detail={`Gecontroleerd ${formatDate(status.health.checked_at)}`} icon={Activity} />
<Stat label="Apparaten online" value={`${status.devices.online} / ${status.devices.total}`} detail={`${status.devices.offline} offline`} icon={Cpu} />
<Stat label="OpenRGB Core" value={openrgb ? <StatusBadge status={openrgb.status} /> : "—"} detail={`1.0rc3 · SDK ${status.sdk_protocol}`} icon={Lightbulb} />
<Stat label="Connectoren" value={Object.keys(status.health.components).filter((key) => key.startsWith("openrgb")).length} detail="Actieve beheerkanalen" icon={Cable} />
</section>
<section className="dashboard-grid">
<Card className="quick-control">
<CardHeader title="Snelle bediening" description="Veilige statische kleur op alle geschikte apparaten." />
<div className="color-control">
<input aria-label="Snelle kleur" type="color" value={color} onChange={(event) => setColor(event.target.value)} />
<div><strong>{color.toUpperCase()}</strong><span>{devices.data?.items.length ?? 0} online doelen</span></div>
<Button onClick={() => quickColor.mutate()} busy={quickColor.isPending}><Sparkles size={16} /> Kleur toepassen</Button>
</div>
<div className="color-swatches" aria-label="Kleursuggesties">
{["#5660ff", "#00c2a8", "#ff7a59", "#f5c451", "#f7f7ff"].map((item) => <button key={item} aria-label={`Kies ${item}`} className={color === item ? "selected" : ""} style={{ background: item }} onClick={() => setColor(item)} />)}
</div>
</Card>
<Card>
<CardHeader title="Actieve scène" description="De laatst succesvol toegepaste LumaOps-scène." />
{status.active_scene ? <div className="active-scene"><span className="scene-orb" /><div><strong>{status.active_scene.name}</strong><span>{formatDate(status.active_scene.last_applied_at)}</span></div><Badge tone="accent">Actief</Badge></div> : <p className="muted">Nog geen scène toegepast.</p>}
</Card>
</section>
<section className="section-block">
<div className="section-heading"><div><h2>Laatst gebruikte scènes</h2><p>Start een sfeer zonder de pagina te verlaten.</p></div><Link className="text-link" to="/scenes">Alle scènes</Link></div>
<div className="grid grid--cards">
{scenes.data?.items.map((scene, index) => <Card key={scene.id} className="scene-card"><div className={`scene-gradient scene-gradient--${index % 4}`}><Sparkles size={20} /></div><div className="scene-card__body"><div><h3>{scene.name}</h3><p>{scene.item_count ?? 0} onderdelen · v{scene.version}</p></div><Button variant="secondary" onClick={() => applyScene.mutate(scene.id)} busy={applyScene.isPending}>Start</Button></div></Card>)}
{!scenes.isLoading && !scenes.data?.items.length ? <Card><p className="muted">Leg je huidige toestand vast als eerste scène.</p></Card> : null}
</div>
</section>
<section className="dashboard-grid">
<Card><CardHeader title="Recente opdrachten" description="Laatste wijzigingen via LumaOps." action={<Link to="/activity" className="text-link">Alles bekijken</Link>} />
<div className="timeline">{status.recent_commands.slice(0, 5).map((command) => <div className="timeline__item" key={command.id}><span className={`timeline__dot timeline__dot--${command.status}`} /><div><strong>{command.action === "set_state" ? "Apparaattoestand gewijzigd" : command.action}</strong><span>{formatDate(command.created_at)}</span></div><Badge tone={command.status === "succeeded" ? "success" : command.status === "failed" ? "danger" : "neutral"}>{command.status}</Badge></div>)}{!status.recent_commands.length ? <p className="muted">Nog geen opdrachten uitgevoerd.</p> : null}</div>
</Card>
<Card><CardHeader title="Waarschuwingen" description="Aandachtspunten met concrete herstelacties." action={<Wifi size={18} />} />
{status.warnings.length ? status.warnings.map((warning) => <div className="warning-row" key={warning.id}><span /><div><strong>{warning.title}</strong><p>{warning.message}</p></div></div>) : <div className="all-clear"><span><Lightbulb size={20} /></span><div><strong>Alles rustig</strong><p>Er zijn geen open waarschuwingen.</p></div></div>}
</Card>
</section>
<ConfirmDialog open={confirmEmergency} title="Globale noodstop activeren?" description="LumaOps probeert alle geschikte apparaten één keer uit te schakelen en blokkeert daarna nieuwe hardwareopdrachten." confirmLabel="Noodstop activeren" danger onClose={() => setConfirmEmergency(false)} onConfirm={() => { setConfirmEmergency(false); allOff.mutate(true); }} />
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, Copy, Download, Eye, Pencil, Play, Plus, Sparkles, Trash2, Upload } from "lucide-react";
import { useRef, useState, type ChangeEvent, type FormEvent } from "react";
import { api } from "../api/client";
import type { Page, Scene, SceneApplyResult } from "../api/types";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { SceneEditor } from "../components/SceneEditor";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, EmptyState, ErrorPanel, Field, Input, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
interface ScenePreview {
scene: Scene;
deviceCount: number;
}
export function ScenesPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [capture, setCapture] = useState(false);
const [createEmpty, setCreateEmpty] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteScene, setDeleteScene] = useState<Scene | null>(null);
const [preview, setPreview] = useState<ScenePreview | null>(null);
const importInput = useRef<HTMLInputElement>(null);
const query = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=200") });
const refresh = () => void queryClient.invalidateQueries({ queryKey: ["scenes"] });
const captureMutation = useMutation({ mutationFn: (name: string) => api<Scene>("/api/v1/scenes/capture", { method: "POST", body: JSON.stringify({ name }) }), onSuccess: () => { notify(text("Huidige toestand als scène vastgelegd.", "Current state captured as a scene.")); setCapture(false); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Vastleggen mislukt.", "Capture failed."), "danger") });
const createMutation = useMutation({ mutationFn: (name: string) => api<Scene>("/api/v1/scenes", { method: "POST", body: JSON.stringify({ name, items: [] }) }), onSuccess: (scene) => { notify(text("Lege scène gemaakt.", "Empty scene created.")); setCreateEmpty(false); setEditingId(scene.id); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Aanmaken mislukt.", "Create failed."), "danger") });
const duplicate = useMutation({ mutationFn: (id: string) => api<Scene>(`/api/v1/scenes/${id}/duplicate`, { method: "POST", body: JSON.stringify({}) }), onSuccess: () => { notify(text("Scène gedupliceerd.", "Scene duplicated.")); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Dupliceren mislukt.", "Duplicate failed."), "danger") });
const importScene = useMutation({ mutationFn: (payload: unknown) => api<Scene>("/api/v1/scenes/import", { method: "POST", body: JSON.stringify(payload) }), onSuccess: () => { notify(text("Scène geïmporteerd.", "Scene imported.")); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Importeren mislukt.", "Import failed."), "danger") });
const apply = useMutation({
mutationFn: (id: string) => api<SceneApplyResult>(`/api/v1/scenes/${id}/apply`, { method: "POST", body: JSON.stringify({ rollback_on_failure: true }) }),
onSuccess: (result) => {
const summary = text(`${result.applied.length} toegepast, ${result.skipped.length} overgeslagen, ${result.failed.length} mislukt.`, `${result.applied.length} applied, ${result.skipped.length} skipped, ${result.failed.length} failed.`);
notify(summary, result.status === "succeeded" ? "success" : "danger");
setPreview(null);
void queryClient.invalidateQueries();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Scène mislukt.", "Scene failed."), "danger"),
});
const inspect = useMutation({
mutationFn: async (scene: Scene) => ({ scene, result: await api<{ device_count: number }>(`/api/v1/scenes/${scene.id}/preview`) }),
onSuccess: ({ scene, result }) => setPreview({ scene, deviceCount: result.device_count }),
onError: (error) => notify(error instanceof Error ? error.message : text("Preview mislukt.", "Preview failed."), "danger"),
});
const remove = useMutation({
mutationFn: (id: string) => api(`/api/v1/scenes/${id}`, { method: "DELETE" }),
onSuccess: () => { notify(text("Scène verwijderd.", "Scene deleted.")); setDeleteScene(null); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
});
const submit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const name = new FormData(event.currentTarget).get("name"); if (typeof name === "string") captureMutation.mutate(name); };
const submitEmpty = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const name = new FormData(event.currentTarget).get("name"); if (typeof name === "string") createMutation.mutate(name); };
const onImport = async (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; try { importScene.mutate(JSON.parse(await file.text()) as unknown); } catch { notify(text("Dit bestand bevat geen geldige JSON.", "This file does not contain valid JSON."), "danger"); } };
return <>
<PageHeader eyebrow={text("LumaOps-scènelaag", "LumaOps scene layer")} title={text("Scènes", "Scenes")} description={text("Leg toestanden over apparaten en groepen vast met preview, rapportage en best-effort rollback.", "Capture state across devices and groups with preview, reporting, and best-effort rollback.")} actions={<><input ref={importInput} className="visually-hidden" type="file" accept="application/json,.json" onChange={(event) => void onImport(event)} aria-label={text("Scènebestand importeren", "Import scene file")} /><Button variant="ghost" onClick={() => importInput.current?.click()} busy={importScene.isPending}><Upload size={16} /> {text("Importeren", "Import")}</Button><Button variant="secondary" onClick={() => setCreateEmpty(true)}><Plus size={16} /> {text("Lege scène", "Empty scene")}</Button><Button onClick={() => setCapture(true)}><Camera size={16} /> {text("Huidige toestand vastleggen", "Capture current state")}</Button></>} />
{editingId ? <SceneEditor sceneId={editingId} onClose={() => setEditingId(null)} /> : null}
{capture ? <Card className="inline-form"><form onSubmit={submit}><Field label={text("Naam voor de scène", "Scene name")}><Input required name="name" autoFocus placeholder={text("Bijvoorbeeld Avondrust", "For example Evening calm")} /></Field><div className="form-actions"><Button type="button" variant="ghost" onClick={() => setCapture(false)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={captureMutation.isPending}>{text("Vastleggen", "Capture")}</Button></div></form></Card> : null}
{createEmpty ? <Card className="inline-form"><form onSubmit={submitEmpty}><Field label={text("Naam voor de lege scène", "Empty scene name")}><Input required name="name" autoFocus placeholder={text("Bijvoorbeeld Filmavond", "For example Movie night")} /></Field><div className="form-actions"><Button type="button" variant="ghost" onClick={() => setCreateEmpty(false)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={createMutation.isPending}>{text("Maken", "Create")}</Button></div></form></Card> : null}
{query.isLoading ? <LoadingGrid /> : query.error ? <ErrorPanel error={query.error} retry={() => void query.refetch()} /> : !query.data?.items.length ? <EmptyState icon={Sparkles} title={text("Nog geen scènes", "No scenes yet")} description={text("Leg de huidige toestand van je online apparaten vast als eerste scène.", "Capture the current state of your online devices as your first scene.")} action={<Button onClick={() => setCapture(true)}>{text("Toestand vastleggen", "Capture state")}</Button>} /> : <div className="grid grid--scenes">{query.data.items.map((scene, index) => <Card key={scene.id} className="scene-tile"><div className={`scene-tile__visual scene-gradient--${index % 4}`}><Sparkles size={28} /><Badge tone="accent">v{scene.version}</Badge></div><div className="scene-tile__content"><div><h2>{scene.name}</h2><p>{scene.description || text(`${scene.item_count ?? 0} doeltoestanden`, `${scene.item_count ?? 0} target states`)}</p></div><dl><div><dt>{text("Laatst toegepast", "Last applied")}</dt><dd>{formatDate(scene.last_applied_at)}</dd></div><div><dt>{text("Rollback", "Rollback")}</dt><dd>{text("Ingeschakeld", "Enabled")}</dd></div></dl><div className="scene-tile__actions"><Button onClick={() => apply.mutate(scene.id)} busy={apply.isPending}><Play size={15} /> {text("Start", "Run")}</Button><Button variant="ghost" title={text("Preview", "Preview")} aria-label={text(`Preview van ${scene.name}`, `Preview ${scene.name}`)} busy={inspect.isPending} onClick={() => inspect.mutate(scene)}><Eye size={16} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} aria-label={text(`${scene.name} bewerken`, `Edit ${scene.name}`)} onClick={() => setEditingId(scene.id)}><Pencil size={16} /></Button><Button variant="ghost" title={text("Dupliceren", "Duplicate")} aria-label={text(`${scene.name} dupliceren`, `Duplicate ${scene.name}`)} onClick={() => duplicate.mutate(scene.id)} busy={duplicate.isPending}><Copy size={16} /></Button><a className="button button--ghost" href={`/api/v1/scenes/${scene.id}/export`} title={text("Exporteren", "Export")} aria-label={text("Scène exporteren", "Export scene")}><Download size={16} /></a><Button variant="ghost" title={text("Verwijderen", "Delete")} aria-label={text(`${scene.name} verwijderen`, `Delete ${scene.name}`)} onClick={() => setDeleteScene(scene)}><Trash2 size={16} /></Button></div></div></Card>)}</div>}
<ConfirmDialog open={Boolean(deleteScene)} title={text("Scène verwijderen?", "Delete scene?")} description={text(`${deleteScene?.name ?? ""}” en alle doeltoestanden worden verwijderd.`, `${deleteScene?.name ?? ""}” and all target states will be deleted.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteScene(null)} onConfirm={() => deleteScene && remove.mutate(deleteScene.id)} />
<ConfirmDialog open={Boolean(preview)} title={text("Scènepreview", "Scene preview")} description={text(`${preview?.scene.name ?? ""}” stuurt opdrachten naar ${preview?.deviceCount ?? 0} unieke apparaten. Bij een vereiste fout worden reeds toegepaste opdrachten teruggedraaid.`, `${preview?.scene.name ?? ""}” sends commands to ${preview?.deviceCount ?? 0} unique devices. On a required failure, applied commands are rolled back.`)} confirmLabel={text("Nu toepassen", "Apply now")} busy={apply.isPending} onClose={() => setPreview(null)} onConfirm={() => preview && apply.mutate(preview.scene.id)} />
</>;
}
@@ -0,0 +1,19 @@
import { Languages, LockKeyhole, Moon, Network, Save, Sun, SunMoon } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { api } from "../api/client";
import { useToast } from "../components/Toast";
import { Button, Card, CardHeader, Field, PageHeader, Select } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { useTheme, type Theme } from "../lib/theme";
export function SettingsPage() {
const { language, setLanguage, text } = useI18n();
const { theme, setTheme } = useTheme();
const { notify } = useToast();
const save = useMutation({ mutationFn: ({ key, value }: { key: string; value: unknown }) => api(`/api/v1/settings/${key}`, { method: "PUT", body: JSON.stringify({ value }) }), onSuccess: () => notify("Instelling opgeslagen.") });
return <>
<PageHeader eyebrow={text("Persoonlijke voorkeuren", "Personal preferences")} title={text("Instellingen", "Settings")} description={text("Interfacevoorkeuren worden lokaal toegepast; deploymentkritische waarden blijven in de containeromgeving.", "Interface preferences apply locally; deployment-critical values remain in the container environment.")} actions={<Button onClick={() => { save.mutate({ key: "language", value: language }); save.mutate({ key: "theme", value: theme }); }} busy={save.isPending}><Save size={16} /> {text("Opslaan", "Save")}</Button>} />
<div className="settings-grid"><Card><CardHeader title="Weergave" description="Taal en thema worden ook in deze browser onthouden." /><div className="form-stack"><Field label="Taal"><div className="setting-select"><Languages size={18} /><Select value={language} onChange={(event) => setLanguage(event.target.value === "en" ? "en" : "nl")}><option value="nl">Nederlands</option><option value="en">English</option></Select></div></Field><Field label="Thema"><div className="theme-options">{(["light", "dark", "system"] as Theme[]).map((item) => <button className={theme === item ? "active" : ""} onClick={() => setTheme(item)} key={item}>{item === "light" ? <Sun size={18} /> : item === "dark" ? <Moon size={18} /> : <SunMoon size={18} />}<span>{item === "light" ? "Licht" : item === "dark" ? "Donker" : "Systeem"}</span></button>)}</div></Field></div></Card>
<Card><CardHeader title="Netwerk & beveiliging" description="Alleen informatief; wijzig deze invarianten via Unraid-containerinstellingen." /><dl className="properties"><div><dt><Network size={15} /> Webinterface</dt><dd>0.0.0.0 · configureerbare APP_PORT</dd></div><div><dt><LockKeyhole size={15} /> OpenRGB SDK</dt><dd>127.0.0.1:6742 · niet gepubliceerd</dd></div><div><dt><LockKeyhole size={15} /> Externe toegang</dt><dd>Standaard uit</dd></div></dl><p className="mini-warning">Forwarded headers worden alleen van expliciet vertrouwde proxy-adressen geaccepteerd.</p></Card></div>
</>;
}
@@ -0,0 +1,23 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, renderApp } from "../test/render";
import { SetupPage } from "./SetupPage";
describe("SetupPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({
completed: false,
current_step: "welcome",
report: {},
})));
});
it("handles a fresh empty persisted report before the first inspection", async () => {
const user = userEvent.setup();
renderApp(<SetupPage />, "/setup");
await user.click(await screen.findByRole("button", { name: "Configuratie starten" }));
expect(await screen.findByRole("heading", { name: "Opslagrechten" })).toBeInTheDocument();
expect(screen.getByText("Voer de systeemcontrole uit om mountrechten te testen.")).toBeInTheDocument();
});
});
+83
View File
@@ -0,0 +1,83 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ChevronRight, CircleCheck, Cpu, Database, HardDrive, Lightbulb, Network, ScanSearch, Server, ShieldCheck, Usb, Wrench } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { ComponentHealth, SetupState } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Button, EmptyState, ErrorPanel, StatusBadge } from "../components/ui";
interface PathStatus { path: string; exists: boolean; readable: boolean; writable: boolean }
interface NodeStatus { path: string; readable: boolean; writable: boolean }
interface Recovery { area: string; message: string }
interface SetupReport {
storage: PathStatus[];
openrgb: ComponentHealth | null;
usb_devices: NodeStatus[];
i2c_devices: NodeStatus[];
serial_devices: NodeStatus[];
recovery: Recovery[];
checked_at: string;
openrgb_inventory?: Record<string, unknown>;
}
const steps = [
["Welkom", Lightbulb], ["Opslagrechten", HardDrive], ["OpenRGB-proces", Server], ["SDK-verbinding", Network],
["USB-inventory", Usb], ["I²C-inventory", Cpu], ["OpenRGB-detectie", ScanSearch], ["Toegankelijkheid", ShieldCheck],
["Netwerkdiscovery", Network], ["Appdata & back-up", Database], ["Systeemrapport", CircleCheck],
] as const;
export function SetupPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { notify } = useToast();
const [active, setActive] = useState(0);
const [networkDiscovery, setNetworkDiscovery] = useState(false);
const [appdata, setAppdata] = useState(false);
const [backup, setBackup] = useState(false);
const state = useQuery({ queryKey: ["setup"], queryFn: () => api<SetupState>("/api/v1/setup") });
const inspect = useMutation({ mutationFn: () => api<SetupReport>(`/api/v1/setup/inspect?include_network=${networkDiscovery}`, { method: "POST" }), onSuccess: () => { notify("Systeemcontrole afgerond."); setActive(10); }, onError: (error) => notify(error instanceof Error ? error.message : "Controle mislukt.", "danger") });
const complete = useMutation({ mutationFn: () => api<SetupState>("/api/v1/setup/complete", { method: "POST", body: JSON.stringify({ appdata_confirmed: appdata, backup_location_confirmed: backup }) }), onSuccess: (result) => { queryClient.setQueryData(["setup"], result); notify("LumaOps is klaar voor gebruik."); void navigate("/", { replace: true }); }, onError: (error) => notify(error instanceof Error ? error.message : "Setup afronden mislukt.", "danger") });
const report = normalizeSetupReport(inspect.data ?? state.data?.report);
if (state.error) return <ErrorPanel error={state.error} retry={() => void state.refetch()} />;
return <div className="setup-shell">
<aside className="setup-sidebar"><div className="brand"><span className="brand__mark"><Lightbulb size={20} /></span><div className="brand__text"><strong>LumaOps</strong><span>Eerste configuratie</span></div></div><ol>{steps.map(([label, Icon], index) => <li key={label} className={active === index ? "active" : index < active ? "complete" : ""}><button onClick={() => setActive(index)}><span>{index < active ? <Check size={15} /> : <Icon size={16} />}</span><div><small>Stap {index + 1}</small><strong>{label}</strong></div></button></li>)}</ol></aside>
<main className="setup-main">
<div className="setup-progress"><span>Stap {active + 1} van {steps.length}</span><div><i style={{ width: `${((active + 1) / steps.length) * 100}%` }} /></div></div>
{active === 0 ? <Welcome onNext={() => setActive(1)} /> : null}
{active >= 1 && active <= 8 ? <CheckStep index={active} report={report} networkDiscovery={networkDiscovery} setNetworkDiscovery={setNetworkDiscovery} busy={inspect.isPending} onInspect={() => inspect.mutate()} onPrevious={() => setActive((value) => Math.max(0, value - 1))} onNext={() => setActive((value) => Math.min(10, value + 1))} /> : null}
{active === 9 ? <StorageConfirm appdata={appdata} backup={backup} onAppdata={setAppdata} onBackup={setBackup} onPrevious={() => setActive(8)} onNext={() => setActive(10)} /> : null}
{active === 10 ? <Report report={report} appdata={appdata} backup={backup} busy={complete.isPending} onInspect={() => inspect.mutate()} onComplete={() => complete.mutate()} /> : null}
</main>
</div>;
}
function normalizeSetupReport(value: unknown): SetupReport | undefined {
if (!value || typeof value !== "object") return undefined;
const candidate = value as Partial<SetupReport>;
return {
storage: Array.isArray(candidate.storage) ? candidate.storage : [],
openrgb: candidate.openrgb ?? null,
usb_devices: Array.isArray(candidate.usb_devices) ? candidate.usb_devices : [],
i2c_devices: Array.isArray(candidate.i2c_devices) ? candidate.i2c_devices : [],
serial_devices: Array.isArray(candidate.serial_devices) ? candidate.serial_devices : [],
recovery: Array.isArray(candidate.recovery) ? candidate.recovery : [],
checked_at: typeof candidate.checked_at === "string" ? candidate.checked_at : "",
openrgb_inventory: candidate.openrgb_inventory,
};
}
function Welcome({ onNext }: { onNext: () => void }) { return <div className="setup-panel setup-welcome"><span className="setup-hero-icon"><Lightbulb size={34} /></span><Badge tone="accent">OpenRGB 1.0rc3 · SDK 5</Badge><h1>Welkom bij LumaOps</h1><p>We controleren eerst veilig je container, opslag en OpenRGB-verbinding. De wizard voert geen wijzigende hardwareopdrachten uit.</p><div className="setup-promises"><div><ShieldCheck size={19} /><span><strong>Loopback-only SDK</strong>Poort 6742 blijft intern.</span></div><div><Wrench size={19} /><span><strong>Concrete herstelstappen</strong>Geen vage foutmeldingen.</span></div><div><Database size={19} /><span><strong>Persistente appdata</strong>Back-up vóór risicovolle wijzigingen.</span></div></div><Button onClick={onNext}>Configuratie starten <ChevronRight size={16} /></Button></div>; }
function CheckStep({ index, report, networkDiscovery, setNetworkDiscovery, busy, onInspect, onPrevious, onNext }: { index: number; report?: SetupReport; networkDiscovery: boolean; setNetworkDiscovery: (value: boolean) => void; busy: boolean; onInspect: () => void; onPrevious: () => void; onNext: () => void }) {
const [title, Icon] = steps[index]!;
const contents = index === 1 ? <StatusList items={report?.storage.map((item) => ({ name: item.path, ok: item.writable, detail: item.writable ? "Schrijfbaar" : "Niet schrijfbaar" })) ?? []} empty="Voer de systeemcontrole uit om mountrechten te testen." /> : index === 2 || index === 3 ? report?.openrgb ? <div className="setup-health"><StatusBadge status={report.openrgb.status} /><strong>{report.openrgb.message}</strong><p>De SDK moet exact protocol 5 rapporteren en op loopback bereikbaar zijn.</p></div> : <EmptyState icon={Server} title="Nog niet gecontroleerd" description="Start de veilige systeemcontrole." /> : index === 4 ? <NodeList title="USB-nodes" nodes={report?.usb_devices ?? []} /> : index === 5 ? <NodeList title="I²C-nodes" nodes={report?.i2c_devices ?? []} /> : index === 6 ? <StatusList items={report?.openrgb_inventory ? Object.entries(report.openrgb_inventory).map(([name, value]) => ({ name, ok: (value as { status?: string }).status === "completed", detail: JSON.stringify(value) })) : []} empty="Na de SDK-test wordt de OpenRGB-inventory read-only gesynchroniseerd." /> : index === 7 ? <div>{report?.recovery.length ? report.recovery.map((item) => <div className="recovery" key={`${item.area}-${item.message}`}><Wrench size={17} /><div><strong>{item.area}</strong><p>{item.message}</p></div></div>) : <EmptyState icon={ShieldCheck} title="Geen herstelacties nodig" description="Alle uitgevoerde controles zijn geslaagd." />}</div> : <label className="choice-card"><input type="checkbox" checked={networkDiscovery} onChange={(event) => setNetworkDiscovery(event.target.checked)} /><span><Network size={20} /></span><div><strong>Optionele netwerkdiscovery</strong><p>Broadcast, multicast en mDNS kunnen host networking vereisen. Bridge blijft de veilige standaard.</p></div></label>;
return <div className="setup-panel"><span className="eyebrow">Stap {index + 1}</span><div className="setup-title"><span><Icon size={24} /></span><div><h1>{title}</h1><p>Deze controle is read-only en verandert geen apparaattoestand.</p></div></div><div className="setup-content">{contents}</div><div className="setup-actions"><Button variant="ghost" onClick={onPrevious}>Vorige</Button><Button variant="secondary" onClick={onInspect} busy={busy}><ScanSearch size={16} /> Systeemcontrole</Button><Button onClick={onNext}>Volgende <ChevronRight size={16} /></Button></div></div>;
}
function StorageConfirm({ appdata, backup, onAppdata, onBackup, onPrevious, onNext }: { appdata: boolean; backup: boolean; onAppdata: (value: boolean) => void; onBackup: (value: boolean) => void; onPrevious: () => void; onNext: () => void }) { return <div className="setup-panel"><span className="eyebrow">Stap 10</span><div className="setup-title"><span><Database size={24} /></span><div><h1>Appdata & back-up</h1><p>Bevestig dat de persistentie buiten de container is gekoppeld.</p></div></div><div className="setup-content"><label className="choice-card"><input type="checkbox" checked={appdata} onChange={(event) => onAppdata(event.target.checked)} /><span><HardDrive size={20} /></span><div><strong>Appdata is persistent</strong><p>/config/openrgb, /config/lumaops, /data en /logs zijn aan Unraid gekoppeld.</p></div></label><label className="choice-card"><input type="checkbox" checked={backup} onChange={(event) => onBackup(event.target.checked)} /><span><Database size={20} /></span><div><strong>Back-uplocatie is bevestigd</strong><p>Database, OpenRGB-config en secret.key worden samen extern geback-upt.</p></div></label></div><div className="setup-actions"><Button variant="ghost" onClick={onPrevious}>Vorige</Button><Button onClick={onNext} disabled={!appdata || !backup}>Rapport bekijken <ChevronRight size={16} /></Button></div></div>; }
function Report({ report, appdata, backup, busy, onInspect, onComplete }: { report?: SetupReport; appdata: boolean; backup: boolean; busy: boolean; onInspect: () => void; onComplete: () => void }) { const ready = Boolean(report?.openrgb?.connected && report.storage.every((item) => item.writable) && appdata && backup); return <div className="setup-panel"><span className="setup-hero-icon"><CircleCheck size={34} /></span><Badge tone={ready ? "success" : "warning"}>{ready ? "Klaar voor gebruik" : "Aandacht vereist"}</Badge><h1>Systeemstatusrapport</h1><p>{ready ? "De container, opslag en SDK-verbinding voldoen aan de veilige MVP-voorwaarden." : "LumaOps kan starten, maar los onderstaande herstelpunten op voor volledige hardwarebediening."}</p><div className="report-summary"><div><strong>{report?.storage.filter((item) => item.writable).length ?? 0}/{report?.storage.length ?? 4}</strong><span>opslagpaden</span></div><div><strong>{report?.usb_devices.length ?? 0}</strong><span>USB-nodes</span></div><div><strong>{report?.i2c_devices.length ?? 0}</strong><span>I²C-nodes</span></div><div><strong>{report?.openrgb?.connected ? "SDK 5" : "Offline"}</strong><span>OpenRGB</span></div></div>{report?.recovery.map((item) => <div className="recovery" key={`${item.area}-${item.message}`}><Wrench size={17} /><div><strong>{item.area}</strong><p>{item.message}</p></div></div>)}<div className="setup-actions"><Button variant="secondary" onClick={onInspect}>Opnieuw controleren</Button><Button onClick={onComplete} busy={busy} disabled={!appdata || !backup}>LumaOps openen <ChevronRight size={16} /></Button></div></div>; }
function StatusList({ items, empty }: { items: Array<{ name: string; ok: boolean; detail: string }>; empty: string }) { if (!items.length) return <p className="muted">{empty}</p>; return <div className="status-list">{items.map((item) => <div key={item.name}><span className={item.ok ? "ok" : "bad"}>{item.ok ? <Check size={14} /> : "!"}</span><div><strong>{item.name}</strong><p>{item.detail}</p></div></div>)}</div>; }
function NodeList({ title, nodes }: { title: string; nodes: NodeStatus[] }) { return nodes.length ? <div><h3>{title}</h3><StatusList items={nodes.map((node) => ({ name: node.path, ok: node.readable && node.writable, detail: node.writable ? "Lees- en schrijfbaar" : node.readable ? "Alleen leesbaar" : "Niet toegankelijk" }))} empty="" /></div> : <EmptyState icon={Usb} title={`Geen ${title.toLowerCase()}`} description="Dit is normaal als de betreffende hardware niet is doorgemapt." />; }
@@ -0,0 +1,46 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
import { SpacesPage } from "./SpacesPage";
const device = {
id: "device-1",
alias: null,
name: "Bureaulamp",
};
describe("SpacesPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = requestUrl(input);
if (url.includes("/rooms")) return Promise.resolve(jsonResponse([]));
if (url.includes("/devices")) return Promise.resolve(jsonResponse({ items: [device], total: 1, limit: 500, offset: 0 }));
if (url.includes("/groups") && (init?.method ?? "GET") === "POST") return Promise.resolve(jsonResponse({ id: "group-1", name: "Bureau", device_count: 1 }, 201));
if (url.includes("/groups")) return Promise.resolve(jsonResponse([]));
return Promise.resolve(jsonResponse({}));
}));
});
it("creates a static and dynamic group from the management form", async () => {
const user = userEvent.setup();
renderApp(<SpacesPage />, "/spaces");
await user.click(await screen.findByRole("button", { name: "Nieuwe groep" }));
await screen.findByText("Bureaulamp");
await user.type(screen.getByLabelText("Naam"), "Bureau");
await user.type(screen.getByLabelText("Dynamische tags (optioneel)"), "bureau, rgb");
await user.click(screen.getByRole("checkbox", { name: "Bureaulamp" }));
await user.click(screen.getByRole("button", { name: "Aanmaken" }));
await waitFor(() => {
const calls = vi.mocked(fetch).mock.calls;
const create = calls.find(([input, init]) => requestUrl(input).endsWith("/api/v1/groups") && init?.method === "POST");
expect(create).toBeDefined();
expect(requestJson(create?.[1])).toMatchObject({
name: "Bureau",
device_ids: ["device-1"],
dynamic_query: { tags: ["bureau", "rgb"], match: "all" },
});
});
});
});
+131
View File
@@ -0,0 +1,131 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Boxes, DoorOpen, Palette, Pencil, Plus, Power, Trash2, Users } from "lucide-react";
import { useState, type FormEvent } from "react";
import { api } from "../api/client";
import type { Device, Group, Page, Room } from "../api/types";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Button, Card, CardHeader, EmptyState, ErrorPanel, Field, Input, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formValue, hexToColor } from "../lib/utils";
type Editor = { kind: "room"; item?: Room } | { kind: "group"; item?: Group };
type DeleteTarget = { kind: "room" | "group"; id: string; name: string };
export function SpacesPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [editor, setEditor] = useState<Editor | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
const [controlGroup, setControlGroup] = useState<Group | null>(null);
const [groupColor, setGroupColor] = useState("#5660ff");
const [groupBrightness, setGroupBrightness] = useState(100);
const rooms = useQuery({ queryKey: ["rooms"], queryFn: () => api<Room[]>("/api/v1/rooms") });
const groups = useQuery({ queryKey: ["groups"], queryFn: () => api<Group[]>("/api/v1/groups") });
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["rooms"] });
void queryClient.invalidateQueries({ queryKey: ["groups"] });
void queryClient.invalidateQueries({ queryKey: ["devices"] });
};
const save = useMutation({
mutationFn: ({ kind, id, payload }: { kind: "room" | "group"; id?: string; payload: Record<string, unknown> }) => api(
`/api/v1/${kind === "room" ? "rooms" : "groups"}${id ? `/${id}` : ""}`,
{ method: id ? "PUT" : "POST", body: JSON.stringify(payload) },
),
onSuccess: (_, variables) => {
notify(variables.id ? text("Indeling bijgewerkt.", "Organization updated.") : text("Indeling aangemaakt.", "Organization created."));
setEditor(null);
invalidate();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
});
const remove = useMutation({
mutationFn: (target: DeleteTarget) => api(`/api/v1/${target.kind === "room" ? "rooms" : "groups"}/${target.id}`, { method: "DELETE" }),
onSuccess: () => {
notify(text("Indeling verwijderd.", "Organization removed."));
setDeleteTarget(null);
invalidate();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
});
const loadGroup = useMutation({
mutationFn: (id: string) => api<Group>(`/api/v1/groups/${id}`),
onSuccess: (item) => setEditor({ kind: "group", item }),
onError: (error) => notify(error instanceof Error ? error.message : text("Groep laden mislukt.", "Failed to load group."), "danger"),
});
const groupState = useMutation({
mutationFn: ({ id, state }: { id: string; state: Record<string, unknown> }) => api<{ results: Array<{ status: string }> }>(`/api/v1/groups/${id}/state`, { method: "POST", body: JSON.stringify({ state }) }),
onSuccess: (result) => {
const failed = result.results.filter((item) => item.status === "failed").length;
notify(failed ? text(`${failed} apparaten konden niet worden bijgewerkt.`, `${failed} devices could not be updated.`) : text("Groep bijgewerkt.", "Group updated."), failed ? "danger" : "success");
setControlGroup(null);
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
onError: (error) => notify(error instanceof Error ? error.message : text("Groepsopdracht mislukt.", "Group command failed."), "danger"),
});
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editor) return;
const data = new FormData(event.currentTarget);
const name = formValue(data, "name").trim();
const description = formValue(data, "description").trim() || null;
if (!name) return;
if (editor.kind === "room") {
save.mutate({ kind: "room", id: editor.item?.id, payload: { name, description, sort_order: editor.item?.sort_order ?? 0 } });
return;
}
const tags = formValue(data, "tags").split(",").map((tag) => tag.trim()).filter(Boolean);
save.mutate({
kind: "group",
id: editor.item?.id,
payload: {
name,
description,
sort_order: editor.item?.sort_order ?? 0,
device_ids: data.getAll("devices").map(String),
dynamic_query: tags.length ? { tags, match: data.get("tag_match") === "any" ? "any" : "all" } : null,
},
});
};
const error = rooms.error ?? groups.error ?? devices.error;
return <>
<PageHeader
eyebrow={text("Logische indeling", "Logical organization")}
title={text("Kamers & groepen", "Rooms & groups")}
description={text("Een apparaat kan in één kamer en in meerdere statische of dynamische groepen staan.", "A device can belong to one room and multiple static or dynamic groups.")}
actions={<><Button variant="secondary" onClick={() => setEditor({ kind: "room" })}><DoorOpen size={16} /> {text("Nieuwe kamer", "New room")}</Button><Button onClick={() => setEditor({ kind: "group" })}><Plus size={16} /> {text("Nieuwe groep", "New group")}</Button></>}
/>
{editor ? (
<Card className="inline-form">
<CardHeader title={editor.item ? text("Indeling bewerken", "Edit organization") : editor.kind === "room" ? text("Kamer aanmaken", "Create room") : text("Groep aanmaken", "Create group")} description={editor.kind === "room" ? text("Gebruik een herkenbare fysieke locatie.", "Use a recognizable physical location.") : text("Selecteer apparaten en/of match dynamisch op tags.", "Select devices and/or match dynamically by tags.")} />
<form onSubmit={submit}>
<div className="form-grid form-grid--two"><Field label={text("Naam", "Name")}><Input name="name" required autoFocus defaultValue={editor.item?.name ?? ""} placeholder={editor.kind === "room" ? text("Bijvoorbeeld Werkplek", "For example Workspace") : text("Bijvoorbeeld Bureauverlichting", "For example Desk lights")} /></Field><Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={editor.item?.description ?? ""} /></Field></div>
{editor.kind === "group" ? <>
<div className="form-grid form-grid--two"><Field label={text("Dynamische tags (optioneel)", "Dynamic tags (optional)")}><Input name="tags" defaultValue={editor.item?.dynamic_query?.tags?.join(", ") ?? ""} placeholder="bureau, rgb" /></Field><Field label={text("Tagmatching", "Tag matching")}><select className="input" name="tag_match" defaultValue={editor.item?.dynamic_query?.match ?? "all"}><option value="all">{text("Alle tags", "All tags")}</option><option value="any">{text("Minstens één tag", "At least one tag")}</option></select></Field></div>
<fieldset className="check-grid"><legend>{text("Statische apparaten", "Static devices")}</legend>{devices.data?.items.map((device) => <label key={device.id}><input type="checkbox" name="devices" value={device.id} defaultChecked={editor.item?.devices?.some((item) => item.id === device.id)} /> <span>{device.alias || device.name}</span></label>)}</fieldset>
</> : null}
<div className="form-actions"><Button variant="ghost" type="button" onClick={() => setEditor(null)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={save.isPending}>{editor.item ? text("Opslaan", "Save") : text("Aanmaken", "Create")}</Button></div>
</form>
</Card>
) : null}
{error ? <ErrorPanel error={error} retry={() => void Promise.all([rooms.refetch(), groups.refetch(), devices.refetch()])} /> : null}
<section className="section-block">
<div className="section-heading"><div><h2>{text("Kamers", "Rooms")}</h2><p>{text("Fysieke locaties voor snelle selectie en overzicht.", "Physical locations for quick selection and overview.")}</p></div></div>
{rooms.isLoading ? <LoadingGrid /> : rooms.data?.length ? <div className="grid grid--cards">{rooms.data.map((room, index) => <Card key={room.id} className="space-card"><span className={`space-card__icon space-card__icon--${index % 4}`}><DoorOpen size={21} /></span><div className="space-card__content"><h3>{room.name}</h3><p>{room.description || text(`${room.device_count} apparaten`, `${room.device_count} devices`)}</p></div><div className="card-actions"><Button variant="ghost" title={text("Bewerken", "Edit")} onClick={() => setEditor({ kind: "room", item: room })}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteTarget({ kind: "room", id: room.id, name: room.name })}><Trash2 size={15} /></Button></div></Card>)}</div> : <EmptyState icon={DoorOpen} title={text("Nog geen kamers", "No rooms yet")} description={text("Maak je eerste fysieke locatie aan.", "Create your first physical location.")} />}
</section>
<section className="section-block">
<div className="section-heading"><div><h2>{text("Logische groepen", "Logical groups")}</h2><p>{text("Combineer apparaten over kamers en connectoren heen.", "Combine devices across rooms and connectors.")}</p></div></div>
{groups.isLoading ? <LoadingGrid /> : groups.data?.length ? <div className="grid grid--cards">{groups.data.map((group, index) => <Card key={group.id} className="space-card"><span className={`space-card__icon space-card__icon--${(index + 1) % 4}`}><Users size={21} /></span><div className="space-card__content"><h3>{group.name}</h3><p>{text(`${group.device_count} apparaten`, `${group.device_count} devices`)}{group.dynamic_query?.tags?.length ? ` · ${group.dynamic_query.tags.join(", ")}` : ""}</p></div><div className="card-actions"><Button variant="ghost" title={text("Groep bedienen", "Control group")} onClick={() => setControlGroup(group)}><Palette size={15} /></Button><Button variant="ghost" title={text("Alles uit", "All off")} onClick={() => groupState.mutate({ id: group.id, state: { power: false } })}><Power size={15} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} busy={loadGroup.isPending} onClick={() => loadGroup.mutate(group.id)}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteTarget({ kind: "group", id: group.id, name: group.name })}><Trash2 size={15} /></Button></div></Card>)}</div> : <EmptyState icon={Boxes} title={text("Nog geen groepen", "No groups yet")} description={text("Maak een groep om meerdere apparaten tegelijk te bedienen.", "Create a group to control multiple devices together.")} />}
</section>
<ConfirmDialog open={Boolean(deleteTarget)} title={text("Indeling verwijderen?", "Delete organization?")} description={text(`${deleteTarget?.name ?? ""}” wordt verwijderd. Apparaten zelf blijven behouden.`, `${deleteTarget?.name ?? ""}” will be deleted. Devices themselves are preserved.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteTarget(null)} onConfirm={() => deleteTarget && remove.mutate(deleteTarget)} />
<ConfirmDialog open={Boolean(controlGroup)} title={text("Groep bedienen", "Control group")} description={text(`Pas kleur en helderheid toe op alle geschikte apparaten in “${controlGroup?.name ?? ""}”.`, `Apply color and brightness to all capable devices in “${controlGroup?.name ?? ""}”.`)} confirmLabel={text("Toepassen", "Apply")} busy={groupState.isPending} confirmDisabled={!/^#[0-9a-f]{6}$/i.test(groupColor)} onClose={() => setControlGroup(null)} onConfirm={() => controlGroup && groupState.mutate({ id: controlGroup.id, state: { power: true, colors: [hexToColor(groupColor)], brightness: groupBrightness } })}>
<div className="dialog-form"><Field label={text("Kleur", "Color")}><div className="large-color"><input type="color" value={groupColor} onChange={(event) => setGroupColor(event.target.value)} /><Input value={groupColor.toUpperCase()} onChange={(event) => setGroupColor(event.target.value)} /></div></Field><Field label={text(`Helderheid · ${groupBrightness}%`, `Brightness · ${groupBrightness}%`)}><input className="range" type="range" min="0" max="100" value={groupBrightness} onChange={(event) => setGroupBrightness(Number(event.target.value))} /></Field></div>
</ConfirmDialog>
</>;
}
+580
View File
@@ -0,0 +1,580 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
--bg: #f4f6fb;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-muted: #f0f2f8;
--border: #dfe3ed;
--border-strong: #cbd1df;
--text: #182033;
--text-muted: #687086;
--accent: #5964f3;
--accent-hover: #4853df;
--accent-soft: #eef0ff;
--success: #159a74;
--success-soft: #e5f7f0;
--warning: #c47a16;
--warning-soft: #fff3dc;
--danger: #d24754;
--danger-soft: #ffeaec;
--shadow: 0 10px 30px rgba(24, 32, 51, 0.07);
--shadow-lg: 0 22px 60px rgba(18, 25, 45, 0.14);
--radius: 16px;
--radius-sm: 10px;
--sidebar: #101628;
--sidebar-muted: #919bb4;
}
:root[data-theme="dark"] {
--bg: #090d17;
--surface: #111725;
--surface-raised: #151c2c;
--surface-muted: #1a2233;
--border: #283248;
--border-strong: #39445d;
--text: #eef2fc;
--text-muted: #939db3;
--accent: #7b83ff;
--accent-hover: #9298ff;
--accent-soft: #252c56;
--success: #3fd0a1;
--success-soft: #123a32;
--warning: #f0af4f;
--warning-soft: #3e2d17;
--danger: #ff7180;
--danger-soft: #421f29;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 22px 60px rgba(0, 0, 0, 0.36);
--sidebar: #0c111e;
}
* { box-sizing: border-box; }
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
html { min-width: 320px; background: var(--bg); }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--bg); color: var(--text); }
button, input, select, textarea { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 9px; font-size: clamp(1.8rem, 3vw, 2.55rem); line-height: 1.08; letter-spacing: -0.035em; }
h2 { margin-bottom: 6px; font-size: 1rem; letter-spacing: -0.01em; }
h3 { margin-bottom: 4px; font-size: .96rem; }
p { margin-bottom: 0; line-height: 1.55; color: var(--text-muted); }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 244px minmax(0, 1fr); }
.app-main { min-width: 0; grid-column: 2; }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 40; width: 244px; display: flex; flex-direction: column; background: var(--sidebar); color: #f5f7ff; border-right: 1px solid rgba(255,255,255,.06); transition: width .2s ease, transform .2s ease; }
.brand { height: 72px; display: flex; align-items: center; gap: 11px; padding: 0 18px; }
.brand__mark { width: 36px; height: 36px; flex: 0 0 auto; border-radius: 11px; display: grid; place-items: center; color: white; background: linear-gradient(145deg, #7077ff, #4b57e8); box-shadow: 0 8px 24px rgba(89,100,243,.34); }
.brand__text { min-width: 0; display: flex; flex-direction: column; }
.brand__text strong { font-size: 1rem; letter-spacing: -.015em; }
.brand__text span { margin-top: 1px; font-size: .68rem; color: var(--sidebar-muted); white-space: nowrap; }
.sidebar__nav { flex: 1; overflow-y: auto; padding: 8px 12px 20px; scrollbar-width: thin; }
.nav-label { display: block; padding: 16px 10px 7px; color: #68748f; font-size: .65rem; font-weight: 700; letter-spacing: .11em; text-transform: uppercase; }
.nav-link { height: 40px; display: flex; align-items: center; gap: 11px; margin: 2px 0; padding: 0 11px; border-radius: 9px; color: #9ba5bd; font-size: .82rem; font-weight: 560; transition: .16s ease; }
.nav-link:hover { color: #fff; background: rgba(255,255,255,.055); }
.nav-link--active { color: #fff; background: linear-gradient(90deg, rgba(111,120,255,.23), rgba(111,120,255,.1)); box-shadow: inset 2px 0 #7981ff; }
.nav-link svg { flex: 0 0 auto; }
.sidebar__collapse { height: 48px; display: flex; align-items: center; gap: 10px; border: 0; border-top: 1px solid rgba(255,255,255,.06); padding: 0 22px; background: transparent; color: #78839b; cursor: pointer; font-size: .72rem; }
.sidebar__collapse:hover { color: #fff; }
.sidebar__close { display: none !important; margin-left: auto; color: #9ba5bd; }
.app-shell--collapsed { grid-template-columns: 72px minmax(0, 1fr); }
.app-shell--collapsed .sidebar { width: 72px; }
.app-shell--collapsed .brand { padding: 0 18px; }
.app-shell--collapsed .brand__text, .app-shell--collapsed .nav-label, .app-shell--collapsed .nav-link span, .app-shell--collapsed .sidebar__collapse span { display: none; }
.app-shell--collapsed .nav-link { justify-content: center; padding: 0; }
.app-shell--collapsed .sidebar__collapse { justify-content: center; padding: 0; }
.app-shell--collapsed .sidebar__collapse svg { transform: rotate(180deg); }
.topbar { position: sticky; top: 0; z-index: 25; height: 64px; display: flex; align-items: center; gap: 13px; padding: 0 clamp(18px, 3vw, 42px); background: color-mix(in srgb, var(--bg) 88%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--border) 80%, transparent); backdrop-filter: blur(16px); }
.topbar__status { display: flex; align-items: center; gap: 11px; margin-left: auto; }
.topbar__device-count { color: var(--text-muted); font-size: .78rem; }
.mobile-menu { display: none !important; }
.mock-banner { min-height: 36px; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 7px 20px; background: var(--warning-soft); color: var(--warning); border-bottom: 1px solid color-mix(in srgb, var(--warning) 25%, transparent); font-size: .75rem; font-weight: 650; }
.page { width: min(1520px, 100%); margin: 0 auto; padding: clamp(25px, 3vw, 44px); }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 26px; }
.page-header > div:first-child { max-width: 760px; }
.page-header p { max-width: 710px; font-size: .9rem; }
.page-header__actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 9px; }
.eyebrow { display: block; margin-bottom: 8px; color: var(--accent); font-size: .68rem; font-weight: 750; letter-spacing: .115em; text-transform: uppercase; }
.section-block { margin-top: 32px; }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
.section-heading h2 { font-size: 1.13rem; }
.section-heading p { font-size: .8rem; }
.text-link, .back-link { color: var(--accent); font-size: .78rem; font-weight: 650; }
.back-link { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 17px; }
.muted { color: var(--text-muted); font-size: .82rem; }
.button { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 9px; padding: 0 14px; cursor: pointer; font-size: .77rem; font-weight: 660; white-space: nowrap; transition: transform .13s ease, background .13s ease, border .13s ease; }
.button:hover:not(:disabled) { transform: translateY(-1px); }
.button:disabled { opacity: .48; cursor: not-allowed; }
.button--primary { color: #fff; background: var(--accent); box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 26%, transparent); }
.button--primary:hover:not(:disabled) { background: var(--accent-hover); }
.button--secondary { color: var(--text); background: var(--surface); border-color: var(--border); }
.button--secondary:hover:not(:disabled) { border-color: var(--border-strong); background: var(--surface-muted); }
.button--ghost { color: var(--text-muted); background: transparent; }
.button--ghost:hover:not(:disabled) { color: var(--text); background: var(--surface-muted); }
.button--danger { color: #fff; background: var(--danger); box-shadow: 0 6px 16px color-mix(in srgb, var(--danger) 22%, transparent); }
.full-width { width: 100%; }
.icon-button { width: 36px; height: 36px; display: inline-grid; place-items: center; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); cursor: pointer; }
.icon-button:hover { background: var(--surface-muted); }
.spin { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.card { min-width: 0; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.card__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.card__header h2 { font-size: .96rem; }
.card__header p { font-size: .75rem; }
.grid { display: grid; gap: 14px; }
.grid--stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid--cards { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid--devices { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid--scenes { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.grid--connectors { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.grid--health { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 16px; margin-top: 16px; }
.badge { width: fit-content; display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 999px; background: var(--surface-muted); color: var(--text-muted); font-size: .64rem; font-weight: 720; line-height: 1; }
.badge--success { background: var(--success-soft); color: var(--success); }
.badge--warning { background: var(--warning-soft); color: var(--warning); }
.badge--danger { background: var(--danger-soft); color: var(--danger); }
.badge--accent { background: var(--accent-soft); color: var(--accent); }
.stat { display: flex; align-items: center; gap: 13px; padding: 17px; }
.stat__icon { width: 39px; height: 39px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 11px; background: var(--accent-soft); color: var(--accent); }
.stat > div { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.stat__label { color: var(--text-muted); font-size: .67rem; font-weight: 600; }
.stat strong { font-size: 1.16rem; }
.stat small { overflow: hidden; color: var(--text-muted); font-size: .61rem; text-overflow: ellipsis; white-space: nowrap; }
.quick-control { background: radial-gradient(circle at 18% 0, color-mix(in srgb, var(--accent) 9%, transparent), transparent 38%), var(--surface); }
.color-control { display: grid; grid-template-columns: 54px minmax(0,1fr) auto; align-items: center; gap: 13px; }
.color-control input[type="color"] { width: 54px; height: 54px; padding: 0; border: 0; border-radius: 14px; overflow: hidden; background: transparent; cursor: pointer; }
.color-control input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
.color-control input[type="color"]::-webkit-color-swatch { border: 4px solid var(--surface-muted); border-radius: 14px; }
.color-control > div { display: flex; flex-direction: column; }
.color-control strong { font-size: .92rem; }
.color-control span { color: var(--text-muted); font-size: .67rem; }
.color-swatches { display: flex; gap: 9px; margin-top: 18px; }
.color-swatches button { width: 27px; height: 27px; border: 3px solid var(--surface); border-radius: 50%; box-shadow: 0 0 0 1px var(--border); cursor: pointer; }
.color-swatches button.selected { box-shadow: 0 0 0 2px var(--accent); }
.active-scene { display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 12px; padding: 11px; border-radius: 12px; background: var(--surface-muted); }
.active-scene > div { display: flex; flex-direction: column; }
.active-scene span:not(.badge):not(.scene-orb) { color: var(--text-muted); font-size: .67rem; }
.scene-orb { width: 42px; height: 42px; border-radius: 13px; background: radial-gradient(circle at 30% 25%, #fff, #7881ff 22%, #252d80 75%); box-shadow: 0 7px 18px rgba(89,100,243,.35); }
.scene-card { overflow: hidden; padding: 0; }
.scene-gradient { height: 76px; display: grid; place-items: center; color: rgba(255,255,255,.82); }
.scene-gradient--0 { background: radial-gradient(circle at 30% 20%, #9aa1ff, transparent 42%), linear-gradient(125deg, #303989, #161b49); }
.scene-gradient--1 { background: radial-gradient(circle at 70% 20%, #ffca8e, transparent 42%), linear-gradient(125deg, #683d42, #261a2b); }
.scene-gradient--2 { background: radial-gradient(circle at 45% 10%, #74f0d0, transparent 45%), linear-gradient(125deg, #16655f, #102f3d); }
.scene-gradient--3 { background: radial-gradient(circle at 60% 20%, #ec9fff, transparent 42%), linear-gradient(125deg, #60346d, #242041); }
.scene-card__body { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 13px 15px 15px; }
.scene-card__body h3 { font-size: .86rem; }
.scene-card__body p { font-size: .65rem; }
.timeline { display: flex; flex-direction: column; }
.timeline__item { display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 11px; min-height: 49px; border-bottom: 1px solid var(--border); }
.timeline__item:last-child { border: 0; }
.timeline__item > div { display: flex; flex-direction: column; }
.timeline__item strong { font-size: .75rem; }
.timeline__item span { color: var(--text-muted); font-size: .63rem; }
.timeline__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); box-shadow: 0 0 0 4px var(--surface-muted); }
.timeline__dot--succeeded { background: var(--success); box-shadow: 0 0 0 4px var(--success-soft); }
.timeline__dot--failed { background: var(--danger); box-shadow: 0 0 0 4px var(--danger-soft); }
.warning-row, .all-clear { display: flex; align-items: flex-start; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); }
.warning-row > span { width: 7px; height: 7px; margin-top: 6px; border-radius: 50%; background: var(--warning); }
.warning-row strong, .all-clear strong { font-size: .76rem; }
.warning-row p, .all-clear p { font-size: .67rem; }
.all-clear { align-items: center; border: 0; }
.all-clear > span { width: 37px; height: 37px; display: grid; place-items: center; border-radius: 10px; background: var(--success-soft); color: var(--success); }
.alert { display: flex; align-items: flex-start; gap: 11px; margin: 0 0 18px; padding: 13px 15px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
.alert > svg { flex: 0 0 auto; margin-top: 1px; }
.alert > div { flex: 1; }
.alert strong { font-size: .78rem; }
.alert p { font-size: .7rem; }
.alert--danger { border-color: color-mix(in srgb, var(--danger) 28%, var(--border)); background: var(--danger-soft); color: var(--danger); }
.alert--danger p { color: color-mix(in srgb, var(--danger) 70%, var(--text)); }
.alert--info { border-color: color-mix(in srgb, var(--accent) 24%, var(--border)); background: var(--accent-soft); color: var(--accent); }
.alert--info p { color: color-mix(in srgb, var(--accent) 60%, var(--text)); }
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
.search { min-width: 240px; max-width: 440px; flex: 1; display: flex; align-items: center; gap: 8px; padding: 0 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--text-muted); }
.search .input { border: 0; padding-left: 0; box-shadow: none; background: transparent; }
.input { width: 100%; min-height: 39px; border: 1px solid var(--border); border-radius: 9px; padding: 8px 10px; background: var(--surface); color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,.02); }
.input::placeholder { color: var(--text-muted); }
.input:focus { border-color: var(--accent); }
.segmented, .view-toggle { display: flex; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
.segmented button, .view-toggle button { min-height: 31px; border: 0; border-radius: 7px; padding: 0 11px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: .69rem; }
.segmented button.active, .view-toggle button.active { background: var(--surface-muted); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.view-toggle button { width: 32px; padding: 0; display: grid; place-items: center; }
.device-card, .device-row { display: block; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); transition: transform .16s ease, border-color .16s ease; }
.device-card:hover, .device-row:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); }
.device-card__visual { --device-color: #5660ff; position: relative; height: 112px; display: grid; place-items: center; overflow: hidden; background: radial-gradient(circle at center, color-mix(in srgb, var(--device-color) 35%, transparent), transparent 53%), linear-gradient(145deg, var(--surface-muted), var(--surface)); color: var(--device-color); }
.device-glow { position: absolute; width: 70px; height: 70px; border-radius: 50%; background: var(--device-color); filter: blur(32px); opacity: .28; }
.device-card__visual svg { position: relative; z-index: 1; filter: drop-shadow(0 3px 10px color-mix(in srgb, var(--device-color) 35%, transparent)); }
.presence { position: absolute; right: 12px; top: 12px; width: 8px; height: 8px; border: 2px solid var(--surface); border-radius: 50%; background: var(--text-muted); box-sizing: content-box; }
.presence--online { background: var(--success); box-shadow: 0 0 10px color-mix(in srgb, var(--success) 75%, transparent); }
.device-card__content { padding: 15px; }
.device-card__title { display: flex; justify-content: space-between; gap: 10px; }
.device-card__title h2 { font-size: .9rem; }
.device-card__title p { font-size: .65rem; }
.favorite { color: #edb539; }
.device-card__meta { min-height: 24px; display: flex; flex-wrap: wrap; gap: 5px; margin-top: 11px; }
.device-card__footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--text-muted); font-size: .63rem; }
.device-card__footer span { display: flex; align-items: center; gap: 6px; }
.device-card__footer i { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 6px currentColor; }
.device-card--group { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
.device-card--group .device-card__visual { background: radial-gradient(circle at center, color-mix(in srgb, var(--device-color) 42%, transparent), transparent 55%), linear-gradient(145deg, var(--accent-soft), var(--surface)); }
.device-list { display: flex; flex-direction: column; gap: 8px; }
.device-row { display: grid; grid-template-columns: 70px minmax(0,1fr); align-items: center; }
.device-row .device-card__visual { height: 68px; margin: 6px; border-radius: 11px; }
.device-row .device-card__content { display: grid; grid-template-columns: minmax(0,1fr) auto auto; align-items: center; gap: 20px; padding: 11px 16px 11px 8px; }
.device-row .device-card__title { min-width: 0; }
.device-row .device-card__meta { margin: 0; }
.device-row__extra { min-width: 190px; display: flex; justify-content: flex-end; gap: 18px; color: var(--text-muted); font-size: .68rem; }
.detail-status { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-top: -13px; margin-bottom: 21px; }
.detail-status > span:last-child { margin-left: auto; color: var(--text-muted); font-size: .68rem; }
.detail-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); gap: 16px; }
.detail-side { display: flex; flex-direction: column; gap: 16px; }
.control-panel { padding: 23px; }
.power-actions { display: flex; gap: 8px; margin-bottom: 18px; }
.field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 16px; }
.field__label { color: var(--text); font-size: .72rem; font-weight: 680; }
.field__hint { color: var(--text-muted); font-size: .62rem; }
.large-color { display: grid; grid-template-columns: 52px minmax(0,1fr); gap: 8px; }
.large-color input[type="color"] { width: 52px; height: 39px; padding: 2px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); }
.effect-colors { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; }
.effect-color { display: flex; flex-direction: column; gap: 6px; }
.effect-color > span { color: var(--text-muted); font-size: .62rem; font-weight: 650; }
.mode-hint { margin: -5px 0 16px; padding: 10px 12px; border-radius: 9px; background: var(--surface-muted); color: var(--text-muted); font-size: .65rem; }
.range { width: 100%; accent-color: var(--accent); }
.device-group-overview { align-items: stretch; }
.group-summary { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; min-height: 230px; padding: 26px; border: 1px solid var(--border); border-radius: var(--radius); background: radial-gradient(circle at 50% 25%, var(--accent-soft), transparent 55%), var(--surface); text-align: center; box-shadow: var(--shadow); }
.group-summary svg { color: var(--accent); }
.group-summary strong { font-size: 1.05rem; }
.group-summary span { max-width: 280px; color: var(--text-muted); font-size: .68rem; line-height: 1.55; }
.properties { margin: 0; }
.properties > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.properties > div:last-child { border: 0; }
.properties dt { display: flex; align-items: center; gap: 7px; color: var(--text-muted); font-size: .67rem; }
.properties dd { margin: 0; max-width: 58%; text-align: right; overflow-wrap: anywhere; font-size: .68rem; font-weight: 620; }
.switch-list { display: flex; flex-direction: column; }
.switch-row { display: flex; align-items: center; justify-content: space-between; min-height: 42px; border-bottom: 1px solid var(--border); font-size: .69rem; }
.switch-row:last-child { border: 0; }
.switch-row--danger { color: var(--danger); }
.switch-row input { position: absolute; opacity: 0; }
.switch-row i { position: relative; width: 34px; height: 19px; border-radius: 999px; background: var(--border-strong); cursor: pointer; transition: .16s ease; }
.switch-row i::after { content: ""; position: absolute; left: 3px; top: 3px; width: 13px; height: 13px; border-radius: 50%; background: white; transition: .16s ease; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
.switch-row input:checked + i { background: var(--accent); }
.switch-row input:checked + i::after { transform: translateX(15px); }
.switch-row input:focus-visible + i { outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent); }
.zone-card { display: flex; align-items: center; gap: 11px; }
.zone-card > span { width: 37px; height: 37px; display: grid; place-items: center; border-radius: 10px; color: var(--accent); background: var(--accent-soft); }
.zone-card p { font-size: .67rem; }
.argb-zone-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.argb-zone-card { display: flex; flex-direction: column; gap: 18px; }
.argb-zone-card .field { margin-bottom: 0; }
.argb-zone-card__heading { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; }
.argb-zone-card__heading > span { width: 39px; height: 39px; display: grid; place-items: center; border-radius: 11px; color: var(--accent); background: var(--accent-soft); }
.argb-zone-card__heading p { font-size: .65rem; }
.zone-size-control { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.inline-form { margin-bottom: 20px; border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
.inline-form form { max-width: 780px; }
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; }
.form-grid--two { grid-template-columns: repeat(2, minmax(0,1fr)); }
.form-grid--three { grid-template-columns: repeat(3, minmax(0,1fr)); }
.form-grid .form-actions { grid-column: 1 / -1; }
.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
.form-stack { display: flex; flex-direction: column; gap: 8px; }
.check-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; margin: 4px 0 16px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; }
.check-grid legend { padding: 0 5px; color: var(--text-muted); font-size: .68rem; }
.check-grid label { display: flex; align-items: center; gap: 7px; font-size: .7rem; }
.check-grid input { accent-color: var(--accent); }
.space-card, .network-device, .planned-connector { display: flex; align-items: center; gap: 12px; }
.space-card > span, .network-device > span, .planned-connector > span { width: 42px; height: 42px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 12px; background: var(--accent-soft); color: var(--accent); }
.space-card__icon--1 { color: var(--success) !important; background: var(--success-soft) !important; }
.space-card__icon--2 { color: var(--warning) !important; background: var(--warning-soft) !important; }
.space-card__icon--3 { color: #be62d3 !important; background: color-mix(in srgb, #be62d3 14%, var(--surface)) !important; }
.space-card p, .network-device p, .planned-connector p { font-size: .67rem; }
.space-card__content { flex: 1; min-width: 0; }
.card-actions, .automation-row__actions { display: flex; align-items: center; gap: 3px; }
.card-actions .button, .automation-row__actions .button { min-width: 34px; padding: 0 9px; }
.network-device > div, .planned-connector > div { flex: 1; }
.scene-tile { overflow: hidden; padding: 0; }
.scene-tile__visual { height: 120px; display: flex; align-items: flex-start; justify-content: space-between; padding: 16px; color: #fff; }
.scene-tile__content { padding: 16px; }
.scene-tile__content h2 { font-size: .94rem; }
.scene-tile__content > div:first-child p { min-height: 34px; font-size: .67rem; }
.scene-tile dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0; }
.scene-tile dl div { display: flex; flex-direction: column; gap: 2px; }
.scene-tile dt { color: var(--text-muted); font-size: .58rem; }
.scene-tile dd { margin: 0; font-size: .65rem; font-weight: 650; }
.scene-tile__actions { display: flex; gap: 6px; }
.scene-tile__actions .button:first-child { flex: 1; }
.automation-list { display: flex; flex-direction: column; gap: 10px; }
.automation-row { display: flex; align-items: center; gap: 14px; padding: 15px; }
.automation-row__icon { width: 40px; height: 40px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 11px; color: var(--accent); background: var(--accent-soft); }
.automation-row__main { flex: 1; min-width: 0; display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 20px; }
.automation-row__main h2 { font-size: .84rem; }
.automation-row__main p { display: flex; align-items: center; gap: 5px; font-size: .64rem; }
.automation-row__meta { display: flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: .62rem; }
.resource-editor { margin-bottom: 20px; border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
.resource-editor__section { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
.resource-editor__section h3 { margin-bottom: 10px; font-size: .8rem; }
.resource-editor__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
.checkbox-row { display: flex; align-items: center; gap: 8px; color: var(--text-muted); font-size: .7rem; }
.checkbox-row input, .option-control > input:first-child, .weekday-grid input { accent-color: var(--accent); }
.field-error { margin: -4px 0 0; color: var(--danger); font-size: .65rem; }
.scene-item-list { display: flex; flex-direction: column; gap: 7px; }
.scene-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-muted); }
.scene-item > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.scene-item strong { font-size: .72rem; }
.scene-item span { color: var(--text-muted); font-size: .62rem; }
.scene-target-form { display: grid; grid-template-columns: minmax(170px,1.3fr) minmax(110px,.7fr) minmax(110px,.7fr) minmax(130px,.8fr) auto; align-items: end; gap: 10px; margin-top: 14px; padding: 14px; border: 1px dashed var(--border-strong); border-radius: 11px; }
.option-control { min-height: 37px; display: flex; align-items: center; gap: 8px; }
.option-control input[type="color"] { width: 44px; height: 34px; padding: 2px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); }
.option-control .input { min-width: 0; }
.weekday-grid { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; }
.weekday-grid legend { padding: 0 5px; color: var(--text-muted); font-size: .68rem; }
.weekday-grid label { min-width: 52px; display: flex; align-items: center; justify-content: center; gap: 5px; padding: 7px 9px; border-radius: 8px; background: var(--surface-muted); font-size: .66rem; }
.run-list { display: flex; flex-direction: column; }
.run-row { display: grid; grid-template-columns: 90px 155px 100px minmax(0,1fr); align-items: center; gap: 12px; min-height: 42px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-size: .65rem; }
.run-row:last-child { border: 0; }
.run-row__error { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dialog-form { display: flex; flex-direction: column; gap: 14px; margin-top: 17px; }
.connector-card { padding: 22px; }
.connector-hero { display: flex; align-items: center; gap: 13px; margin-bottom: 16px; padding: 13px; border-radius: 12px; background: var(--surface-muted); }
.connector-hero > span { width: 43px; height: 43px; display: grid; place-items: center; border-radius: 12px; color: var(--accent); background: var(--accent-soft); }
.connector-hero p { font-size: .66rem; }
.connector-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 13px; }
.connector-meta div { display: flex; flex-direction: column; padding: 9px; border: 1px solid var(--border); border-radius: 9px; }
.connector-meta span { color: var(--text-muted); font-size: .58rem; }
.connector-meta strong { font-size: .68rem; }
.mini-warning { display: flex; gap: 7px; margin: 0 0 13px; padding: 9px 10px; border-radius: 8px; background: var(--warning-soft); color: var(--warning); font-size: .62rem; line-height: 1.45; }
.planned-connector .badge { margin-left: auto; }
.discovery-mode { display: flex; align-items: center; gap: 11px; }
.discovery-mode > span { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; background: var(--accent-soft); color: var(--accent); }
.discovery-mode p { font-size: .64rem; }
.table-wrap { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: .7rem; }
th { padding: 9px 11px; color: var(--text-muted); text-align: left; font-size: .61rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; background: var(--surface-muted); }
th:first-child { border-radius: 8px 0 0 8px; }
th:last-child { border-radius: 0 8px 8px 0; }
td { padding: 11px; border-bottom: 1px solid var(--border); }
td:first-child { display: flex; align-items: center; gap: 7px; font-weight: 630; }
tr:last-child td { border: 0; }
.event-list { display: flex; flex-direction: column; }
.event-row { display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 12px; min-height: 62px; border-bottom: 1px solid var(--border); }
.event-row:last-child { border: 0; }
.event-row__icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; background: var(--surface-muted); color: var(--text-muted); }
.event-row__icon--succeeded, .event-row__icon--info { color: var(--success); background: var(--success-soft); }
.event-row__icon--failed, .event-row__icon--error { color: var(--danger); background: var(--danger-soft); }
.event-row__icon--warning { color: var(--warning); background: var(--warning-soft); }
.event-row strong { font-size: .74rem; }
.event-row p { font-size: .64rem; }
.event-row > div:last-child { display: flex; align-items: flex-end; flex-direction: column; gap: 3px; color: var(--text-muted); font-size: .59rem; }
.health-card { min-height: 164px; }
.health-card__top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.health-card__top > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; color: var(--accent); background: var(--accent-soft); }
.health-card h2 { font-size: .83rem; }
.health-card p { min-height: 38px; margin-bottom: 10px; font-size: .64rem; }
.action-list { display: flex; flex-direction: column; }
.action-list button, .action-list a { width: 100%; display: flex; align-items: center; gap: 11px; padding: 10px 0; border: 0; border-bottom: 1px solid var(--border); background: transparent; text-align: left; cursor: pointer; }
.action-list button:last-child, .action-list a:last-child { border: 0; }
.action-list > * > span { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; color: var(--accent); background: var(--accent-soft); }
.action-list strong { font-size: .7rem; }
.action-list p { font-size: .61rem; }
.check-list, .icon-list { display: flex; flex-direction: column; gap: 11px; margin: 0; padding: 0; list-style: none; }
.check-list li, .icon-list li { display: flex; align-items: center; gap: 8px; color: var(--text-muted); font-size: .68rem; }
.check-list svg { color: var(--success); }
.icon-list svg { color: var(--accent); }
.backup-list { display: flex; flex-direction: column; }
.backup-row { display: grid; grid-template-columns: auto minmax(0,1fr) auto auto; align-items: center; gap: 11px; padding: 11px 0; border-bottom: 1px solid var(--border); }
.backup-row:last-child { border: 0; }
.backup-row > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 9px; color: var(--accent); background: var(--accent-soft); }
.backup-row strong { font-size: .71rem; }
.backup-row p { font-size: .61rem; }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.setting-select { display: grid; grid-template-columns: auto minmax(0,1fr); align-items: center; gap: 9px; color: var(--text-muted); }
.theme-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
.theme-options button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 58px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--text-muted); cursor: pointer; font-size: .66rem; }
.theme-options button.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.about-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; padding: 23px; border-radius: var(--radius); background: radial-gradient(circle at 13% 20%, rgba(123,131,255,.28), transparent 28%), linear-gradient(130deg, #171e43, #101526); color: #fff; box-shadow: var(--shadow-lg); }
.about-hero > span { width: 58px; height: 58px; display: grid; place-items: center; border-radius: 17px; background: rgba(255,255,255,.1); color: #aeb4ff; }
.about-hero h2 { font-size: 1.2rem; }
.about-hero p { color: #a7b0c9; font-size: .76rem; }
.credits { margin-top: 16px; }
.empty-state { min-height: 240px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 30px; text-align: center; }
.empty-state__icon { width: 48px; height: 48px; display: grid; place-items: center; margin-bottom: 13px; border-radius: 14px; color: var(--accent); background: var(--accent-soft); }
.empty-state h2 { font-size: .92rem; }
.empty-state p { max-width: 390px; margin-bottom: 15px; font-size: .72rem; }
.skeleton { width: 100%; height: 12px; border-radius: 6px; background: linear-gradient(90deg, var(--surface-muted), var(--border), var(--surface-muted)); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
.skeleton--short { width: 36%; }
.skeleton--medium { width: 68%; }
.skeleton-card { min-height: 140px; display: flex; flex-direction: column; gap: 17px; }
@keyframes shimmer { to { background-position: -200% 0; } }
.dialog { width: min(470px, calc(100vw - 30px)); border: 1px solid var(--border); border-radius: 16px; padding: 23px; background: var(--surface-raised); color: var(--text); box-shadow: var(--shadow-lg); }
.dialog::backdrop { background: rgba(5,8,15,.65); backdrop-filter: blur(3px); }
.dialog h2 { font-size: 1.05rem; }
.dialog p { font-size: .75rem; }
.dialog__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 23px; }
.toasts { position: fixed; right: 20px; bottom: 20px; z-index: 100; width: min(370px, calc(100vw - 30px)); display: flex; flex-direction: column; gap: 8px; }
.toast { display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 9px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); box-shadow: var(--shadow-lg); font-size: .7rem; }
.toast--success > svg { color: var(--success); }
.toast--danger > svg { color: var(--danger); }
.toast .icon-button { width: 28px; height: 28px; }
.setup-shell { min-height: 100vh; display: grid; grid-template-columns: 260px minmax(0,1fr); background: var(--bg); }
.setup-sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; background: var(--sidebar); color: #fff; }
.setup-sidebar ol { margin: 0; padding: 8px 14px 30px; list-style: none; }
.setup-sidebar li button { width: 100%; min-height: 47px; display: flex; align-items: center; gap: 10px; border: 0; border-radius: 9px; padding: 5px 10px; background: transparent; color: #78839b; text-align: left; cursor: pointer; }
.setup-sidebar li button > span { width: 27px; height: 27px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid #303a51; border-radius: 8px; }
.setup-sidebar li button div { display: flex; flex-direction: column; }
.setup-sidebar li small { color: #606b84; font-size: .54rem; }
.setup-sidebar li strong { font-size: .69rem; }
.setup-sidebar li.active button { color: #fff; background: rgba(111,120,255,.14); }
.setup-sidebar li.active button > span { border-color: #767fff; background: #5964f3; }
.setup-sidebar li.complete button { color: #9da7bd; }
.setup-sidebar li.complete button > span { border-color: rgba(63,208,161,.35); background: rgba(63,208,161,.15); color: #51d8ad; }
.setup-main { min-width: 0; display: flex; flex-direction: column; align-items: center; padding: 30px clamp(20px, 5vw, 70px); }
.setup-progress { width: min(820px, 100%); display: flex; align-items: center; gap: 13px; margin-bottom: 32px; color: var(--text-muted); font-size: .65rem; }
.setup-progress > div { flex: 1; height: 4px; overflow: hidden; border-radius: 999px; background: var(--border); }
.setup-progress i { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .3s ease; }
.setup-panel { width: min(820px, 100%); margin: auto 0; padding: clamp(24px, 4vw, 48px); border: 1px solid var(--border); border-radius: 22px; background: var(--surface); box-shadow: var(--shadow-lg); }
.setup-welcome { text-align: center; }
.setup-welcome > p, .setup-panel > p { max-width: 610px; margin: 0 auto 23px; font-size: .82rem; }
.setup-hero-icon { width: 65px; height: 65px; display: grid; place-items: center; margin: 0 auto 17px; border-radius: 19px; color: #fff; background: linear-gradient(145deg, #747cff, #4d58e9); box-shadow: 0 12px 30px rgba(89,100,243,.32); }
.setup-welcome .badge, .setup-panel > .badge { margin: 0 auto 15px; }
.setup-welcome h1, .setup-panel > h1 { text-align: center; }
.setup-promises { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 26px 0; text-align: left; }
.setup-promises > div { display: flex; align-items: flex-start; gap: 9px; padding: 11px; border: 1px solid var(--border); border-radius: 11px; }
.setup-promises svg { flex: 0 0 auto; color: var(--accent); }
.setup-promises span { display: flex; flex-direction: column; color: var(--text-muted); font-size: .59rem; }
.setup-promises strong { color: var(--text); font-size: .65rem; }
.setup-title { display: flex; align-items: center; gap: 14px; margin-bottom: 26px; }
.setup-title > span { width: 47px; height: 47px; display: grid; place-items: center; border-radius: 13px; color: var(--accent); background: var(--accent-soft); }
.setup-title h1 { margin-bottom: 4px; font-size: 1.45rem; }
.setup-title p { font-size: .7rem; }
.setup-content { min-height: 260px; }
.setup-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--border); }
.setup-actions .button:first-child { margin-right: auto; }
.setup-health { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 35px; text-align: center; }
.setup-health p { max-width: 480px; font-size: .7rem; }
.choice-card { position: relative; display: grid; grid-template-columns: auto auto minmax(0,1fr); align-items: center; gap: 12px; margin-bottom: 10px; padding: 14px; border: 1px solid var(--border); border-radius: 12px; cursor: pointer; }
.choice-card:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.choice-card > span { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; color: var(--accent); background: var(--surface); }
.choice-card input { accent-color: var(--accent); }
.choice-card strong { font-size: .72rem; }
.choice-card p { font-size: .62rem; }
.status-list { display: flex; flex-direction: column; }
.status-list > div { display: flex; align-items: center; gap: 11px; padding: 11px 0; border-bottom: 1px solid var(--border); }
.status-list > div:last-child { border: 0; }
.status-list > div > span { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 8px; font-size: .65rem; font-weight: 800; }
.status-list .ok { color: var(--success); background: var(--success-soft); }
.status-list .bad { color: var(--danger); background: var(--danger-soft); }
.status-list strong { font-size: .68rem; overflow-wrap: anywhere; }
.status-list p { font-size: .6rem; }
.recovery { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 8px; padding: 11px; border-radius: 10px; background: var(--warning-soft); color: var(--warning); }
.recovery svg { flex: 0 0 auto; }
.recovery strong { font-size: .67rem; text-transform: capitalize; }
.recovery p { color: color-mix(in srgb, var(--warning) 68%, var(--text)); font-size: .61rem; }
.report-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 25px 0; }
.report-summary div { display: flex; flex-direction: column; align-items: center; padding: 13px 8px; border: 1px solid var(--border); border-radius: 11px; }
.report-summary strong { font-size: 1rem; }
.report-summary span { color: var(--text-muted); font-size: .57rem; }
.app-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 13px; }
.app-loading p { font-size: .75rem; }
.login-shell { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, var(--accent-soft), var(--bg) 44%); }
.login-panel { width: min(100%, 430px); display: grid; gap: 24px; padding: 36px; border: 1px solid var(--border); border-radius: 22px; background: var(--surface-raised); box-shadow: var(--shadow-lg); }
.login-panel form { display: grid; gap: 10px; }
.login-panel label { font-size: .8rem; font-weight: 700; }
.login-panel input { width: 100%; padding: 12px 13px; color: var(--text); border: 1px solid var(--border-strong); border-radius: 10px; background: var(--surface); }
.login-panel .button { margin-top: 6px; }
.login-error { color: var(--danger); font-size: .78rem; }
@media (max-width: 1180px) {
.grid--stats, .grid--health { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid--devices, .grid--scenes { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.automation-row__meta { flex-wrap: wrap; justify-content: flex-end; }
.argb-zone-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.app-shell, .app-shell--collapsed { display: block; }
.sidebar, .app-shell--collapsed .sidebar { width: 255px; transform: translateX(-100%); box-shadow: var(--shadow-lg); }
.sidebar--open { transform: translateX(0); }
.app-shell--collapsed .brand__text, .app-shell--collapsed .nav-label, .app-shell--collapsed .nav-link span, .app-shell--collapsed .sidebar__collapse span { display: initial; }
.app-shell--collapsed .nav-link { justify-content: flex-start; padding: 0 11px; }
.sidebar__collapse { display: none; }
.sidebar__close { display: inline-grid !important; }
.sidebar-backdrop { position: fixed; inset: 0; z-index: 35; border: 0; background: rgba(3,6,13,.62); backdrop-filter: blur(2px); }
.mobile-menu { display: inline-grid !important; }
.topbar__status { margin-left: 0; }
.topbar .icon-button:last-child { margin-left: auto; }
.dashboard-grid, .detail-grid, .settings-grid { grid-template-columns: 1fr; }
.grid--cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.argb-zone-list { grid-template-columns: 1fr; }
.setup-shell { display: block; }
.setup-sidebar { position: static; height: auto; overflow: visible; }
.setup-sidebar ol { display: none; }
.setup-main { min-height: calc(100vh - 72px); }
}
@media (max-width: 650px) {
.page { padding: 22px 15px 35px; }
.topbar { padding: 0 15px; }
.topbar__device-count { display: none; }
.page-header { flex-direction: column; margin-bottom: 20px; }
.page-header__actions { width: 100%; justify-content: flex-start; }
.page-header__actions .button { flex: 1; }
.grid--stats, .grid--cards, .grid--devices, .grid--scenes, .grid--connectors, .grid--health { grid-template-columns: 1fr; }
.toolbar { align-items: stretch; flex-wrap: wrap; }
.search { flex-basis: 100%; max-width: none; }
.segmented { flex: 1; }
.segmented button { flex: 1; }
.color-control { grid-template-columns: 48px minmax(0,1fr); }
.color-control .button { grid-column: 1 / -1; }
.device-row { grid-template-columns: 64px minmax(0,1fr); }
.device-row .device-card__content { display: block; }
.device-row .device-card__meta { margin-top: 7px; }
.device-row__extra { display: none; }
.detail-status > span:last-child { width: 100%; margin-left: 0; }
.form-grid, .form-grid--two, .form-grid--three, .check-grid, .scene-target-form { grid-template-columns: 1fr; }
.automation-row { align-items: flex-start; flex-wrap: wrap; }
.automation-row__main { display: block; }
.automation-row__meta { justify-content: flex-start; margin-top: 9px; }
.automation-row__actions { width: 100%; justify-content: flex-end; }
.run-row { grid-template-columns: 80px 1fr; gap: 7px; padding: 8px 0; }
.run-row__error { grid-column: 1 / -1; }
.backup-row { grid-template-columns: auto minmax(0,1fr); }
.backup-row > .badge, .backup-row > .button { grid-column: 2; justify-self: start; }
.event-row { grid-template-columns: auto minmax(0,1fr); padding: 8px 0; }
.event-row > div:last-child { grid-column: 2; align-items: flex-start; flex-direction: row; flex-wrap: wrap; }
.setup-main { padding: 20px 12px; }
.setup-panel { padding: 22px 17px; border-radius: 17px; }
.setup-progress { margin-bottom: 18px; }
.setup-promises, .report-summary { grid-template-columns: 1fr 1fr; }
.setup-actions { flex-wrap: wrap; }
.setup-actions .button:first-child { margin-right: 0; }
.setup-actions .button { flex: 1; }
.mock-banner { justify-content: flex-start; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
+38
View File
@@ -0,0 +1,38 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, type RenderResult } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { ReactElement } from "react";
import { ToastProvider } from "../components/Toast";
import { I18nProvider } from "../lib/i18n";
import { ThemeProvider } from "../lib/theme";
export function renderApp(element: ReactElement, route = "/"): RenderResult {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<MemoryRouter initialEntries={[route]}>{element}</MemoryRouter>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
</QueryClientProvider>,
);
}
export function jsonResponse(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), {
status,
headers: { "Content-Type": "application/json", "X-Request-ID": "test-request" },
});
}
export function requestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
return input instanceof URL ? input.href : input.url;
}
export function requestJson(init?: RequestInit): unknown {
return typeof init?.body === "string" ? JSON.parse(init.body) as unknown : null;
}
+36
View File
@@ -0,0 +1,36 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
beforeEach(() => {
vi.mocked(window.matchMedia).mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
class EventSourceStub {
addEventListener = vi.fn();
close = vi.fn();
onerror: (() => void) | null = null;
constructor(public readonly url: string) {}
}
vi.stubGlobal("EventSource", EventSourceStub);
vi.stubGlobal("crypto", { randomUUID: () => "00000000-0000-4000-8000-000000000001" });
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts", "eslint.config.js"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:8080",
"/health": "http://127.0.0.1:8080",
},
},
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
css: true,
},
});