Update docs and screenshots
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s

This commit is contained in:
2026-07-25 10:26:29 +02:00
parent d6cda77aaf
commit 1f110866f5
170 changed files with 24657 additions and 3 deletions
+137
View File
@@ -0,0 +1,137 @@
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(),
}