First upload, 18 controller version
This commit is contained in:
2
app/output/__init__.py
Normal file
2
app/output/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Output backend interfaces and implementations."""
|
||||
|
||||
BIN
app/output/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/output/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/output/__pycache__/artnet.cpython-310.pyc
Normal file
BIN
app/output/__pycache__/artnet.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/output/__pycache__/base.cpython-310.pyc
Normal file
BIN
app/output/__pycache__/base.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/output/__pycache__/ddp.cpython-310.pyc
Normal file
BIN
app/output/__pycache__/ddp.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/output/__pycache__/manager.cpython-310.pyc
Normal file
BIN
app/output/__pycache__/manager.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/output/__pycache__/preview.cpython-310.pyc
Normal file
BIN
app/output/__pycache__/preview.cpython-310.pyc
Normal file
Binary file not shown.
82
app/output/artnet.py
Normal file
82
app/output/artnet.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.core.types import PreviewFrame
|
||||
|
||||
from .base import OutputBackend, OutputResult
|
||||
|
||||
|
||||
class ArtnetOutputBackend(OutputBackend):
|
||||
backend_id = "artnet"
|
||||
display_name = "Art-Net"
|
||||
supports_live_output = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._socket: socket.socket | None = None
|
||||
self._sequence = 0
|
||||
|
||||
def start(self) -> None:
|
||||
if self._socket is None:
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
|
||||
def send_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
|
||||
try:
|
||||
self.start()
|
||||
packets = self._build_packets(config, frame)
|
||||
for host, subnet, universe, payload in packets:
|
||||
packet = self._create_artnet_packet(subnet=subnet, universe=universe, payload=payload)
|
||||
self._socket.sendto(packet, (host, 6454))
|
||||
device_count = len({host for host, _, _, _ in packets})
|
||||
return OutputResult(
|
||||
ok=True,
|
||||
message=f"Sent {len(packets)} Art-Net packet(s).",
|
||||
packets_sent=len(packets),
|
||||
device_count=device_count,
|
||||
)
|
||||
except OSError as exc:
|
||||
return OutputResult(ok=False, message=f"Art-Net send failed: {exc}")
|
||||
|
||||
def _build_packets(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> list[tuple[str, int, int, bytes]]:
|
||||
grouped: dict[tuple[str, int, int], bytearray] = defaultdict(lambda: bytearray(512))
|
||||
|
||||
for tile in config.sorted_tiles():
|
||||
tile_frame = frame.tiles.get(tile.tile_id)
|
||||
if not tile.enabled or tile_frame is None:
|
||||
continue
|
||||
host = tile.controller_ip or "127.0.0.1"
|
||||
key = (host, tile.subnet, tile.universe)
|
||||
universe = grouped[key]
|
||||
for segment in tile.segments:
|
||||
segment_colors = tile_frame.led_pixels.get(segment.name, [])
|
||||
base_index = max(0, segment.start_channel - 1)
|
||||
for led_index, color in enumerate(segment_colors):
|
||||
channel_index = base_index + led_index * 3
|
||||
if channel_index + 2 >= len(universe):
|
||||
break
|
||||
red, green, blue = color.to_8bit_tuple()
|
||||
universe[channel_index] = red
|
||||
universe[channel_index + 1] = green
|
||||
universe[channel_index + 2] = blue
|
||||
|
||||
return [(host, subnet, universe, bytes(payload)) for (host, subnet, universe), payload in grouped.items()]
|
||||
|
||||
def _create_artnet_packet(self, subnet: int, universe: int, payload: bytes) -> bytes:
|
||||
self._sequence = (self._sequence + 1) % 256
|
||||
header = b"Art-Net\x00"
|
||||
opcode = struct.pack("<H", 0x5000)
|
||||
prot_ver = struct.pack(">H", 14)
|
||||
sequence = struct.pack("B", self._sequence)
|
||||
physical = struct.pack("B", 0)
|
||||
port_address = ((subnet & 0x0F) << 4) | (universe & 0x0F)
|
||||
subnet_universe = struct.pack("<H", port_address)
|
||||
length = struct.pack(">H", len(payload))
|
||||
return header + opcode + prot_ver + sequence + physical + subnet_universe + length + payload
|
||||
70
app/output/base.py
Normal file
70
app/output/base.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.core.types import PreviewFrame
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputResult:
|
||||
ok: bool
|
||||
message: str = ""
|
||||
packets_sent: int = 0
|
||||
device_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControllerMetrics:
|
||||
fps: float | None = None
|
||||
live_devices: int = 0
|
||||
sampled_devices: int = 0
|
||||
total_devices: int = 0
|
||||
source: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputDiagnostics:
|
||||
backend_id: str
|
||||
backend_name: str
|
||||
output_enabled: bool
|
||||
worker_running: bool
|
||||
target_fps: float
|
||||
send_fps: float = 0.0
|
||||
last_send_time_ms: float = 0.0
|
||||
frames_submitted: int = 0
|
||||
frames_sent: int = 0
|
||||
stale_frame_drops: int = 0
|
||||
send_failures: int = 0
|
||||
packets_last_frame: int = 0
|
||||
devices_last_frame: int = 0
|
||||
packets_sent_total: int = 0
|
||||
last_message: str = ""
|
||||
send_budget_misses: int = 0
|
||||
last_schedule_slip_ms: float = 0.0
|
||||
controller_fps: float | None = None
|
||||
controller_live_devices: int = 0
|
||||
controller_sampled_devices: int = 0
|
||||
controller_total_devices: int = 0
|
||||
controller_source: str = ""
|
||||
|
||||
|
||||
class OutputBackend(ABC):
|
||||
backend_id: str
|
||||
display_name: str
|
||||
supports_live_output: bool = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Hook for backends that need to allocate resources."""
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Hook for backends that need to release resources."""
|
||||
|
||||
def controller_metrics(self, config: InfinityMirrorConfig) -> ControllerMetrics | None:
|
||||
"""Optional hook for backends that can report controller-side receive metrics."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def send_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
|
||||
raise NotImplementedError
|
||||
205
app/output/ddp.py
Normal file
205
app/output/ddp.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
from app.config.models import InfinityMirrorConfig, TileConfig
|
||||
from app.core.types import PreviewFrame, TileFrame
|
||||
from app.network.wled import fetch_wled_info
|
||||
|
||||
from .base import ControllerMetrics, OutputBackend, OutputResult
|
||||
|
||||
DDP_DEFAULT_PORT = 4048
|
||||
DDP_HEADER_LENGTH = 10
|
||||
DDP_MAX_DATA_LENGTH = 1440
|
||||
DDP_VERSION_1 = 0x40
|
||||
DDP_PUSH_FLAG = 0x01
|
||||
DDP_RGB888 = 0x0B
|
||||
DDP_DEFAULT_DESTINATION_ID = 1
|
||||
WLED_INFO_TIMEOUT_S = 0.25
|
||||
WLED_INFO_SOURCE = "WLED /json/info leds.fps (live only)"
|
||||
DDP_UNCHANGED_HOST_KEEPALIVE_S = 0.35
|
||||
|
||||
|
||||
class DdpOutputBackend(OutputBackend):
|
||||
backend_id = "ddp"
|
||||
display_name = "DDP (WLED)"
|
||||
supports_live_output = True
|
||||
|
||||
def __init__(self, port: int = DDP_DEFAULT_PORT, destination_id: int = DDP_DEFAULT_DESTINATION_ID) -> None:
|
||||
self.port = int(port)
|
||||
self.destination_id = int(destination_id)
|
||||
self._socket: socket.socket | None = None
|
||||
self._sequence = 0
|
||||
self._last_payload_by_host: dict[str, bytes] = {}
|
||||
self._last_payload_sent_at: dict[str, float] = {}
|
||||
|
||||
def start(self) -> None:
|
||||
if self._socket is None:
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
self._last_payload_by_host.clear()
|
||||
self._last_payload_sent_at.clear()
|
||||
|
||||
def controller_metrics(self, config: InfinityMirrorConfig) -> ControllerMetrics | None:
|
||||
hosts = self._controller_hosts(config)
|
||||
if not hosts:
|
||||
return ControllerMetrics(source=WLED_INFO_SOURCE)
|
||||
|
||||
fps_values: list[float] = []
|
||||
sampled_devices = 0
|
||||
live_devices = 0
|
||||
for host in hosts:
|
||||
info = self._fetch_wled_info(host)
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
sampled_devices += 1
|
||||
live = bool(info.get("live"))
|
||||
leds = info.get("leds")
|
||||
fps = leds.get("fps") if isinstance(leds, dict) else None
|
||||
if not live:
|
||||
continue
|
||||
live_devices += 1
|
||||
if isinstance(fps, (int, float)):
|
||||
fps_values.append(float(fps))
|
||||
elif isinstance(fps, str):
|
||||
try:
|
||||
fps_values.append(float(fps))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
average_fps = (sum(fps_values) / len(fps_values)) if fps_values else None
|
||||
return ControllerMetrics(
|
||||
fps=average_fps,
|
||||
live_devices=live_devices,
|
||||
sampled_devices=sampled_devices,
|
||||
total_devices=len(hosts),
|
||||
source=WLED_INFO_SOURCE,
|
||||
)
|
||||
|
||||
def send_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
|
||||
try:
|
||||
self.start()
|
||||
packets = self._build_packets(config, frame)
|
||||
for host, sequence, offset, payload, last in packets:
|
||||
packet = self._create_ddp_packet(sequence=sequence, offset=offset, payload=payload, last=last)
|
||||
self._socket.sendto(packet, (host, self.port))
|
||||
device_count = len({host for host, _, _, _, _ in packets})
|
||||
return OutputResult(
|
||||
ok=True,
|
||||
message=f"Sent {len(packets)} DDP packet(s).",
|
||||
packets_sent=len(packets),
|
||||
device_count=device_count,
|
||||
)
|
||||
except OSError as exc:
|
||||
return OutputResult(ok=False, message=f"DDP send failed: {exc}")
|
||||
|
||||
def _build_packets(
|
||||
self,
|
||||
config: InfinityMirrorConfig,
|
||||
frame: PreviewFrame,
|
||||
) -> list[tuple[str, int, int, bytes, bool]]:
|
||||
packets: list[tuple[str, int, int, bytes, bool]] = []
|
||||
sequence = self._next_sequence()
|
||||
payloads = self._filter_redundant_payloads(self._build_device_payloads(config, frame), time.perf_counter())
|
||||
|
||||
for host, payload in payloads.items():
|
||||
for offset in range(0, len(payload), DDP_MAX_DATA_LENGTH):
|
||||
chunk = payload[offset : offset + DDP_MAX_DATA_LENGTH]
|
||||
last = offset + DDP_MAX_DATA_LENGTH >= len(payload)
|
||||
packets.append((host, sequence, offset, chunk, last))
|
||||
return packets
|
||||
|
||||
def _build_device_payloads(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> dict[str, bytes]:
|
||||
host_payloads: dict[str, bytearray] = {}
|
||||
|
||||
for tile in config.sorted_tiles():
|
||||
tile_frame = frame.tiles.get(tile.tile_id)
|
||||
if not tile.enabled or tile_frame is None:
|
||||
continue
|
||||
|
||||
payload = self._tile_payload(tile, tile_frame)
|
||||
if not payload:
|
||||
continue
|
||||
|
||||
host = tile.controller_ip or "127.0.0.1"
|
||||
host_payloads.setdefault(host, bytearray()).extend(payload)
|
||||
|
||||
return {host: bytes(payload) for host, payload in host_payloads.items()}
|
||||
|
||||
def _tile_payload(self, tile: TileConfig, tile_frame: TileFrame) -> bytes:
|
||||
required_length = tile.led_total * 3
|
||||
for segment in tile.segments:
|
||||
segment_colors = tile_frame.led_pixels.get(segment.name, [])
|
||||
segment_end = max(0, segment.start_channel - 1) + (len(segment_colors) * 3)
|
||||
required_length = max(required_length, segment_end)
|
||||
|
||||
if required_length <= 0:
|
||||
return b""
|
||||
|
||||
payload = bytearray(required_length)
|
||||
for segment in sorted(tile.segments, key=lambda item: (item.start_channel, item.name)):
|
||||
segment_colors = tile_frame.led_pixels.get(segment.name, [])
|
||||
base_index = max(0, segment.start_channel - 1)
|
||||
for led_index, color in enumerate(segment_colors):
|
||||
channel_index = base_index + led_index * 3
|
||||
if channel_index + 2 >= len(payload):
|
||||
break
|
||||
red, green, blue = color.to_8bit_tuple()
|
||||
payload[channel_index] = red
|
||||
payload[channel_index + 1] = green
|
||||
payload[channel_index + 2] = blue
|
||||
|
||||
return bytes(payload)
|
||||
|
||||
def _create_ddp_packet(self, sequence: int, offset: int, payload: bytes, last: bool) -> bytes:
|
||||
header = struct.pack(
|
||||
"!BBBBLH",
|
||||
DDP_VERSION_1 | (DDP_PUSH_FLAG if last else 0),
|
||||
sequence,
|
||||
DDP_RGB888,
|
||||
self.destination_id,
|
||||
offset,
|
||||
len(payload),
|
||||
)
|
||||
return header + payload
|
||||
|
||||
def _next_sequence(self) -> int:
|
||||
self._sequence = (self._sequence % 15) + 1
|
||||
return self._sequence
|
||||
|
||||
def _controller_hosts(self, config: InfinityMirrorConfig) -> list[str]:
|
||||
return sorted({tile.controller_ip.strip() for tile in config.sorted_tiles() if tile.enabled and tile.controller_ip.strip()})
|
||||
|
||||
def _fetch_wled_info(self, host: str) -> dict[str, object] | None:
|
||||
result = fetch_wled_info(host, timeout_s=WLED_INFO_TIMEOUT_S)
|
||||
if result is None:
|
||||
return None
|
||||
payload, _endpoint = result
|
||||
return payload
|
||||
|
||||
def _filter_redundant_payloads(self, host_payloads: dict[str, bytes], now: float) -> dict[str, bytes]:
|
||||
filtered: dict[str, bytes] = {}
|
||||
active_hosts = set(host_payloads)
|
||||
|
||||
for stale_host in set(self._last_payload_by_host) - active_hosts:
|
||||
self._last_payload_by_host.pop(stale_host, None)
|
||||
self._last_payload_sent_at.pop(stale_host, None)
|
||||
|
||||
for host, payload in host_payloads.items():
|
||||
previous_payload = self._last_payload_by_host.get(host)
|
||||
last_sent_at = self._last_payload_sent_at.get(host, 0.0)
|
||||
unchanged = previous_payload == payload
|
||||
keepalive_due = (now - last_sent_at) >= DDP_UNCHANGED_HOST_KEEPALIVE_S
|
||||
if unchanged and not keepalive_due:
|
||||
continue
|
||||
filtered[host] = payload
|
||||
self._last_payload_by_host[host] = payload
|
||||
self._last_payload_sent_at[host] = now
|
||||
|
||||
return filtered
|
||||
391
app/output/manager.py
Normal file
391
app/output/manager.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import threading
|
||||
import time
|
||||
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.core.types import PreviewFrame
|
||||
|
||||
from .artnet import ArtnetOutputBackend
|
||||
from .base import ControllerMetrics, OutputBackend, OutputDiagnostics, OutputResult
|
||||
from .ddp import DdpOutputBackend
|
||||
from .preview import PreviewOutputBackend
|
||||
|
||||
DEFAULT_OUTPUT_FPS = 40.0
|
||||
MIN_OUTPUT_FPS = 1.0
|
||||
MAX_OUTPUT_FPS = 60.0
|
||||
class OutputManager:
|
||||
def __init__(self, target_fps: float = DEFAULT_OUTPUT_FPS) -> None:
|
||||
self.backends: dict[str, OutputBackend] = {
|
||||
PreviewOutputBackend.backend_id: PreviewOutputBackend(),
|
||||
DdpOutputBackend.backend_id: DdpOutputBackend(),
|
||||
ArtnetOutputBackend.backend_id: ArtnetOutputBackend(),
|
||||
}
|
||||
self.active_backend_id = PreviewOutputBackend.backend_id
|
||||
self.output_enabled = False
|
||||
|
||||
self._target_fps = self._clamp_target_fps(target_fps)
|
||||
self._config_snapshot: InfinityMirrorConfig | None = None
|
||||
self._config_source_id: int | None = None
|
||||
self._latest_frame: PreviewFrame | None = None
|
||||
self._latest_frame_version = 0
|
||||
self._sent_frame_version = 0
|
||||
|
||||
self._frames_submitted = 0
|
||||
self._frames_sent = 0
|
||||
self._stale_frame_drops = 0
|
||||
self._send_failures = 0
|
||||
self._packets_last_frame = 0
|
||||
self._devices_last_frame = 0
|
||||
self._packets_sent_total = 0
|
||||
self._last_send_duration_s = 0.0
|
||||
self._send_budget_misses = 0
|
||||
self._last_schedule_slip_s = 0.0
|
||||
self._last_result_message = ""
|
||||
self._send_window_started_at = 0.0
|
||||
self._send_window_count = 0
|
||||
self._send_fps = 0.0
|
||||
self._controller_metrics = ControllerMetrics(
|
||||
source="Controller-side FPS polling disabled during live output for stability.",
|
||||
)
|
||||
self._pending_messages: deque[str] = deque()
|
||||
self._last_queued_message = ""
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._condition = threading.Condition(self._lock)
|
||||
self._worker_thread: threading.Thread | None = None
|
||||
self._worker_stop_requested = False
|
||||
self._telemetry_thread: threading.Thread | None = None
|
||||
self._telemetry_stop_requested = False
|
||||
|
||||
def __del__(self) -> None: # pragma: no cover - destructor timing is interpreter-dependent
|
||||
try:
|
||||
self.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def backend_names(self) -> list[tuple[str, str]]:
|
||||
return [(backend_id, backend.display_name) for backend_id, backend in self.backends.items()]
|
||||
|
||||
def set_active_backend(self, backend_id: str) -> None:
|
||||
with self._condition:
|
||||
if backend_id not in self.backends:
|
||||
raise KeyError(f"Unknown backend: {backend_id}")
|
||||
if self.active_backend_id == backend_id:
|
||||
return
|
||||
self.active_backend_id = backend_id
|
||||
self._condition.notify_all()
|
||||
|
||||
def set_output_enabled(self, enabled: bool) -> None:
|
||||
with self._condition:
|
||||
enabled = bool(enabled)
|
||||
if self.output_enabled == enabled:
|
||||
return
|
||||
self.output_enabled = enabled
|
||||
if enabled:
|
||||
self._ensure_worker_locked()
|
||||
self._controller_metrics = ControllerMetrics(
|
||||
source="Controller-side FPS polling disabled during live output for stability.",
|
||||
)
|
||||
self._condition.notify_all()
|
||||
|
||||
def set_target_fps(self, value: float) -> None:
|
||||
with self._condition:
|
||||
target_fps = self._clamp_target_fps(value)
|
||||
if abs(target_fps - self._target_fps) < 1e-9:
|
||||
return
|
||||
self._target_fps = target_fps
|
||||
self._condition.notify_all()
|
||||
|
||||
def target_fps(self) -> float:
|
||||
with self._lock:
|
||||
return self._target_fps
|
||||
|
||||
def active_backend(self) -> OutputBackend:
|
||||
with self._lock:
|
||||
return self.backends[self.active_backend_id]
|
||||
|
||||
def update_config(self, config: InfinityMirrorConfig) -> None:
|
||||
config_snapshot = config.clone()
|
||||
with self._condition:
|
||||
self._config_snapshot = config_snapshot
|
||||
self._config_source_id = id(config)
|
||||
self._condition.notify_all()
|
||||
|
||||
def submit_frame(self, frame: PreviewFrame) -> OutputResult:
|
||||
with self._condition:
|
||||
if self.output_enabled and self._latest_frame_version > self._sent_frame_version:
|
||||
self._stale_frame_drops += 1
|
||||
self._latest_frame = frame
|
||||
self._latest_frame_version += 1
|
||||
self._frames_submitted += 1
|
||||
if self.output_enabled:
|
||||
self._ensure_worker_locked()
|
||||
self._condition.notify_all()
|
||||
message = "Frame submitted to output worker." if self.output_enabled else "Hardware output disabled."
|
||||
return OutputResult(ok=True, message=message)
|
||||
|
||||
def push_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
|
||||
with self._lock:
|
||||
config_source_id = self._config_source_id
|
||||
if config_source_id != id(config):
|
||||
self.update_config(config)
|
||||
return self.submit_frame(frame)
|
||||
|
||||
def drain_status_messages(self) -> list[str]:
|
||||
with self._lock:
|
||||
messages = list(self._pending_messages)
|
||||
self._pending_messages.clear()
|
||||
return messages
|
||||
|
||||
def diagnostics_snapshot(self) -> OutputDiagnostics:
|
||||
with self._lock:
|
||||
backend = self.backends[self.active_backend_id]
|
||||
worker_running = self.output_enabled and self._worker_thread is not None and self._worker_thread.is_alive()
|
||||
return OutputDiagnostics(
|
||||
backend_id=self.active_backend_id,
|
||||
backend_name=backend.display_name,
|
||||
output_enabled=self.output_enabled,
|
||||
worker_running=worker_running,
|
||||
target_fps=self._target_fps,
|
||||
send_fps=self._send_fps,
|
||||
last_send_time_ms=self._last_send_duration_s * 1000.0,
|
||||
frames_submitted=self._frames_submitted,
|
||||
frames_sent=self._frames_sent,
|
||||
stale_frame_drops=self._stale_frame_drops,
|
||||
send_failures=self._send_failures,
|
||||
packets_last_frame=self._packets_last_frame,
|
||||
devices_last_frame=self._devices_last_frame,
|
||||
packets_sent_total=self._packets_sent_total,
|
||||
last_message=self._last_result_message,
|
||||
send_budget_misses=self._send_budget_misses,
|
||||
last_schedule_slip_ms=self._last_schedule_slip_s * 1000.0,
|
||||
controller_fps=self._controller_metrics.fps,
|
||||
controller_live_devices=self._controller_metrics.live_devices,
|
||||
controller_sampled_devices=self._controller_metrics.sampled_devices,
|
||||
controller_total_devices=self._controller_metrics.total_devices,
|
||||
controller_source=self._controller_metrics.source,
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
thread: threading.Thread | None = None
|
||||
telemetry_thread: threading.Thread | None = None
|
||||
with self._condition:
|
||||
self.output_enabled = False
|
||||
self._worker_stop_requested = True
|
||||
self._telemetry_stop_requested = True
|
||||
thread = self._worker_thread
|
||||
telemetry_thread = self._telemetry_thread
|
||||
self._condition.notify_all()
|
||||
if thread is not None:
|
||||
thread.join(timeout=1.0)
|
||||
if telemetry_thread is not None:
|
||||
telemetry_thread.join(timeout=1.0)
|
||||
with self._condition:
|
||||
if self._worker_thread is thread and thread is not None and not thread.is_alive():
|
||||
self._worker_thread = None
|
||||
if self._telemetry_thread is telemetry_thread and telemetry_thread is not None and not telemetry_thread.is_alive():
|
||||
self._telemetry_thread = None
|
||||
|
||||
def _ensure_worker_locked(self) -> None:
|
||||
if self._worker_thread is not None and self._worker_thread.is_alive():
|
||||
return
|
||||
self._worker_stop_requested = False
|
||||
self._worker_thread = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
name="InfinityMirrorOutputWorker",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker_thread.start()
|
||||
|
||||
def _ensure_telemetry_locked(self) -> None:
|
||||
if self._telemetry_thread is not None and self._telemetry_thread.is_alive():
|
||||
return
|
||||
self._telemetry_stop_requested = False
|
||||
self._telemetry_thread = threading.Thread(
|
||||
target=self._telemetry_loop,
|
||||
name="InfinityMirrorTelemetryWorker",
|
||||
daemon=True,
|
||||
)
|
||||
self._telemetry_thread.start()
|
||||
|
||||
def _worker_loop(self) -> None:
|
||||
current_backend_id: str | None = None
|
||||
current_backend: OutputBackend | None = None
|
||||
next_send_at = time.perf_counter()
|
||||
|
||||
try:
|
||||
while True:
|
||||
action = "wait"
|
||||
desired_backend_id = ""
|
||||
desired_backend: OutputBackend | None = None
|
||||
config: InfinityMirrorConfig | None = None
|
||||
frame: PreviewFrame | None = None
|
||||
frame_version = 0
|
||||
interval_s = 1.0 / DEFAULT_OUTPUT_FPS
|
||||
scheduled_send_at = next_send_at
|
||||
|
||||
with self._condition:
|
||||
while True:
|
||||
if self._worker_stop_requested:
|
||||
return
|
||||
|
||||
desired_backend_id = self.active_backend_id
|
||||
desired_backend = self.backends[desired_backend_id]
|
||||
interval_s = 1.0 / self._target_fps
|
||||
|
||||
if not self.output_enabled:
|
||||
if current_backend is not None:
|
||||
action = "disable_backend"
|
||||
break
|
||||
self._condition.wait()
|
||||
next_send_at = time.perf_counter()
|
||||
continue
|
||||
|
||||
if current_backend_id != desired_backend_id or current_backend is None:
|
||||
action = "switch_backend"
|
||||
break
|
||||
|
||||
config = self._config_snapshot
|
||||
frame = self._latest_frame
|
||||
frame_version = self._latest_frame_version
|
||||
if config is None or frame is None:
|
||||
self._condition.wait()
|
||||
next_send_at = time.perf_counter()
|
||||
continue
|
||||
|
||||
now = time.perf_counter()
|
||||
wait_timeout = next_send_at - now
|
||||
if wait_timeout > 0.0:
|
||||
self._condition.wait(timeout=wait_timeout)
|
||||
continue
|
||||
|
||||
scheduled_send_at = next_send_at
|
||||
action = "send_frame"
|
||||
break
|
||||
|
||||
if action == "disable_backend":
|
||||
current_backend.stop()
|
||||
current_backend = None
|
||||
current_backend_id = None
|
||||
next_send_at = time.perf_counter()
|
||||
continue
|
||||
|
||||
if action == "switch_backend":
|
||||
if current_backend is not None:
|
||||
current_backend.stop()
|
||||
current_backend = desired_backend
|
||||
current_backend_id = desired_backend_id
|
||||
try:
|
||||
current_backend.start()
|
||||
except OSError as exc:
|
||||
self._queue_status_message(f"{current_backend.display_name} start failed: {exc}")
|
||||
current_backend = None
|
||||
current_backend_id = None
|
||||
next_send_at = time.perf_counter()
|
||||
continue
|
||||
|
||||
if action != "send_frame" or current_backend is None or config is None or frame is None:
|
||||
continue
|
||||
|
||||
send_started_at = time.perf_counter()
|
||||
result = current_backend.send_frame(config, frame)
|
||||
send_finished_at = time.perf_counter()
|
||||
send_duration_s = send_finished_at - send_started_at
|
||||
schedule_slip_s = max(0.0, send_started_at - scheduled_send_at)
|
||||
missed_budget = send_finished_at > (scheduled_send_at + interval_s)
|
||||
|
||||
with self._condition:
|
||||
self._last_send_duration_s = send_duration_s
|
||||
self._last_schedule_slip_s = schedule_slip_s
|
||||
if missed_budget:
|
||||
self._send_budget_misses += 1
|
||||
self._frames_sent += 1
|
||||
self._sent_frame_version = max(self._sent_frame_version, frame_version)
|
||||
self._packets_last_frame = result.packets_sent
|
||||
self._devices_last_frame = result.device_count
|
||||
self._packets_sent_total += result.packets_sent
|
||||
self._last_result_message = result.message
|
||||
if not result.ok and result.message:
|
||||
self._send_failures += 1
|
||||
self._queue_status_message(result.message)
|
||||
self._record_send_fps(send_finished_at)
|
||||
|
||||
# Keep a stable cadence anchored to the worker clock instead of adding
|
||||
# a full extra interval after every send. If a send overruns, jump to
|
||||
# "now" and continue with the freshest frame rather than compounding lag.
|
||||
next_send_at = max(next_send_at + interval_s, send_finished_at)
|
||||
finally:
|
||||
if current_backend is not None:
|
||||
try:
|
||||
current_backend.stop()
|
||||
finally:
|
||||
with self._condition:
|
||||
if self._worker_thread is threading.current_thread():
|
||||
self._worker_thread = None
|
||||
else:
|
||||
with self._condition:
|
||||
if self._worker_thread is threading.current_thread():
|
||||
self._worker_thread = None
|
||||
|
||||
def _telemetry_loop(self) -> None:
|
||||
next_poll_at = time.perf_counter()
|
||||
try:
|
||||
while True:
|
||||
backend: OutputBackend | None = None
|
||||
config: InfinityMirrorConfig | None = None
|
||||
with self._condition:
|
||||
while True:
|
||||
if self._telemetry_stop_requested:
|
||||
return
|
||||
if not self.output_enabled:
|
||||
self._controller_metrics = ControllerMetrics()
|
||||
self._condition.wait()
|
||||
next_poll_at = time.perf_counter()
|
||||
continue
|
||||
config = self._config_snapshot
|
||||
backend = self.backends[self.active_backend_id]
|
||||
if config is None:
|
||||
self._condition.wait(timeout=0.2)
|
||||
next_poll_at = time.perf_counter()
|
||||
continue
|
||||
now = time.perf_counter()
|
||||
wait_timeout = next_poll_at - now
|
||||
if wait_timeout > 0.0:
|
||||
self._condition.wait(timeout=wait_timeout)
|
||||
continue
|
||||
break
|
||||
|
||||
metrics = backend.controller_metrics(config) if backend is not None and config is not None else None
|
||||
with self._condition:
|
||||
self._controller_metrics = metrics if metrics is not None else ControllerMetrics()
|
||||
next_poll_at = time.perf_counter() + CONTROLLER_TELEMETRY_INTERVAL_S
|
||||
finally:
|
||||
with self._condition:
|
||||
if self._telemetry_thread is threading.current_thread():
|
||||
self._telemetry_thread = None
|
||||
|
||||
def _queue_status_message(self, message: str) -> None:
|
||||
if not message or message == self._last_queued_message:
|
||||
return
|
||||
self._pending_messages.append(message)
|
||||
self._last_queued_message = message
|
||||
|
||||
def _record_send_fps(self, timestamp: float) -> None:
|
||||
if self._send_window_started_at <= 0.0:
|
||||
self._send_window_started_at = timestamp
|
||||
self._send_window_count = 1
|
||||
self._send_fps = 0.0
|
||||
return
|
||||
|
||||
self._send_window_count += 1
|
||||
elapsed = timestamp - self._send_window_started_at
|
||||
if elapsed >= 0.5:
|
||||
self._send_fps = self._send_window_count / elapsed
|
||||
self._send_window_started_at = timestamp
|
||||
self._send_window_count = 0
|
||||
|
||||
@staticmethod
|
||||
def _clamp_target_fps(value: float) -> float:
|
||||
return max(MIN_OUTPUT_FPS, min(MAX_OUTPUT_FPS, float(value)))
|
||||
15
app/output/preview.py
Normal file
15
app/output/preview.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.core.types import PreviewFrame
|
||||
|
||||
from .base import OutputBackend, OutputResult
|
||||
|
||||
|
||||
class PreviewOutputBackend(OutputBackend):
|
||||
backend_id = "preview"
|
||||
display_name = "Preview Only"
|
||||
supports_live_output = False
|
||||
|
||||
def send_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
|
||||
return OutputResult(ok=True, message="Preview-only mode active.", packets_sent=0, device_count=0)
|
||||
Reference in New Issue
Block a user