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