Publish curated LumaOps source from c85ee9ef8f1454f5c305398902afbb9e6738de40
Managed validation / full (push) Failing after 8s
Managed validation / full (push) Failing after 8s
This commit is contained in:
1 parent
91f8b3e3c1
commit
def7920856
2271 files changed
+501225
-501178
No files matched your search
@@ -1,445 +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
|
||||
"""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
|
||||
Reference in new issue
Block a user