First upload, 18 controller version

This commit is contained in:
2026-04-14 15:23:56 +02:00
commit 8c55001a1c
3810 changed files with 764061 additions and 0 deletions

2
app/output/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Output backend interfaces and implementations."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

82
app/output/artnet.py Normal file
View 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
View 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
View 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
View 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
View 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)