from __future__ import annotations import asyncio import logging import time from collections import deque from datetime import UTC, datetime from app.core.config import get_settings from app.dmx.backends import DmxBackend, SimulatorDmxBackend from app.dmx.frame import DmxFrame, FrameLayer, merge_layers from app.telemetry.service import TelemetryService logger = logging.getLogger(__name__) class DmxEngine: def __init__(self, telemetry: TelemetryService, backend: DmxBackend | None = None) -> None: self._settings = get_settings() self.telemetry = telemetry self.backend = backend or SimulatorDmxBackend() self.layers: dict[str, FrameLayer] = {} self.blackout = False self.freeze = False self.master = 255 self.current_frame = DmxFrame() self.current_frames: dict[int, DmxFrame] = {1: DmxFrame()} self._task: asyncio.Task[None] | None = None self._running = False self._frame_times: deque[float] = deque(maxlen=120) @property def is_running(self) -> bool: return self._running async def start(self) -> None: if self._running: return await self.backend.startup() self._running = True self._task = asyncio.create_task(self._loop()) async def stop(self) -> None: self._running = False if self._task is not None: self._task.cancel() try: await self._task except asyncio.CancelledError: pass self._task = None await self.backend.shutdown() def configure_backend(self, backend: DmxBackend) -> None: self.backend = backend async def replace_backend(self, backend: DmxBackend) -> None: was_running = self._running if was_running: await self.stop() self.backend = backend selected_universe = self.backend.get_status().selected_universe self.current_frame = DmxFrame(universe=selected_universe) self.current_frames = {selected_universe: self.current_frame.copy()} if was_running: await self.start() async def _loop(self) -> None: interval = 1 / self._settings.target_fps while self._running: started = time.perf_counter() try: if not self.freeze: frames = merge_layers(list(self.layers.values()), blackout=self.blackout) if self.master < 255: for frame in frames.values(): frame.values = [int((value / 255) * self.master) for value in frame.values] self.current_frames = {universe: frame.copy() for universe, frame in frames.items()} selected_universe = self.backend.get_status().selected_universe frame = self.current_frames.get(selected_universe, DmxFrame(universe=selected_universe)) self.current_frame = frame.copy() await self.backend.send_frame(frame) self.telemetry.record_send_success(self.backend.get_status()) self._frame_times.append(time.perf_counter() - started) except Exception as exc: logger.exception("DMX send failed") self.telemetry.record_send_failure(str(exc), self.backend.get_status()) elapsed = time.perf_counter() - started await asyncio.sleep(max(0, interval - elapsed)) def set_layer(self, layer: FrameLayer) -> None: self.layers[layer.name] = layer def remove_layer(self, name: str) -> None: self.layers.pop(name, None) def trigger_blackout(self) -> None: self.blackout = True def release_blackout(self) -> None: self.blackout = False def get_frame(self, universe: int) -> DmxFrame: frame = self.current_frames.get(universe) if frame is None: return DmxFrame(universe=universe) return frame.copy() def snapshot(self) -> dict[str, object]: status = self.backend.get_status() fps = 0.0 if self._frame_times: average = sum(self._frame_times) / len(self._frame_times) if average > 0: fps = 1 / average return { "backend": status.backend_name, "connected": status.connected, "degraded": status.degraded, "last_error": status.last_error, "last_successful_frame": status.last_successful_frame.isoformat() if status.last_successful_frame is not None else None, "frames_sent": status.frames_sent, "send_errors": status.send_errors, "reconnect_count": status.reconnect_count, "selected_universe": status.selected_universe, "selected_output_port": status.selected_output_port, "available_universes": sorted(self.current_frames), "blackout": self.blackout, "freeze": self.freeze, "master": self.master, "fps": round(fps, 2), "frame": self.current_frame.values, "source_map": self.current_frame.source_map, "updated_at": datetime.now(UTC).isoformat(), }