Publish LumaOps source

This commit is contained in:
LumaOps release export committed 2026-09-03 01:18:36 +02:00
commit 7f1c0e5f71
2363 files changed
+501543

No files matched your search

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