446 lines
18 KiB
Python
446 lines
18 KiB
Python
"""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
|