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
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:
@@ -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(),
|
||||
}
|
||||
Reference in New Issue
Block a user