from __future__ import annotations from dataclasses import dataclass, field import time from .models import FrameSnapshot, MappingSpec, SceneState from .output.ddp import DDPClient from .renderer import FastRenderer @dataclass(slots=True) class OutputSettings: output_fps: float = 40.0 preview_fps: float = 12.0 keepalive_seconds: float = 0.35 delta_sending: bool = True preview_enabled: bool = True @dataclass(slots=True) class RuntimeDiagnostics: tile_count: int controller_count: int total_leds: int output_frames: int = 0 preview_frames: int = 0 packets_built: int = 0 packets_sent: int = 0 changed_controller_frames: int = 0 keepalive_controller_frames: int = 0 stale_output_frames: int = 0 last_render_ms: float = 0.0 last_tick_at: float = 0.0 last_output_at: float = 0.0 last_preview_at: float = 0.0 @dataclass(slots=True) class TickResult: timestamp: float output_due: bool preview_due: bool output_snapshot: FrameSnapshot | None = None preview_snapshot: FrameSnapshot | None = None changed_payloads: dict[str, bytes] = field(default_factory=dict) packet_count: int = 0 class RealtimeEngine: def __init__( self, mapping: MappingSpec, scene: SceneState | None = None, settings: OutputSettings | None = None, ddp_client: DDPClient | None = None, ) -> None: self.mapping = mapping self.scene = scene or SceneState() self.settings = settings or OutputSettings() self.renderer = FastRenderer(mapping) self.ddp_client = ddp_client or DDPClient() renderer_stats = self.renderer.stats() self.diagnostics = RuntimeDiagnostics( tile_count=renderer_stats.tile_count, controller_count=renderer_stats.controller_count, total_leds=renderer_stats.total_leds, ) self._last_output_at: float | None = None self._last_preview_at: float | None = None self._last_sent_payloads: dict[str, bytes] = {} self._last_sent_at: dict[str, float] = {} self._latest_snapshot: FrameSnapshot | None = None self._force_output = True self._force_preview = True def set_scene(self, scene: SceneState) -> None: self.scene = scene self._force_output = True self._force_preview = True def request_preview_refresh(self) -> None: self._force_preview = True def latest_snapshot(self) -> FrameSnapshot | None: return self._latest_snapshot def tick(self, now: float | None = None) -> TickResult: timestamp = time.perf_counter() if now is None else float(now) output_due = self._is_due(self._last_output_at, self._interval(self.settings.output_fps), timestamp, self._force_output) preview_due = self.settings.preview_enabled and self._is_due( self._last_preview_at, self._interval(self.settings.preview_fps), timestamp, self._force_preview, ) if not output_due and not preview_due: return TickResult(timestamp=timestamp, output_due=False, preview_due=False) render_started = time.perf_counter() snapshot = self.renderer.render(self.scene, timestamp=timestamp) self.diagnostics.last_render_ms = (time.perf_counter() - render_started) * 1000.0 self.diagnostics.last_tick_at = timestamp self._latest_snapshot = snapshot changed_payloads: dict[str, bytes] = {} packet_count = 0 output_snapshot: FrameSnapshot | None = None preview_snapshot: FrameSnapshot | None = None if output_due: changed_payloads, changed_count, keepalive_count = self._select_output_payloads( snapshot.controller_payloads, timestamp, ) output_snapshot = snapshot self._last_output_at = timestamp self._force_output = False self.diagnostics.output_frames += 1 self.diagnostics.changed_controller_frames += changed_count self.diagnostics.keepalive_controller_frames += keepalive_count self.diagnostics.last_output_at = timestamp if changed_payloads: packet_count = self.ddp_client.count_packets(changed_payloads) self.diagnostics.packets_built += packet_count else: self.diagnostics.stale_output_frames += 1 if preview_due: preview_snapshot = snapshot self._last_preview_at = timestamp self._force_preview = False self.diagnostics.preview_frames += 1 self.diagnostics.last_preview_at = timestamp return TickResult( timestamp=timestamp, output_due=output_due, preview_due=preview_due, output_snapshot=output_snapshot, preview_snapshot=preview_snapshot, changed_payloads=changed_payloads, packet_count=packet_count, ) def send_due(self, now: float | None = None) -> TickResult: result = self.tick(now=now) if result.changed_payloads: sent = self.ddp_client.send(result.changed_payloads) self.diagnostics.packets_sent += sent return result def _interval(self, fps: float) -> float: if fps <= 0: return 0.0 return 1.0 / float(fps) def _is_due(self, last_at: float | None, interval: float, now: float, forced: bool) -> bool: if forced or last_at is None: return True if interval <= 0: return True return (now - last_at) >= max(0.0, interval - 1e-9) def _select_output_payloads(self, payloads: dict[str, bytes], timestamp: float) -> tuple[dict[str, bytes], int, int]: if not self.settings.delta_sending: for host, payload in payloads.items(): self._last_sent_payloads[host] = payload self._last_sent_at[host] = timestamp return dict(payloads), len(payloads), 0 changed_payloads: dict[str, bytes] = {} changed_count = 0 keepalive_count = 0 keepalive_seconds = max(0.0, float(self.settings.keepalive_seconds)) for host, payload in payloads.items(): previous = self._last_sent_payloads.get(host) last_sent_at = self._last_sent_at.get(host) unchanged = previous == payload keepalive_due = ( keepalive_seconds > 0.0 and unchanged and last_sent_at is not None and (timestamp - last_sent_at) >= keepalive_seconds ) if previous is None or not unchanged or keepalive_due: changed_payloads[host] = payload self._last_sent_payloads[host] = payload self._last_sent_at[host] = timestamp if keepalive_due and unchanged: keepalive_count += 1 else: changed_count += 1 return changed_payloads, changed_count, keepalive_count