from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Literal RGB = tuple[int, int, int] @dataclass(slots=True) class SegmentSpec: name: str side: str start_channel: int led_count: int reverse: bool = False orientation_rad: float = 0.0 x0: float = 0.0 y0: float = 0.0 x1: float = 0.0 y1: float = 0.0 @property def byte_offset(self) -> int: return max(0, self.start_channel - 1) @dataclass(slots=True) class TileSpec: tile_id: str row: int col: int led_total: int controller_ip: str = "" screen_name: str = "" controller_host: str = "" controller_name: str = "" controller_mac: str = "" universe: int = 0 subnet: int = 0 enabled: bool = True brightness: float = 1.0 x0: float = 0.0 y0: float = 0.0 x1: float = 0.0 y1: float = 0.0 segments: list[SegmentSpec] = field(default_factory=list) @property def controller_payload_bytes(self) -> int: return max(0, self.led_total) * 3 @dataclass(slots=True) class ControllerTileRoute: tile_id: str controller_offset_bytes: int segment_offsets: tuple[tuple[int, int], ...] @dataclass(slots=True) class ControllerSpec: host: str payload_bytes: int tiles: tuple[ControllerTileRoute, ...] @dataclass(slots=True) class MappingSpec: name: str = "Infinity Vis 1" rows: int = 3 cols: int = 6 tiles: list[TileSpec] = field(default_factory=list) file_path: Path | None = None protocol: str = "ddp" subnet: int = 0 color_format: str = "rgb" tile_behavior: str = "solid_color_per_tile" global_gamma: float = 2.2 def ordered_tiles(self) -> list[TileSpec]: return sorted(self.tiles, key=lambda tile: (tile.row, tile.col, tile.tile_id)) def tile_lookup(self) -> dict[str, TileSpec]: return {tile.tile_id: tile for tile in self.tiles} def build_controllers(self) -> list[ControllerSpec]: groups: dict[str, list[TileSpec]] = {} for tile in self.ordered_tiles(): if not tile.enabled: continue host = tile.controller_ip.strip() if not host: continue groups.setdefault(host, []).append(tile) controllers: list[ControllerSpec] = [] for host, tiles in groups.items(): running_offset = 0 routes: list[ControllerTileRoute] = [] for tile in tiles: segment_offsets = tuple( (running_offset + segment.byte_offset, segment.led_count) for segment in sorted(tile.segments, key=lambda item: (item.start_channel, item.name)) ) routes.append( ControllerTileRoute( tile_id=tile.tile_id, controller_offset_bytes=running_offset, segment_offsets=segment_offsets, ) ) running_offset += tile.controller_payload_bytes controllers.append( ControllerSpec( host=host, payload_bytes=running_offset, tiles=tuple(routes), ) ) return sorted(controllers, key=lambda item: item.host) @dataclass(slots=True) class PatternParams: brightness: float = 1.0 tempo_multiplier: float = 1.0 direction: str = "left_to_right" checker_mode: str = "classic" scan_style: str = "line" angle: float = 0.0 on_width: float = 1.0 off_width: float = 1.0 strobe_mode: str = "global" stopwatch_mode: str = "sync" color_mode: str = "dual" primary_color: str = "#4D7CFF" secondary_color: str = "#0E1630" palette: str = "Laser Club" symmetry: str = "none" center_pulse_mode: str = "expand" step_size: float = 1.0 block_size: float = 1.0 pixel_group_size: float = 1.0 strobe_duty_cycle: float = 0.5 randomness: float = 0.35 @dataclass(slots=True) class SceneState: pattern_id: str = "solid" params: PatternParams = field(default_factory=PatternParams) tempo_bpm: float = 120.0 utility_mode: Literal["none", "blackout", "single_tile", "identify"] = "none" selected_tile_id: str | None = None @dataclass(slots=True) class FrameSnapshot: timestamp: float pattern_id: str tile_colors: list[RGB] controller_payloads: dict[str, bytes]