"""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