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/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Infinity Mirror control app."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -0,0 +1,2 @@
"""Configuration models and XML helpers."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from dataclasses import dataclass
from app.config.models import InfinityMirrorConfig, TileConfig
from app.network.wled import DiscoveredWledDevice, normalize_mac_address
@dataclass(frozen=True)
class TileAssignmentSnapshot:
tile_id: str
controller_ip: str = ""
controller_host: str = ""
controller_name: str = ""
controller_mac: str = ""
@property
def is_assigned(self) -> bool:
return any((self.controller_ip, self.controller_host, self.controller_name, self.controller_mac))
@dataclass(frozen=True)
class DeviceAssignmentResult:
target_tile_id: str
previous_tile_id: str | None = None
displaced_tile_assignment: TileAssignmentSnapshot | None = None
def capture_tile_assignment(tile: TileConfig) -> TileAssignmentSnapshot:
return TileAssignmentSnapshot(
tile_id=tile.tile_id,
controller_ip=tile.controller_ip.strip(),
controller_host=tile.controller_host.strip(),
controller_name=tile.controller_name.strip(),
controller_mac=normalize_mac_address(tile.controller_mac),
)
def clear_tile_assignment(tile: TileConfig) -> None:
tile.controller_ip = ""
tile.controller_host = ""
tile.controller_name = ""
tile.controller_mac = ""
def tile_matches_device(tile: TileConfig, device: DiscoveredWledDevice) -> bool:
tile_mac = normalize_mac_address(tile.controller_mac)
device_mac = normalize_mac_address(device.mac_address)
if tile_mac and device_mac:
return tile_mac == device_mac
return bool(tile.controller_ip.strip() and tile.controller_ip.strip() == device.ip_address)
def find_tile_for_device(config: InfinityMirrorConfig, device: DiscoveredWledDevice) -> TileConfig | None:
for tile in config.sorted_tiles():
if tile_matches_device(tile, device):
return tile
return None
def assign_device_to_tile(
config: InfinityMirrorConfig,
device: DiscoveredWledDevice,
target_tile_id: str,
) -> DeviceAssignmentResult:
tile_lookup = config.tile_lookup()
target_tile = tile_lookup.get(target_tile_id)
if target_tile is None:
raise KeyError(f"Unknown tile id: {target_tile_id}")
previous_tile = find_tile_for_device(config, device)
displaced_assignment = capture_tile_assignment(target_tile)
displaced_snapshot = displaced_assignment if displaced_assignment.is_assigned else None
if previous_tile is not None and previous_tile.tile_id != target_tile.tile_id:
clear_tile_assignment(previous_tile)
if displaced_snapshot is not None:
target_mac = normalize_mac_address(target_tile.controller_mac)
if previous_tile is None or previous_tile.tile_id != target_tile.tile_id:
if not (target_mac and target_mac == device.mac_address) and target_tile.controller_ip.strip() != device.ip_address:
clear_tile_assignment(target_tile)
target_tile.controller_ip = device.ip_address
target_tile.controller_host = device.hostname
target_tile.controller_name = device.instance_name
target_tile.controller_mac = device.mac_address
return DeviceAssignmentResult(
target_tile_id=target_tile.tile_id,
previous_tile_id=previous_tile.tile_id if previous_tile is not None and previous_tile.tile_id != target_tile.tile_id else None,
displaced_tile_assignment=displaced_snapshot,
)

121
app/config/models.py Normal file
View File

@@ -0,0 +1,121 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
import copy
@dataclass
class CompositionInfo:
width: int = 1200
height: int = 600
@dataclass
class SourceInfo:
original_export: str = ""
derived_from: str = ""
composition: CompositionInfo = field(default_factory=CompositionInfo)
@dataclass
class LogicalDisplayConfig:
rows: int = 3
cols: int = 6
preview_width: int = 1200
preview_height: int = 600
tile_width: int = 200
tile_height: int = 200
@dataclass
class DefaultsConfig:
protocol: str = "artnet"
subnet: int = 0
color_format: str = "rgb"
tile_behavior: str = "solid_color_per_tile"
global_gamma: float = 2.2
@dataclass
class CalibrationConfig:
brightness: float = 1.0
red_gain: float = 1.0
green_gain: float = 1.0
blue_gain: float = 1.0
@dataclass
class SegmentConfig:
name: str
side: str
start_channel: int
led_count: int
orientation_rad: float = 0.0
x0: float = 0.0
y0: float = 0.0
x1: float = 0.0
y1: float = 0.0
reverse: bool = False
@dataclass
class TileConfig:
tile_id: str
row: int
col: int
screen_name: str = ""
controller_ip: str = ""
controller_host: str = ""
controller_name: str = ""
controller_mac: str = ""
universe: int = 0
subnet: int = 0
led_total: int = 0
x0: float = 0.0
y0: float = 0.0
x1: float = 0.0
y1: float = 0.0
enabled: bool = True
calibration: CalibrationConfig = field(default_factory=CalibrationConfig)
segments: list[SegmentConfig] = field(default_factory=list)
@property
def brightness_factor(self) -> float:
return self.calibration.brightness
@brightness_factor.setter
def brightness_factor(self, value: float) -> None:
self.calibration.brightness = value
@dataclass
class InfinityMirrorConfig:
name: str = "Infinity Mirror"
version: str = "1.0"
source: SourceInfo = field(default_factory=SourceInfo)
logical_display: LogicalDisplayConfig = field(default_factory=LogicalDisplayConfig)
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
tiles: list[TileConfig] = field(default_factory=list)
file_path: Path | None = None
def clone(self) -> "InfinityMirrorConfig":
return copy.deepcopy(self)
def sorted_tiles(self) -> list[TileConfig]:
return sorted(self.tiles, key=lambda tile: (tile.row, tile.col, tile.tile_id))
def tile_lookup(self) -> dict[str, TileConfig]:
return {tile.tile_id: tile for tile in self.tiles}
def tile_at(self, row: int, col: int) -> TileConfig | None:
for tile in self.tiles:
if tile.row == row and tile.col == col:
return tile
return None
def all_segments(self) -> Iterable[tuple[TileConfig, SegmentConfig]]:
for tile in self.sorted_tiles():
for segment in tile.segments:
yield tile, segment

361
app/config/xml_mapping.py Normal file
View File

@@ -0,0 +1,361 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import ipaddress
import xml.etree.ElementTree as ET
from .models import (
CalibrationConfig,
CompositionInfo,
DefaultsConfig,
InfinityMirrorConfig,
LogicalDisplayConfig,
SegmentConfig,
SourceInfo,
TileConfig,
)
@dataclass
class ValidationResult:
errors: list[str]
@property
def is_valid(self) -> bool:
return not self.errors
class MappingValidationError(ValueError):
def __init__(self, errors: list[str]) -> None:
self.errors = errors
super().__init__("\n".join(errors))
def _get_required(node: ET.Element, key: str) -> str:
value = node.get(key)
if value is None:
raise MappingValidationError([f"Missing required attribute '{key}' on <{node.tag}>."])
return value
def _get_int(node: ET.Element, key: str, default: int | None = None) -> int:
raw = node.get(key)
if raw is None:
if default is None:
raise MappingValidationError([f"Missing required integer attribute '{key}' on <{node.tag}>."])
return default
try:
return int(raw)
except ValueError as exc:
raise MappingValidationError([f"Invalid integer for '{key}' on <{node.tag}>: {raw!r}"]) from exc
def _get_float(node: ET.Element, key: str, default: float | None = None) -> float:
raw = node.get(key)
if raw is None:
if default is None:
raise MappingValidationError([f"Missing required float attribute '{key}' on <{node.tag}>."])
return default
try:
return float(raw)
except ValueError as exc:
raise MappingValidationError([f"Invalid float for '{key}' on <{node.tag}>: {raw!r}"]) from exc
def _get_bool(node: ET.Element, key: str, default: bool = False) -> bool:
raw = node.get(key)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def load_config(path: str | Path, validate: bool = True) -> InfinityMirrorConfig:
xml_text = Path(path).read_text(encoding="utf-8")
config = load_config_from_string(xml_text, validate=validate)
config.file_path = Path(path)
return config
def load_config_from_string(xml_text: str, validate: bool = True) -> InfinityMirrorConfig:
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise MappingValidationError([f"XML parse error: {exc}"]) from exc
if root.tag != "InfinityMirrorConfig":
raise MappingValidationError([f"Unexpected root element: <{root.tag}>"])
source_node = root.find("Source")
composition_node = source_node.find("OriginalComposition") if source_node is not None else None
source = SourceInfo(
original_export=source_node.findtext("OriginalExport", default="") if source_node is not None else "",
derived_from=source_node.findtext("DerivedFrom", default="") if source_node is not None else "",
composition=CompositionInfo(
width=int(composition_node.get("width", "1200")) if composition_node is not None else 1200,
height=int(composition_node.get("height", "600")) if composition_node is not None else 600,
),
)
logical_node = root.find("LogicalDisplay")
logical_display = LogicalDisplayConfig(
rows=_get_int(logical_node, "rows", 3) if logical_node is not None else 3,
cols=_get_int(logical_node, "cols", 6) if logical_node is not None else 6,
preview_width=_get_int(logical_node, "previewWidth", 1200) if logical_node is not None else 1200,
preview_height=_get_int(logical_node, "previewHeight", 600) if logical_node is not None else 600,
tile_width=_get_int(logical_node, "tileWidth", 200) if logical_node is not None else 200,
tile_height=_get_int(logical_node, "tileHeight", 200) if logical_node is not None else 200,
)
defaults_node = root.find("Defaults")
defaults = DefaultsConfig(
protocol=defaults_node.findtext("protocol", default="artnet") if defaults_node is not None else "artnet",
subnet=int(defaults_node.findtext("subnet", default="0")) if defaults_node is not None else 0,
color_format=defaults_node.findtext("colorFormat", default="rgb") if defaults_node is not None else "rgb",
tile_behavior=defaults_node.findtext("tileBehavior", default="solid_color_per_tile")
if defaults_node is not None
else "solid_color_per_tile",
global_gamma=float(defaults_node.findtext("globalGamma", default="2.2")) if defaults_node is not None else 2.2,
)
tiles: list[TileConfig] = []
tiles_node = root.find("Tiles")
if tiles_node is None:
raise MappingValidationError(["Missing <Tiles> element."])
for tile_node in tiles_node.findall("Tile"):
calibration_node = tile_node.find("Calibration")
calibration = CalibrationConfig(
brightness=_get_float(calibration_node, "brightness", 1.0) if calibration_node is not None else 1.0,
red_gain=_get_float(calibration_node, "redGain", 1.0) if calibration_node is not None else 1.0,
green_gain=_get_float(calibration_node, "greenGain", 1.0) if calibration_node is not None else 1.0,
blue_gain=_get_float(calibration_node, "blueGain", 1.0) if calibration_node is not None else 1.0,
)
segments: list[SegmentConfig] = []
segments_node = tile_node.find("Segments")
for segment_node in segments_node.findall("Segment") if segments_node is not None else []:
segments.append(
SegmentConfig(
name=segment_node.get("name", ""),
side=segment_node.get("side", ""),
start_channel=_get_int(segment_node, "startChannel", 1),
led_count=_get_int(segment_node, "ledCount", 0),
orientation_rad=_get_float(segment_node, "orientationRad", 0.0),
x0=_get_float(segment_node, "x0", 0.0),
y0=_get_float(segment_node, "y0", 0.0),
x1=_get_float(segment_node, "x1", 0.0),
y1=_get_float(segment_node, "y1", 0.0),
reverse=_get_bool(segment_node, "reverse", False),
)
)
segments.sort(key=lambda segment: (segment.start_channel, segment.name))
tiles.append(
TileConfig(
tile_id=_get_required(tile_node, "id"),
row=_get_int(tile_node, "row"),
col=_get_int(tile_node, "col"),
screen_name=tile_node.get("screenName", ""),
controller_ip=tile_node.get("ip", ""),
controller_host=tile_node.get("controllerHost", ""),
controller_name=tile_node.get("controllerName", ""),
controller_mac=tile_node.get("controllerMac", ""),
universe=_get_int(tile_node, "universe", 0),
subnet=_get_int(tile_node, "subnet", defaults.subnet),
led_total=_get_int(tile_node, "ledTotal", 0),
x0=_get_float(tile_node, "x0", 0.0),
y0=_get_float(tile_node, "y0", 0.0),
x1=_get_float(tile_node, "x1", 0.0),
y1=_get_float(tile_node, "y1", 0.0),
enabled=_get_bool(tile_node, "enabled", True),
calibration=calibration,
segments=segments,
)
)
config = InfinityMirrorConfig(
name=root.get("name", "Infinity Mirror"),
version=root.get("version", "1.0"),
source=source,
logical_display=logical_display,
defaults=defaults,
tiles=tiles,
)
if validate:
result = validate_config(config)
if not result.is_valid:
raise MappingValidationError(result.errors)
return config
def validate_config(config: InfinityMirrorConfig) -> ValidationResult:
errors: list[str] = []
rows = config.logical_display.rows
cols = config.logical_display.cols
if rows <= 0 or cols <= 0:
errors.append("Logical display must have positive rows and columns.")
seen_ids: set[str] = set()
seen_positions: set[tuple[int, int]] = set()
for tile in config.tiles:
if not tile.tile_id:
errors.append("Tile id cannot be empty.")
elif tile.tile_id in seen_ids:
errors.append(f"Duplicate tile id: {tile.tile_id}")
seen_ids.add(tile.tile_id)
if not (1 <= tile.row <= rows):
errors.append(f"{tile.tile_id}: row {tile.row} is outside 1..{rows}.")
if not (1 <= tile.col <= cols):
errors.append(f"{tile.tile_id}: col {tile.col} is outside 1..{cols}.")
position = (tile.row, tile.col)
if position in seen_positions:
errors.append(f"Duplicate tile position row={tile.row}, col={tile.col}.")
seen_positions.add(position)
if tile.controller_ip:
try:
ipaddress.ip_address(tile.controller_ip)
except ValueError:
errors.append(f"{tile.tile_id}: invalid IP address {tile.controller_ip!r}.")
if tile.universe < 0:
errors.append(f"{tile.tile_id}: universe must be >= 0.")
if not (0 <= tile.subnet <= 15):
errors.append(f"{tile.tile_id}: subnet must be between 0 and 15.")
if tile.led_total < 0:
errors.append(f"{tile.tile_id}: led count must be >= 0.")
if tile.brightness_factor < 0:
errors.append(f"{tile.tile_id}: brightness factor must be >= 0.")
segment_led_total = 0
for segment in tile.segments:
if not segment.name:
errors.append(f"{tile.tile_id}: segment name cannot be empty.")
if segment.led_count <= 0:
errors.append(f"{tile.tile_id}/{segment.name}: led count must be > 0.")
if segment.start_channel <= 0:
errors.append(f"{tile.tile_id}/{segment.name}: start channel must be > 0.")
segment_led_total += segment.led_count
if tile.segments and segment_led_total != tile.led_total:
errors.append(
f"{tile.tile_id}: ledTotal={tile.led_total} does not match segment sum {segment_led_total}."
)
expected_tiles = rows * cols
if len(config.tiles) != expected_tiles:
errors.append(f"Expected {expected_tiles} tiles for a {rows}x{cols} display, found {len(config.tiles)}.")
return ValidationResult(errors)
def config_to_xml_string(config: InfinityMirrorConfig) -> str:
root = ET.Element("InfinityMirrorConfig", {"name": config.name, "version": config.version})
source_node = ET.SubElement(root, "Source")
ET.SubElement(source_node, "OriginalExport").text = config.source.original_export
ET.SubElement(source_node, "DerivedFrom").text = config.source.derived_from
ET.SubElement(
source_node,
"OriginalComposition",
{
"width": str(config.source.composition.width),
"height": str(config.source.composition.height),
},
)
ET.SubElement(
root,
"LogicalDisplay",
{
"rows": str(config.logical_display.rows),
"cols": str(config.logical_display.cols),
"previewWidth": str(config.logical_display.preview_width),
"previewHeight": str(config.logical_display.preview_height),
"tileWidth": str(config.logical_display.tile_width),
"tileHeight": str(config.logical_display.tile_height),
},
)
defaults_node = ET.SubElement(root, "Defaults")
ET.SubElement(defaults_node, "protocol").text = config.defaults.protocol
ET.SubElement(defaults_node, "subnet").text = str(config.defaults.subnet)
ET.SubElement(defaults_node, "colorFormat").text = config.defaults.color_format
ET.SubElement(defaults_node, "tileBehavior").text = config.defaults.tile_behavior
ET.SubElement(defaults_node, "globalGamma").text = str(config.defaults.global_gamma)
tiles_node = ET.SubElement(root, "Tiles")
for tile in config.sorted_tiles():
tile_attributes = {
"id": tile.tile_id,
"row": str(tile.row),
"col": str(tile.col),
"screenName": tile.screen_name,
"ip": tile.controller_ip,
"universe": str(tile.universe),
"subnet": str(tile.subnet),
"ledTotal": str(tile.led_total),
"x0": _format_float(tile.x0),
"y0": _format_float(tile.y0),
"x1": _format_float(tile.x1),
"y1": _format_float(tile.y1),
"enabled": "true" if tile.enabled else "false",
}
if tile.controller_host:
tile_attributes["controllerHost"] = tile.controller_host
if tile.controller_name:
tile_attributes["controllerName"] = tile.controller_name
if tile.controller_mac:
tile_attributes["controllerMac"] = tile.controller_mac
tile_node = ET.SubElement(tiles_node, "Tile", tile_attributes)
ET.SubElement(
tile_node,
"Calibration",
{
"brightness": _format_float(tile.calibration.brightness),
"redGain": _format_float(tile.calibration.red_gain),
"greenGain": _format_float(tile.calibration.green_gain),
"blueGain": _format_float(tile.calibration.blue_gain),
},
)
segments_node = ET.SubElement(tile_node, "Segments")
for segment in sorted(tile.segments, key=lambda item: (item.start_channel, item.name)):
ET.SubElement(
segments_node,
"Segment",
{
"name": segment.name,
"side": segment.side,
"startChannel": str(segment.start_channel),
"ledCount": str(segment.led_count),
"orientationRad": _format_float(segment.orientation_rad),
"x0": _format_float(segment.x0),
"y0": _format_float(segment.y0),
"x1": _format_float(segment.x1),
"y1": _format_float(segment.y1),
"reverse": "true" if segment.reverse else "false",
},
)
ET.indent(root, space=" ")
return ET.tostring(root, encoding="unicode", xml_declaration=True)
def save_config(config: InfinityMirrorConfig, path: str | Path) -> None:
result = validate_config(config)
if not result.is_valid:
raise MappingValidationError(result.errors)
Path(path).write_text(config_to_xml_string(config), encoding="utf-8")
config.file_path = Path(path)
def _format_float(value: float) -> str:
text = f"{value:.6f}".rstrip("0").rstrip(".")
return text if text else "0"

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

@@ -0,0 +1,2 @@
"""Pure Python core types and orchestration helpers."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

162
app/core/colors.py Normal file
View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import colorsys
import math
from .types import RGBColor, clamp
PALETTES: dict[str, list[str]] = {
"Laser Club": ["#00F0FF", "#008CFF", "#6A00FF", "#060814"],
"Magenta Drive": ["#FF006E", "#FF4DA6", "#7A00FF", "#120318"],
"Warehouse Heat": ["#FF5A1F", "#FF9E00", "#FFD000", "#140600"],
"UV Riot": ["#7A00FF", "#B100FF", "#FF00A8", "#100014"],
"Redline": ["#FF2D55", "#FF6A00", "#FFB000", "#160406"],
"Sodium Haze": ["#FF7A00", "#FFB000", "#FFD86B", "#120700"],
"Afterhours": ["#F72585", "#B5179E", "#7209B7", "#14031A"],
"Voltage": ["#00E5FF", "#00B3FF", "#3A86FF", "#050A14"],
}
PALETTE_ALIASES: dict[str, str] = {
"Aurora": "Laser Club",
"Cinder": "Warehouse Heat",
"Sapphire": "Voltage",
"Deep Blue": "Voltage",
"Neon Tide": "Laser Club",
"Sunset Drive": "Redline",
"Moss Signal": "Sodium Haze",
"Polar": "Voltage",
"Signal Warm": "Warehouse Heat",
"Steel Bloom": "Magenta Drive",
}
DEFAULT_PALETTE = "Laser Club"
RANDOM_EFFECT_COLORS: tuple[RGBColor, ...] = (
RGBColor(1.0, 0.12, 0.12),
RGBColor(1.0, 0.42, 0.0),
RGBColor(1.0, 0.74, 0.0),
RGBColor(0.7, 1.0, 0.0),
RGBColor(0.0, 1.0, 0.3),
RGBColor(0.0, 0.86, 1.0),
RGBColor(0.0, 0.32, 1.0),
RGBColor(0.7, 0.0, 1.0),
)
def hex_to_color(value: str) -> RGBColor:
text = value.strip().lstrip("#")
if len(text) != 6:
return RGBColor.white()
try:
return RGBColor(*(int(text[index : index + 2], 16) / 255.0 for index in (0, 2, 4)))
except ValueError:
return RGBColor.white()
def canonical_palette_name(name: str) -> str:
candidate = PALETTE_ALIASES.get(name, name)
return candidate if candidate in PALETTES else DEFAULT_PALETTE
def palette_colors(name: str) -> list[RGBColor]:
raw = PALETTES[canonical_palette_name(name)]
return [hex_to_color(value) for value in raw]
def sample_palette(name: str, amount: float) -> RGBColor:
colors = palette_colors(name)
if len(colors) == 1:
return colors[0]
amount = clamp(amount)
scaled = amount * (len(colors) - 1)
index = int(math.floor(scaled))
next_index = min(len(colors) - 1, index + 1)
local = scaled - index
return colors[index].mix(colors[next_index], local)
def sample_random_effect_color(amount: float) -> RGBColor:
return sample_color_choices(RANDOM_EFFECT_COLORS, amount)
def sample_color_choices(colors: tuple[RGBColor, ...], amount: float) -> RGBColor:
amount = clamp(amount)
if not colors:
return RGBColor.white()
index = int(math.floor(amount * len(colors))) % len(colors)
return colors[index]
def custom_random_color_choices(primary_hex: str, secondary_hex: str) -> tuple[RGBColor, ...]:
primary = hex_to_color(primary_hex)
secondary = hex_to_color(secondary_hex)
if primary.to_8bit_tuple() == secondary.to_8bit_tuple():
return (primary,)
return (
primary,
primary.mix(secondary, 0.25),
primary.mix(secondary, 0.5),
primary.mix(secondary, 0.75),
secondary,
)
def sample_custom_random_color(primary_hex: str, secondary_hex: str, amount: float) -> RGBColor:
return sample_color_choices(custom_random_color_choices(primary_hex, secondary_hex), amount)
def smoothstep(edge0: float, edge1: float, value: float) -> float:
if edge0 == edge1:
return 0.0
amount = clamp((value - edge0) / (edge1 - edge0))
return amount * amount * (3.0 - 2.0 * amount)
def ease_in_out_sine(value: float) -> float:
return -(math.cos(math.pi * clamp(value)) - 1.0) / 2.0
def oscillate(time_s: float, speed: float = 1.0, phase: float = 0.0) -> float:
return 0.5 + 0.5 * math.sin(time_s * speed * math.tau + phase)
def brighten(color: RGBColor, amount: float) -> RGBColor:
return color.mix(RGBColor.white(), amount)
def darken(color: RGBColor, amount: float) -> RGBColor:
return color.mix(RGBColor.black(), amount)
def choose_pair(color_mode: str, primary_hex: str, secondary_hex: str, palette_name: str, amount: float) -> tuple[RGBColor, RGBColor]:
primary = hex_to_color(primary_hex)
secondary = hex_to_color(secondary_hex)
if color_mode == "palette":
primary = sample_palette(palette_name, amount)
secondary = sample_palette(palette_name, (amount + 0.38) % 1.0)
elif color_mode == "random_colors":
primary = sample_random_effect_color(amount)
secondary = darken(primary, 0.78)
elif color_mode == "custom_random":
primary = sample_custom_random_color(primary_hex, secondary_hex, amount)
secondary = darken(primary, 0.78)
elif color_mode == "mono":
# Mono should be a true single-color-on-black look so stepped patterns can
# reach an actual off state instead of hovering at a dim gray floor.
secondary = RGBColor.black()
elif color_mode == "complementary":
hue, lightness, saturation = colorsys.rgb_to_hls(primary.r, primary.g, primary.b)
red, green, blue = colorsys.hls_to_rgb((hue + 0.5) % 1.0, lightness, saturation)
secondary = RGBColor(red, green, blue)
return primary, secondary
def relative_luminance(color: RGBColor) -> float:
return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
def label_contrast(color: RGBColor) -> RGBColor:
return RGBColor.black() if relative_luminance(color) > 0.6 else RGBColor.white()

609
app/core/controller.py Normal file
View File

@@ -0,0 +1,609 @@
from __future__ import annotations
from pathlib import Path
import time
import traceback
from app.qt_compat import QObject, QTimer, Qt, Signal
from app.config.models import InfinityMirrorConfig
from app.config.xml_mapping import MappingValidationError, load_config, save_config
from app.core.diagnostics import RealtimeDiagnostics
from app.core.pattern_engine import PatternEngine
from app.core.pattern_compat import normalize_pattern_request
from app.core.presets import PresetRecord, PresetStore
from app.core.types import PatternParameters, PreviewFrame, SceneState, SceneTransition, blend_preview_frames
from app.output.manager import OutputManager
NUMERIC_PARAMETER_KEYS = {
"brightness",
"fade",
"tempo_multiplier",
"angle",
"on_width",
"off_width",
"block_size",
"pixel_group_size",
"strobe_duty_cycle",
"randomness",
}
DEFAULT_TEMPO_BPM = 120.0
MIN_TEMPO_BPM = 10.0
MAX_TEMPO_BPM = 300.0
DEFAULT_RENDER_FPS = 60.0
DEFAULT_PREVIEW_FPS = 30.0
PRIORITIZED_PREVIEW_FPS = 12.0
def _clamp_tempo_bpm(value: float) -> float:
return max(MIN_TEMPO_BPM, min(MAX_TEMPO_BPM, float(value)))
def _legacy_speed_to_tempo_bpm(value: float) -> float:
return _clamp_tempo_bpm(float(value) * 60.0)
class InfinityMirrorController(QObject):
frame_ready = Signal(object)
next_frame_ready = Signal(object)
state_changed = Signal()
config_changed = Signal()
presets_changed = Signal()
status_message = Signal(str)
def __init__(self, project_root: str | Path, output_manager: OutputManager | None = None) -> None:
super().__init__()
self.project_root = Path(project_root)
self.output_manager = output_manager if output_manager is not None else OutputManager()
self.preset_store = PresetStore(self.project_root / "presets")
self.preset_store.ensure_seed_presets()
self.config = InfinityMirrorConfig()
self.mapping_path: Path | None = None
self._live_scene = SceneState()
self._next_scene = self._live_scene.clone()
self.foh_mode_enabled = False
self.tempo_bpm = DEFAULT_TEMPO_BPM
self._tempo_anchor_time_s = 0.0
self._tempo_anchor_phase = 0.0
self._transport_time_s = 0.0
self.transition_duration_s = 2.0
self._scene_transition: SceneTransition | None = None
self.live_engine = PatternEngine()
self.next_engine = PatternEngine()
self._transition_engine: PatternEngine | None = None
self.utility_mode = "none"
self.selected_tile_id: str | None = None
self.current_frame: PreviewFrame | None = None
self.next_frame: PreviewFrame | None = None
self._last_output_message = ""
self._frames_rendered = 0
self._last_render_duration_s = 0.0
self._render_window_started_at = 0.0
self._render_window_count = 0
self._render_fps = 0.0
self._last_live_preview_emit_time_s = None
self._last_next_preview_emit_time_s = None
self._last_live_preview_emit_time_s: float | None = None
self._last_next_preview_emit_time_s: float | None = None
self.output_manager.update_config(self.config)
self.timer = QTimer(self)
self.timer.setTimerType(Qt.PreciseTimer)
self.timer.setInterval(max(1, int(round(1000.0 / DEFAULT_RENDER_FPS))))
self.timer.timeout.connect(self.render_once)
self.timer.start()
@property
def pattern_id(self) -> str:
return self._editable_scene().pattern_id
@pattern_id.setter
def pattern_id(self, value: str) -> None:
self.set_pattern(value)
@property
def params(self) -> PatternParameters:
return self._editable_scene().params.clone()
@params.setter
def params(self, value: PatternParameters) -> None:
self.set_params(value)
@property
def transition_active(self) -> bool:
return self._scene_transition is not None
def live_scene(self) -> SceneState:
return self.scene_state("live")
def next_scene(self) -> SceneState:
return self.scene_state("next")
def scene_state(self, role: str = "live") -> SceneState:
return self._scene_for_role(role).clone()
def preview_frame_for(self, role: str = "live") -> PreviewFrame | None:
return self.next_frame if str(role).strip().lower() == "next" else self.current_frame
def startup_mapping_candidates(self) -> list[Path]:
return [
self.project_root / "sample_data" / "infinity_mirror_mapping_clean.xml",
self.project_root / "infinity_mirror_mapping_clean.xml",
]
def load_initial_config(self) -> None:
for candidate in self.startup_mapping_candidates():
if candidate.exists():
self.load_mapping(candidate)
return
self.status_message.emit("No default mapping found. Use File > Open Mapping.")
def load_mapping(self, path: str | Path) -> None:
config = load_config(path)
self.config = config
self.mapping_path = Path(path)
if self.selected_tile_id not in self.config.tile_lookup():
first_tile = self.config.sorted_tiles()[0] if self.config.tiles else None
self.selected_tile_id = first_tile.tile_id if first_tile else None
self._reset_render_state()
self.output_manager.update_config(self.config)
self.config_changed.emit()
self.state_changed.emit()
self.status_message.emit(f"Loaded mapping: {Path(path).name}")
self.render_once(force_preview=True)
def save_mapping(self, path: str | Path | None = None) -> None:
target = Path(path) if path is not None else self.mapping_path
if target is None:
raise ValueError("No mapping path set.")
save_config(self.config, target)
self.mapping_path = target
self.status_message.emit(f"Saved mapping: {target.name}")
def replace_config(self, config: InfinityMirrorConfig, path: str | Path | None = None) -> None:
self.config = config
if path is not None:
self.mapping_path = Path(path)
if self.selected_tile_id not in self.config.tile_lookup():
first_tile = self.config.sorted_tiles()[0] if self.config.tiles else None
self.selected_tile_id = first_tile.tile_id if first_tile else None
self._reset_render_state()
self.output_manager.update_config(self.config)
self.config_changed.emit()
self.state_changed.emit()
self.render_once(force_preview=True)
def _reset_render_state(self) -> None:
self.live_engine = PatternEngine()
self.next_engine = PatternEngine()
self._transition_engine = None
self._scene_transition = None
self.current_frame = None
self.next_frame = None
self._last_output_message = ""
self._frames_rendered = 0
self._last_render_duration_s = 0.0
self._render_window_started_at = 0.0
self._render_window_count = 0
self._render_fps = 0.0
def _editable_scene(self) -> SceneState:
return self._scene_for_role(self._editable_scene_role())
def _editable_scene_role(self) -> str:
return "next" if self.foh_mode_enabled else "live"
def _normalize_scene_role(self, role: str = "live") -> str:
return "next" if str(role).strip().lower() == "next" else "live"
def _scene_for_role(self, role: str = "live") -> SceneState:
return self._next_scene if self._normalize_scene_role(role) == "next" else self._live_scene
def _replace_scene(self, role: str, scene: SceneState) -> None:
cloned_scene = scene.clone()
if self._normalize_scene_role(role) == "next":
self._next_scene = cloned_scene
else:
self._live_scene = cloned_scene
def _editable_scene_snapshot(self) -> SceneState:
return self._editable_scene().clone()
def _replace_editable_scene(self, scene: SceneState) -> None:
self._replace_scene(self._editable_scene_role(), scene)
def _sync_next_to_live(self) -> None:
self._next_scene = self._live_scene.clone()
self.next_engine = PatternEngine()
self.next_frame = None
def _normalize_pattern_request(self, pattern_id: str, params: PatternParameters | None = None) -> tuple[str, PatternParameters | None]:
return normalize_pattern_request(
pattern_id,
params,
rows=self.config.logical_display.rows,
cols=self.config.logical_display.cols,
)
def _render_scene_frame(
self,
engine: PatternEngine,
scene: SceneState,
timestamp: float,
utility_mode: str = "none",
) -> PreviewFrame:
tempo_phase = self._tempo_phase_at(timestamp)
return engine.render_frame(
config=self.config,
pattern_id=scene.pattern_id,
params=scene.params,
utility_mode=utility_mode,
selected_tile_id=self.selected_tile_id,
timestamp=timestamp,
tempo_bpm=self.tempo_bpm,
tempo_phase=tempo_phase,
)
def _tempo_phase_at(self, timestamp: float) -> float:
return self._tempo_anchor_phase + (float(timestamp) - self._tempo_anchor_time_s) * (self.tempo_bpm / 60.0)
def _retime_transport(self, tempo_bpm: float, timestamp: float) -> None:
current_phase = self._tempo_phase_at(timestamp)
self.tempo_bpm = tempo_bpm
self._tempo_anchor_time_s = float(timestamp)
self._tempo_anchor_phase = current_phase
self._transport_time_s = float(timestamp)
def set_tempo_bpm(self, value: float, timestamp: float | None = None) -> None:
tempo_bpm = _clamp_tempo_bpm(float(value))
if abs(tempo_bpm - self.tempo_bpm) < 1e-9:
return
anchor_time = self._transport_time_s if timestamp is None else float(timestamp)
self._retime_transport(tempo_bpm, anchor_time)
self.state_changed.emit()
self.render_once(timestamp=anchor_time if timestamp is not None else None, force_preview=True)
def set_foh_mode(self, enabled: bool) -> None:
enabled = bool(enabled)
if self.foh_mode_enabled == enabled:
return
self.foh_mode_enabled = enabled
if not enabled:
if self._scene_transition is None:
self._sync_next_to_live()
self.state_changed.emit()
self.render_once(force_preview=True)
def set_transition_duration(self, duration_s: float) -> None:
duration = max(0.0, min(60.0, float(duration_s)))
if abs(duration - self.transition_duration_s) < 1e-9:
return
self.transition_duration_s = duration
self.state_changed.emit()
def set_pattern(self, pattern_id: str) -> None:
scene = self._editable_scene_snapshot()
pattern_id, params = self._normalize_pattern_request(pattern_id, scene.params)
scene.pattern_id = pattern_id
if params is not None:
scene.params = params
self._replace_editable_scene(scene)
if not self.foh_mode_enabled:
self.utility_mode = "none"
self._sync_next_to_live()
self.state_changed.emit()
self.render_once(force_preview=True)
def set_params(self, value: PatternParameters) -> None:
scene = self._editable_scene_snapshot()
params = value.clone().sanitized()
if params == scene.params:
return
scene.params = params
self._replace_editable_scene(scene)
if not self.foh_mode_enabled:
self._sync_next_to_live()
self.state_changed.emit()
self.render_once(force_preview=True)
def set_parameter(self, key: str, value) -> None:
if key == "speed":
try:
self.set_tempo_bpm(_legacy_speed_to_tempo_bpm(float(value)))
except (TypeError, ValueError) as exc:
self.status_message.emit(f"Invalid value for {key}: {value!r} ({exc})")
return
if key == "step_size":
self.status_message.emit("Step Size has been retired. Use the global BPM control instead.")
return
scene = self._editable_scene_snapshot()
if not hasattr(scene.params, key):
self.status_message.emit(f"Unknown parameter ignored: {key}")
return
payload = {
field_name: getattr(scene.params, field_name)
for field_name in scene.params.__dataclass_fields__
}
try:
if key in NUMERIC_PARAMETER_KEYS:
payload[key] = float(value)
else:
payload[key] = value
params = PatternParameters.from_dict(payload)
except (TypeError, ValueError) as exc:
self.status_message.emit(f"Invalid value for {key}: {value!r} ({exc})")
return
if params == scene.params:
return
scene.params = params
self._replace_editable_scene(scene)
if not self.foh_mode_enabled:
self._sync_next_to_live()
self.state_changed.emit()
if not self.timer.isActive() or self.current_frame is None:
self.render_once(force_preview=True)
def set_selected_tile(self, tile_id: str | None) -> None:
self.selected_tile_id = tile_id
self.state_changed.emit()
def set_backend(self, backend_id: str) -> None:
self.output_manager.set_active_backend(backend_id)
self.state_changed.emit()
self.status_message.emit(f"Active backend: {self.output_manager.active_backend().display_name}")
def set_output_enabled(self, enabled: bool) -> None:
self.output_manager.set_output_enabled(enabled)
self.state_changed.emit()
state = "enabled" if enabled else "disabled"
self.status_message.emit(f"Hardware output {state}.")
def set_output_target_fps(self, value: float) -> None:
self.output_manager.set_target_fps(value)
self.state_changed.emit()
def set_utility_mode(self, utility_mode: str) -> None:
self.utility_mode = utility_mode
self.state_changed.emit()
self.render_once(force_preview=True)
def clear_utility_mode(self) -> None:
self.utility_mode = "none"
self.state_changed.emit()
self.render_once(force_preview=True)
def go_scene(self) -> None:
self.utility_mode = "none"
self._scene_transition = None
self._transition_engine = None
self._live_scene = self._next_scene.clone()
self.live_engine = PatternEngine()
if not self.foh_mode_enabled:
self._sync_next_to_live()
self.state_changed.emit()
self.render_once(force_preview=True)
self.status_message.emit("Go: next scene is now live.")
def fade_go(self, duration_s: float | None = None, timestamp: float | None = None) -> None:
duration = self.transition_duration_s if duration_s is None else max(0.0, min(60.0, float(duration_s)))
self.transition_duration_s = duration
if duration <= 0.0:
self.go_scene()
return
now = time.perf_counter() if timestamp is None else timestamp
if self.current_frame is None:
self.render_once(timestamp=now, force_preview=True)
source_frame = self.current_frame
if source_frame is None:
source_frame = self._render_scene_frame(self.live_engine, self._live_scene, now, utility_mode=self.utility_mode)
self.utility_mode = "none"
self._transition_engine = PatternEngine()
self._scene_transition = SceneTransition(
started_at=now,
duration_s=duration,
source_frame=source_frame,
target_scene=self._next_scene.clone(),
)
self.state_changed.emit()
self.render_once(timestamp=now, force_preview=True)
self.status_message.emit(f"Fade Go: {duration:.1f}s transition started.")
def available_patterns(self):
return self.live_engine.descriptors()
def available_presets(self) -> list[PresetRecord]:
return self.preset_store.list_presets()
def save_current_preset(self, name: str) -> None:
record = PresetRecord.create(name=name, pattern_id=self.pattern_id, params=self.params, tempo_bpm=self.tempo_bpm)
self.preset_store.save(record)
self.presets_changed.emit()
self.status_message.emit(f"Saved preset: {name}")
def apply_preset(self, preset_name: str) -> None:
record = self.preset_store.load(preset_name)
params = PatternParameters.from_dict(record.parameters)
pattern_id, normalized_params = self._normalize_pattern_request(record.pattern_id, params)
scene = self._editable_scene_snapshot()
scene.pattern_id = pattern_id
scene.params = normalized_params if normalized_params is not None else params
self._replace_editable_scene(scene)
preset_tempo_bpm: float | None = None
if record.tempo_bpm is not None:
preset_tempo_bpm = _clamp_tempo_bpm(record.tempo_bpm)
elif "speed" in record.parameters:
try:
preset_tempo_bpm = _legacy_speed_to_tempo_bpm(float(record.parameters["speed"]))
except (TypeError, ValueError):
pass
if preset_tempo_bpm is not None:
self._retime_transport(preset_tempo_bpm, self._transport_time_s)
if not self.foh_mode_enabled:
self.utility_mode = "none"
self._sync_next_to_live()
self.state_changed.emit()
self.render_once(force_preview=True)
self.status_message.emit(f"Loaded preset: {preset_name}")
def delete_preset(self, preset_name: str) -> None:
self.preset_store.delete(preset_name)
self.presets_changed.emit()
self.status_message.emit(f"Deleted preset: {preset_name}")
def render_once(self, timestamp: float | None = None, force_preview: bool = False) -> None:
if not self.config.tiles:
return
render_started_at = time.perf_counter()
now = time.perf_counter() if timestamp is None else timestamp
self._transport_time_s = float(now)
try:
if self._scene_transition is None:
live_frame = self._render_scene_frame(
self.live_engine,
self._live_scene,
now,
utility_mode=self.utility_mode,
)
else:
if self._transition_engine is None:
self._transition_engine = PatternEngine()
target_frame = self._render_scene_frame(self._transition_engine, self._scene_transition.target_scene, now)
elapsed = max(0.0, now - self._scene_transition.started_at)
alpha = 1.0 if self._scene_transition.duration_s <= 0.0 else min(1.0, elapsed / self._scene_transition.duration_s)
live_frame = blend_preview_frames(self._scene_transition.source_frame, target_frame, alpha)
if alpha >= 1.0:
self._live_scene = self._scene_transition.target_scene.clone()
self.live_engine = self._transition_engine
self._transition_engine = None
self._scene_transition = None
live_frame = target_frame
if not self.foh_mode_enabled:
self._sync_next_to_live()
self.state_changed.emit()
self.current_frame = live_frame
if self.foh_mode_enabled:
self.next_frame = self._render_scene_frame(self.next_engine, self._next_scene, now)
elif self._next_scene.pattern_id == self._live_scene.pattern_id and self._next_scene.params.to_dict() == self._live_scene.params.to_dict():
self.next_frame = live_frame
self.output_manager.submit_frame(live_frame)
self._emit_preview_frames(live_frame, self.next_frame if self.foh_mode_enabled else None, now, force_preview)
self._emit_output_status_messages()
except Exception as exc: # pragma: no cover - UI safety net
message = f"Render error: {exc}"
if message != self._last_output_message:
self._last_output_message = message
self.status_message.emit(message)
traceback.print_exc()
finally:
self._record_render_metrics(render_started_at, time.perf_counter())
def realtime_diagnostics(self) -> RealtimeDiagnostics:
output = self.output_manager.diagnostics_snapshot()
return RealtimeDiagnostics(
backend_id=output.backend_id,
backend_name=output.backend_name,
output_enabled=output.output_enabled,
worker_running=output.worker_running,
target_output_fps=output.target_fps,
render_fps=self._render_fps,
send_fps=output.send_fps,
last_render_time_ms=self._last_render_duration_s * 1000.0,
last_send_time_ms=output.last_send_time_ms,
frames_rendered=self._frames_rendered,
frames_submitted=output.frames_submitted,
frames_sent=output.frames_sent,
stale_frame_drops=output.stale_frame_drops,
send_failures=output.send_failures,
packets_last_frame=output.packets_last_frame,
devices_last_frame=output.devices_last_frame,
packets_sent_total=output.packets_sent_total,
last_output_message=output.last_message,
send_budget_misses=output.send_budget_misses,
last_schedule_slip_ms=output.last_schedule_slip_ms,
controller_fps=output.controller_fps,
controller_live_devices=output.controller_live_devices,
controller_sampled_devices=output.controller_sampled_devices,
controller_total_devices=output.controller_total_devices,
controller_source=output.controller_source,
)
def shutdown(self) -> None:
self.timer.stop()
self.output_manager.shutdown()
def safe_load_mapping(self, path: str | Path) -> tuple[bool, list[str]]:
try:
self.load_mapping(path)
return True, []
except MappingValidationError as exc:
return False, exc.errors
def _emit_output_status_messages(self) -> None:
for message in self.output_manager.drain_status_messages():
if message != self._last_output_message:
self._last_output_message = message
self.status_message.emit(message)
def _record_render_metrics(self, started_at: float, finished_at: float) -> None:
self._last_render_duration_s = max(0.0, finished_at - started_at)
self._frames_rendered += 1
if self._render_window_started_at <= 0.0:
self._render_window_started_at = finished_at
self._render_window_count = 1
self._render_fps = 0.0
return
self._render_window_count += 1
elapsed = finished_at - self._render_window_started_at
if elapsed >= 0.5:
self._render_fps = self._render_window_count / elapsed
self._render_window_started_at = finished_at
self._render_window_count = 0
def _emit_preview_frames(
self,
live_frame: PreviewFrame,
next_frame: PreviewFrame | None,
timestamp: float,
force_preview: bool,
) -> None:
if self._preview_emit_due(self._last_live_preview_emit_time_s, timestamp, force_preview):
self._last_live_preview_emit_time_s = timestamp
self.frame_ready.emit(live_frame)
if next_frame is not None and self._preview_emit_due(self._last_next_preview_emit_time_s, timestamp, force_preview):
self._last_next_preview_emit_time_s = timestamp
self.next_frame_ready.emit(next_frame)
def _preview_emit_due(self, last_emit_time_s: float | None, timestamp: float, force_preview: bool) -> bool:
if force_preview or last_emit_time_s is None:
return True
if float(timestamp) <= last_emit_time_s:
return True
return (float(timestamp) - last_emit_time_s) >= self._preview_emit_interval_s()
def _preview_emit_interval_s(self) -> float:
try:
backend = self.output_manager.active_backend()
live_output_active = self.output_manager.output_enabled and backend.supports_live_output
except Exception:
live_output_active = False
preview_fps = PRIORITIZED_PREVIEW_FPS if live_output_active else DEFAULT_PREVIEW_FPS
return 1.0 / max(1.0, preview_fps)

32
app/core/diagnostics.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RealtimeDiagnostics:
backend_id: str
backend_name: str
output_enabled: bool
worker_running: bool
target_output_fps: float
render_fps: float
send_fps: float
last_render_time_ms: float
last_send_time_ms: float
frames_rendered: int
frames_submitted: int
frames_sent: int
stale_frame_drops: int
send_failures: int
packets_last_frame: int
devices_last_frame: int
packets_sent_total: int
last_output_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 = ""

89
app/core/geometry.py Normal file
View File

@@ -0,0 +1,89 @@
from __future__ import annotations
from app.config.models import SegmentConfig, TileConfig
from app.core.types import clamp
NormalizedPoint = tuple[float, float]
NormalizedInsets = tuple[float, float]
_SEGMENT_SIDE_ALIASES = {
"left": "left",
"right": "right",
"top": "top",
"bottom": "bottom",
"l": "left",
"r": "right",
"t": "top",
"b": "bottom",
}
def segment_side(tile: TileConfig, segment: SegmentConfig) -> str | None:
side = (segment.side or "").strip().lower()
if side in _SEGMENT_SIDE_ALIASES:
return _SEGMENT_SIDE_ALIASES[side]
width = max(0.001, tile.x1 - tile.x0)
height = max(0.001, tile.y1 - tile.y0)
delta_x = abs(segment.x1 - segment.x0) / width
delta_y = abs(segment.y1 - segment.y0) / height
mid_x = (segment.x0 + segment.x1) / 2.0
mid_y = (segment.y0 + segment.y1) / 2.0
if delta_x >= delta_y:
return "top" if mid_y <= tile.y0 + height * 0.5 else "bottom"
return "left" if mid_x <= tile.x0 + width * 0.5 else "right"
def segment_led_position(
tile: TileConfig,
segment: SegmentConfig,
amount: float,
*,
insets: NormalizedInsets = (0.0, 0.0),
apply_reverse: bool = False,
) -> NormalizedPoint:
amount = clamp(float(amount))
if apply_reverse and segment.reverse:
amount = 1.0 - amount
inset_x = clamp(float(insets[0]), 0.0, 0.49)
inset_y = clamp(float(insets[1]), 0.0, 0.49)
side = segment_side(tile, segment)
if side == "left":
return inset_x, inset_y + (1.0 - inset_y * 2.0) * amount
if side == "right":
return 1.0 - inset_x, inset_y + (1.0 - inset_y * 2.0) * amount
if side == "top":
return inset_x + (1.0 - inset_x * 2.0) * amount, inset_y
if side == "bottom":
return inset_x + (1.0 - inset_x * 2.0) * amount, 1.0 - inset_y
width = max(0.001, tile.x1 - tile.x0)
height = max(0.001, tile.y1 - tile.y0)
x_pos = segment.x0 + (segment.x1 - segment.x0) * amount
y_pos = segment.y0 + (segment.y1 - segment.y0) * amount
return (
clamp((x_pos - tile.x0) / width),
clamp((y_pos - tile.y0) / height),
)
def segment_led_positions(
tile: TileConfig,
segment: SegmentConfig,
*,
insets: NormalizedInsets = (0.0, 0.0),
) -> list[NormalizedPoint]:
count = max(1, segment.led_count)
return [
segment_led_position(
tile,
segment,
0.0 if count == 1 else index / (count - 1),
insets=insets,
apply_reverse=True,
)
for index in range(count)
]

125
app/core/pattern_compat.py Normal file
View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from app.core.types import PatternParameters, clamp
SCAN_ANGLES: tuple[int, ...] = (0, 45, 90, 135, 180, 225, 270, 315)
_LEGACY_SCAN_IDS = {
"row_chase",
"row_scan",
"column_chase",
"column_scan",
"diagonal_scan",
}
_DEFAULT_SCAN_PARAMS = PatternParameters()
def nearest_scan_angle(value: float) -> int:
angle = int(round(float(value))) % 360
return min(SCAN_ANGLES, key=lambda candidate: min((candidate - angle) % 360, (angle - candidate) % 360))
def coerce_bool(value: object, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return default
def _flip_angle_horizontal(angle: int) -> int:
return (180 - angle) % 360
def _flip_angle_vertical(angle: int) -> int:
return (-angle) % 360
def normalize_pattern_request(
pattern_id: str,
params: PatternParameters | None = None,
rows: int | None = None,
cols: int | None = None,
) -> tuple[str, PatternParameters | None]:
if pattern_id == "pixel_sparkle":
if params is None:
return "strobe", None
payload = params.to_dict()
payload["strobe_mode"] = "random_pixels"
return "strobe", PatternParameters.from_dict(payload)
if pattern_id == "random_blocks":
return "sparkle", params
if pattern_id == "scan" and params is not None:
payload = params.to_dict()
angle = nearest_scan_angle(getattr(params, "angle", payload.get("angle", 0.0)))
if coerce_bool(getattr(params, "flip_horizontal", False)):
angle = _flip_angle_horizontal(angle)
if coerce_bool(getattr(params, "flip_vertical", False)):
angle = _flip_angle_vertical(angle)
payload["angle"] = angle
payload.pop("band_thickness", None)
payload.pop("flip_horizontal", None)
payload.pop("flip_vertical", None)
return "scan", PatternParameters.from_dict(payload)
if pattern_id not in _LEGACY_SCAN_IDS:
return pattern_id, params
payload = params.to_dict() if params is not None else {}
if params is not None:
payload["flip_horizontal"] = getattr(params, "flip_horizontal", payload.get("flip_horizontal", False))
payload["flip_vertical"] = getattr(params, "flip_vertical", payload.get("flip_vertical", False))
payload["band_thickness"] = getattr(params, "band_thickness", payload.get("band_thickness", params.on_width))
direction = str(payload.get("direction", "left_to_right"))
diagonal_mode = str(payload.get("diagonal_scan_mode", "line"))
def preferred_value(key: str, legacy_value):
current_value = payload.get(key, getattr(_DEFAULT_SCAN_PARAMS, key))
default_value = getattr(_DEFAULT_SCAN_PARAMS, key)
return legacy_value if current_value == default_value else current_value
if pattern_id in {"row_chase", "row_scan"}:
angle = 90 if direction != "bottom_to_top" else 270
on_width = float(preferred_value("on_width", payload.get("block_size", 1.0)))
off_width = float(preferred_value("off_width", 0.0))
scan_style = str(preferred_value("scan_style", "line"))
elif pattern_id in {"column_chase", "column_scan"}:
angle = 0 if direction != "right_to_left" else 180
on_width = float(preferred_value("on_width", payload.get("block_size", 1.0)))
off_width = float(preferred_value("off_width", 0.0))
scan_style = str(preferred_value("scan_style", "line"))
else:
angle = {
"left_to_right": 315,
"right_to_left": 135,
"top_to_bottom": 315,
"bottom_to_top": 135,
}.get(direction, 315)
scan_style = "bands" if diagonal_mode == "bands" else "line"
scan_style = str(preferred_value("scan_style", scan_style))
on_width = float(preferred_value("on_width", 2.0 if scan_style == "bands" else 0.5))
off_width = float(preferred_value("off_width", 1.5 if scan_style == "bands" else 0.0))
angle = nearest_scan_angle(preferred_value("angle", angle))
if coerce_bool(preferred_value("flip_horizontal", False)):
angle = _flip_angle_horizontal(angle)
if coerce_bool(preferred_value("flip_vertical", False)):
angle = _flip_angle_vertical(angle)
payload["angle"] = angle
payload["scan_style"] = str(scan_style)
payload["on_width"] = clamp(float(on_width), 0.1, 2.0)
payload["off_width"] = clamp(float(off_width), 0.0, 2.0)
payload.pop("band_thickness", None)
payload.pop("flip_horizontal", None)
payload.pop("flip_vertical", None)
return "scan", PatternParameters.from_dict(payload)

324
app/core/pattern_engine.py Normal file
View File

@@ -0,0 +1,324 @@
from __future__ import annotations
import math
import time
from typing import cast
from app.config.models import InfinityMirrorConfig, SegmentConfig, TileConfig
from app.core.colors import choose_pair, darken, label_contrast, sample_palette
from app.core.geometry import segment_led_positions
from app.core.pattern_compat import normalize_pattern_request
from app.core.types import PatternParameters, PreviewFrame, RGBColor, TileFrame, TilePatternSample
from app.patterns.base import PatternContext, PatternDescriptor, PatternRegistry
from app.patterns.builtin import built_in_patterns
class PatternEngine:
def __init__(self) -> None:
self.registry = PatternRegistry(built_in_patterns())
self._last_time = time.perf_counter()
self._previous_samples: dict[str, TilePatternSample] = {}
def descriptors(self) -> list[PatternDescriptor]:
return self.registry.descriptors()
def render_frame(
self,
config: InfinityMirrorConfig,
pattern_id: str,
params: PatternParameters,
utility_mode: str = "none",
selected_tile_id: str | None = None,
timestamp: float | None = None,
tempo_bpm: float = 60.0,
tempo_phase: float | None = None,
) -> PreviewFrame:
params = params.sanitized()
pattern_id, normalized_params = normalize_pattern_request(
pattern_id,
params,
rows=config.logical_display.rows,
cols=config.logical_display.cols,
)
if normalized_params is not None:
params = normalized_params
timestamp = time.perf_counter() if timestamp is None else timestamp
if tempo_phase is None:
tempo_phase = timestamp * max(0.05, float(tempo_bpm) / 60.0)
delta = max(1.0 / 120.0, timestamp - self._last_time)
self._last_time = timestamp
if utility_mode != "none":
temporal_profile = "direct"
samples = self._render_utility_frame(config, params, utility_mode, selected_tile_id, timestamp)
else:
descriptor = self.registry.get(pattern_id).descriptor
temporal_profile = descriptor.temporal_profile
samples = self.registry.get(pattern_id).render(
PatternContext(
config=config,
params=params,
time_s=timestamp,
tempo_bpm=tempo_bpm,
tempo_phase=tempo_phase,
)
)
blend_alpha = 1.0 - math.exp(-delta * (10.0 * (1.0 - params.fade) + 1.0))
frame_tiles: dict[str, TileFrame] = {}
for tile in config.sorted_tiles():
sample = samples.get(tile.tile_id, self._fallback_sample(tile))
blended = sample if temporal_profile == "direct" else self._blend_sample(tile.tile_id, sample, blend_alpha)
self._previous_samples[tile.tile_id] = blended
brightness = params.brightness * tile.brightness_factor if tile.enabled else 0.04
fill = blended.fill_color.scaled(brightness)
glow = blended.glow_color.scaled(brightness)
rim = blended.rim_color.scaled(brightness)
metadata = self._scale_metadata(blended.metadata, brightness)
frame_tiles[tile.tile_id] = TileFrame(
tile_id=tile.tile_id,
row=tile.row,
col=tile.col,
fill_color=fill,
glow_color=glow,
rim_color=rim,
label_color=label_contrast(fill),
intensity=blended.intensity,
enabled=tile.enabled,
led_pixels=self._build_led_pixels(
tile,
fill,
rim,
timestamp,
max(0.05, tempo_bpm / 60.0),
config.defaults.tile_behavior,
metadata,
),
metadata=metadata,
)
if params.color_mode == "palette":
background_start = darken(sample_palette(params.palette, 0.78), 0.86)
background_end = darken(sample_palette(params.palette, 0.22), 0.94)
else:
primary, secondary = choose_pair(
params.color_mode,
params.primary_color,
params.secondary_color,
params.palette,
0.22,
)
background_start = darken(secondary.mix(primary, 0.18), 0.78)
background_end = darken(primary.mix(secondary, 0.12), 0.92)
return PreviewFrame(
timestamp=timestamp,
pattern_id=pattern_id,
utility_mode=utility_mode,
background_start=background_start,
background_end=background_end,
tiles=frame_tiles,
)
def _blend_sample(self, tile_id: str, sample: TilePatternSample, blend_alpha: float) -> TilePatternSample:
previous = self._previous_samples.get(tile_id, sample)
return TilePatternSample(
fill_color=previous.fill_color.mix(sample.fill_color, blend_alpha),
glow_color=previous.glow_color.mix(sample.glow_color, blend_alpha),
rim_color=previous.rim_color.mix(sample.rim_color, blend_alpha),
label_color=previous.label_color.mix(sample.label_color, blend_alpha),
intensity=previous.intensity + (sample.intensity - previous.intensity) * blend_alpha,
metadata=sample.metadata,
)
def _fallback_sample(self, tile: TileConfig) -> TilePatternSample:
color = RGBColor(0.08, 0.12, 0.16)
return TilePatternSample(
fill_color=color,
glow_color=color.mix(RGBColor.white(), 0.12),
rim_color=color.mix(RGBColor.white(), 0.22),
label_color=RGBColor.white(),
intensity=0.2,
)
def _scale_metadata(self, metadata: dict[str, object], brightness: float) -> dict[str, object]:
if not metadata:
return {}
scaled = dict(metadata)
diagonal_split = metadata.get("diagonal_split")
if isinstance(diagonal_split, dict):
color_a = diagonal_split.get("color_a")
color_b = diagonal_split.get("color_b")
scaled_a = color_a.scaled(brightness) if isinstance(color_a, RGBColor) else RGBColor.black()
scaled_b = color_b.scaled(brightness) if isinstance(color_b, RGBColor) else RGBColor.black()
scaled["diagonal_split"] = {
**diagonal_split,
"color_a": scaled_a,
"color_b": scaled_b,
}
led_pixels = metadata.get("led_pixels")
if isinstance(led_pixels, dict):
scaled_led_pixels: dict[str, list[RGBColor]] = {}
for segment_name, segment_colors in led_pixels.items():
if not isinstance(segment_name, str) or not isinstance(segment_colors, list):
continue
scaled_segment: list[RGBColor] = []
for color in segment_colors:
if not isinstance(color, RGBColor):
continue
scaled_segment.append(color.scaled(brightness))
scaled_led_pixels[segment_name] = scaled_segment
scaled["led_pixels"] = scaled_led_pixels
return scaled
def _build_led_pixels(
self,
tile: TileConfig,
fill_color: RGBColor,
rim_color: RGBColor,
timestamp: float,
tempo_hz: float,
tile_behavior: str,
metadata: dict[str, object],
) -> dict[str, list[RGBColor]]:
led_pixels = metadata.get("led_pixels")
if isinstance(led_pixels, dict):
return self._build_metadata_led_pixels(tile, cast(dict[str, list[RGBColor]], led_pixels))
diagonal_split = metadata.get("diagonal_split")
if isinstance(diagonal_split, dict):
return self._build_diagonal_split_pixels(tile, diagonal_split)
pixels: dict[str, list[RGBColor]] = {}
if tile_behavior == "solid_color_per_tile":
for segment in tile.segments:
pixels[segment.name] = [fill_color for _ in range(segment.led_count)]
return pixels
for segment in tile.segments:
segment_pixels: list[RGBColor] = []
for index in range(segment.led_count):
pulse = 0.04 * math.sin((timestamp * tempo_hz * 3.0) + index * 0.15 + tile.row * 0.9 + tile.col * 0.55)
amount = 0.05 + max(0.0, pulse)
segment_pixels.append(fill_color.mix(rim_color, amount))
if segment.reverse:
segment_pixels.reverse()
pixels[segment.name] = segment_pixels
return pixels
def _build_metadata_led_pixels(self, tile: TileConfig, led_pixels: dict[str, list[RGBColor]]) -> dict[str, list[RGBColor]]:
pixels: dict[str, list[RGBColor]] = {}
for segment in tile.segments:
colors = list(led_pixels.get(segment.name, []))
if len(colors) < segment.led_count:
colors.extend([RGBColor.black()] * (segment.led_count - len(colors)))
pixels[segment.name] = colors[: segment.led_count]
return pixels
def _build_diagonal_split_pixels(self, tile: TileConfig, diagonal_split: dict[str, object]) -> dict[str, list[RGBColor]]:
orientation = str(diagonal_split.get("orientation", "slash"))
color_a = diagonal_split.get("color_a")
color_b = diagonal_split.get("color_b")
if not isinstance(color_a, RGBColor) or not isinstance(color_b, RGBColor):
return {}
pixels: dict[str, list[RGBColor]] = {}
for segment in tile.segments:
segment_pixels: list[RGBColor] = []
for x_pos, y_pos in segment_led_positions(tile, segment):
if orientation == "backslash":
color = color_a if y_pos <= x_pos else color_b
else:
color = color_a if y_pos <= 1.0 - x_pos else color_b
segment_pixels.append(color)
pixels[segment.name] = segment_pixels
return pixels
def _render_utility_frame(
self,
config: InfinityMirrorConfig,
params: PatternParameters,
utility_mode: str,
selected_tile_id: str | None,
timestamp: float,
) -> dict[str, TilePatternSample]:
tiles = config.sorted_tiles()
blank = TilePatternSample(
fill_color=RGBColor.black(),
glow_color=RGBColor(0.04, 0.06, 0.07),
rim_color=RGBColor(0.08, 0.1, 0.12),
label_color=RGBColor.white(),
intensity=0.1,
)
if utility_mode == "blackout":
return {tile.tile_id: blank for tile in tiles}
if utility_mode == "identify":
active_index = int(timestamp * 2.0) % max(1, len(tiles))
result = {tile.tile_id: blank for tile in tiles}
active = tiles[active_index]
result[active.tile_id] = TilePatternSample(
fill_color=RGBColor.white().scaled(0.9),
glow_color=RGBColor(1.0, 0.85, 0.4),
rim_color=RGBColor.white(),
label_color=RGBColor.black(),
intensity=1.0,
metadata={"active": True},
)
return result
if utility_mode == "single_tile":
result = {tile.tile_id: blank for tile in tiles}
if selected_tile_id and selected_tile_id in result:
result[selected_tile_id] = TilePatternSample(
fill_color=RGBColor.white(),
glow_color=RGBColor(0.9, 0.96, 1.0),
rim_color=RGBColor.white(),
label_color=RGBColor.black(),
intensity=1.0,
metadata={"active": True},
)
return result
if utility_mode == "row_test":
palette = [sample_palette(params.palette, row / max(1, config.logical_display.rows - 1)) for row in range(config.logical_display.rows)]
return {
tile.tile_id: TilePatternSample(
fill_color=palette[tile.row - 1].scaled(0.95),
glow_color=palette[tile.row - 1].mix(RGBColor.white(), 0.2),
rim_color=palette[tile.row - 1].mix(RGBColor.white(), 0.3),
label_color=RGBColor.black() if tile.row == 1 else RGBColor.white(),
intensity=0.9,
)
for tile in tiles
}
if utility_mode == "column_test":
palette = [sample_palette(params.palette, col / max(1, config.logical_display.cols - 1)) for col in range(config.logical_display.cols)]
return {
tile.tile_id: TilePatternSample(
fill_color=palette[tile.col - 1].scaled(0.95),
glow_color=palette[tile.col - 1].mix(RGBColor.white(), 0.2),
rim_color=palette[tile.col - 1].mix(RGBColor.white(), 0.3),
label_color=RGBColor.white(),
intensity=0.9,
)
for tile in tiles
}
if utility_mode == "checker_test":
return {
tile.tile_id: TilePatternSample(
fill_color=(RGBColor.white() if (tile.row + tile.col) % 2 == 0 else RGBColor(0.05, 0.08, 0.1)),
glow_color=RGBColor(0.8, 0.9, 1.0) if (tile.row + tile.col) % 2 == 0 else RGBColor(0.08, 0.1, 0.14),
rim_color=RGBColor.white() if (tile.row + tile.col) % 2 == 0 else RGBColor(0.12, 0.16, 0.18),
label_color=RGBColor.black() if (tile.row + tile.col) % 2 == 0 else RGBColor.white(),
intensity=1.0 if (tile.row + tile.col) % 2 == 0 else 0.15,
)
for tile in tiles
}
return {tile.tile_id: blank for tile in tiles}

90
app/core/presets.py Normal file
View File

@@ -0,0 +1,90 @@
from __future__ import annotations
from dataclasses import dataclass, asdict
from datetime import datetime
from pathlib import Path
import json
import re
from .types import PatternParameters
@dataclass
class PresetRecord:
name: str
pattern_id: str
parameters: dict
brightness: float
palette: str
created_at: str
tempo_bpm: float | None = None
@classmethod
def create(cls, name: str, pattern_id: str, params: PatternParameters, tempo_bpm: float | None = None) -> "PresetRecord":
return cls(
name=name,
pattern_id=pattern_id,
parameters=params.to_dict(),
brightness=params.brightness,
palette=params.palette,
created_at=datetime.utcnow().isoformat(timespec="seconds"),
tempo_bpm=tempo_bpm,
)
class PresetStore:
def __init__(self, root: str | Path) -> None:
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
def list_presets(self) -> list[PresetRecord]:
presets: list[PresetRecord] = []
for path in sorted(self.root.glob("*.json")):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
presets.append(PresetRecord(**payload))
except (OSError, json.JSONDecodeError, TypeError):
continue
return presets
def save(self, record: PresetRecord) -> Path:
path = self.root / f"{slugify(record.name)}.json"
path.write_text(json.dumps(asdict(record), indent=2), encoding="utf-8")
return path
def load(self, name: str) -> PresetRecord:
path = self.root / f"{slugify(name)}.json"
payload = json.loads(path.read_text(encoding="utf-8"))
return PresetRecord(**payload)
def delete(self, name: str) -> None:
path = self.root / f"{slugify(name)}.json"
if path.exists():
path.unlink()
def ensure_seed_presets(self) -> None:
if any(self.root.glob("*.json")):
return
seeds = [
PresetRecord.create(
"Afterhours Pulse",
"center_pulse",
PatternParameters(palette="Afterhours", color_mode="palette", fade=0.28),
),
PresetRecord.create(
"Laser Chase",
"scan_dual",
PatternParameters(palette="Laser Club", block_size=1.6, direction="left_to_right"),
),
PresetRecord.create(
"Heat Breathing",
"breathing",
PatternParameters(palette="Warehouse Heat", color_mode="palette", fade=0.7),
),
]
for preset in seeds:
self.save(preset)
def slugify(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "preset"

342
app/core/types.py Normal file
View File

@@ -0,0 +1,342 @@
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import Any
def clamp(value: float, minimum: float = 0.0, maximum: float = 1.0) -> float:
return max(minimum, min(maximum, value))
_SCAN_ANGLES = (0, 45, 90, 135, 180, 225, 270, 315)
def _nearest_scan_angle(value: float) -> int:
angle = int(round(float(value))) % 360
return min(_SCAN_ANGLES, key=lambda candidate: min((candidate - angle) % 360, (angle - candidate) % 360))
def _coerce_bool(value: object, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return default
@dataclass(frozen=True)
class RGBColor:
r: float
g: float
b: float
def clamped(self) -> "RGBColor":
return RGBColor(clamp(self.r), clamp(self.g), clamp(self.b))
def scaled(self, factor: float) -> "RGBColor":
return RGBColor(self.r * factor, self.g * factor, self.b * factor).clamped()
def mix(self, other: "RGBColor", amount: float) -> "RGBColor":
amount = clamp(amount)
return RGBColor(
self.r + (other.r - self.r) * amount,
self.g + (other.g - self.g) * amount,
self.b + (other.b - self.b) * amount,
).clamped()
def to_8bit_tuple(self) -> tuple[int, int, int]:
value = self.clamped()
return int(value.r * 255), int(value.g * 255), int(value.b * 255)
def to_hex(self) -> str:
red, green, blue = self.to_8bit_tuple()
return f"#{red:02X}{green:02X}{blue:02X}"
@staticmethod
def black() -> "RGBColor":
return RGBColor(0.0, 0.0, 0.0)
@staticmethod
def white() -> "RGBColor":
return RGBColor(1.0, 1.0, 1.0)
@dataclass
class PatternParameters:
speed: float = 0.45
brightness: float = 1.0
fade: float = 0.35
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
band_thickness: float = 0.8
flip_horizontal: bool = False
flip_vertical: bool = False
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
def clone(self) -> "PatternParameters":
return replace(self)
def sanitized(self) -> "PatternParameters":
return PatternParameters(
speed=max(0.01, self.speed),
brightness=clamp(self.brightness, 0.0, 2.0),
fade=clamp(self.fade),
tempo_multiplier=clamp(self.tempo_multiplier, 0.25, 8.0),
direction=self.direction,
checker_mode=self.checker_mode,
scan_style=self.scan_style if self.scan_style in {"line", "bands"} else "line",
angle=_nearest_scan_angle(self.angle),
on_width=clamp(self.on_width, 0.1, 2.0),
off_width=clamp(self.off_width, 0.0, 2.0),
band_thickness=clamp(self.band_thickness, 0.1, 2.0),
flip_horizontal=bool(self.flip_horizontal),
flip_vertical=bool(self.flip_vertical),
strobe_mode=self.strobe_mode,
stopwatch_mode=self.stopwatch_mode,
color_mode=self.color_mode,
primary_color=self.primary_color,
secondary_color=self.secondary_color,
palette=self.palette,
symmetry=self.symmetry,
center_pulse_mode=self.center_pulse_mode if self.center_pulse_mode in {"expand", "reverse", "outline", "outline_reverse"} else "expand",
step_size=max(0.1, self.step_size),
block_size=max(0.1, self.block_size),
pixel_group_size=max(1.0, min(5.0, round(self.pixel_group_size))),
strobe_duty_cycle=clamp(self.strobe_duty_cycle, 0.02, 0.98),
randomness=clamp(self.randomness, 0.0, 1.5),
)
def to_dict(self) -> dict[str, Any]:
return {
"brightness": self.brightness,
"fade": self.fade,
"tempo_multiplier": self.tempo_multiplier,
"direction": self.direction,
"checker_mode": self.checker_mode,
"scan_style": self.scan_style,
"angle": self.angle,
"on_width": self.on_width,
"off_width": self.off_width,
"strobe_mode": self.strobe_mode,
"stopwatch_mode": self.stopwatch_mode,
"color_mode": self.color_mode,
"primary_color": self.primary_color,
"secondary_color": self.secondary_color,
"palette": self.palette,
"symmetry": self.symmetry,
"center_pulse_mode": self.center_pulse_mode,
"block_size": self.block_size,
"pixel_group_size": self.pixel_group_size,
"strobe_duty_cycle": self.strobe_duty_cycle,
"randomness": self.randomness,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "PatternParameters":
return cls(
speed=float(payload.get("speed", cls.speed)),
brightness=float(payload.get("brightness", cls.brightness)),
fade=float(payload.get("fade", cls.fade)),
tempo_multiplier=float(payload.get("tempo_multiplier", cls.tempo_multiplier)),
direction=str(payload.get("direction", cls.direction)),
checker_mode=str(payload.get("checker_mode", cls.checker_mode)),
scan_style=str(payload.get("scan_style", payload.get("diagonal_scan_mode", cls.scan_style))),
angle=float(payload.get("angle", cls.angle)),
on_width=float(payload.get("on_width", cls.on_width)),
off_width=float(payload.get("off_width", cls.off_width)),
band_thickness=float(payload.get("band_thickness", payload.get("on_width", cls.band_thickness))),
flip_horizontal=_coerce_bool(payload.get("flip_horizontal", cls.flip_horizontal)),
flip_vertical=_coerce_bool(payload.get("flip_vertical", cls.flip_vertical)),
strobe_mode=str(payload.get("strobe_mode", cls.strobe_mode)),
stopwatch_mode=str(payload.get("stopwatch_mode", cls.stopwatch_mode)),
color_mode=str(payload.get("color_mode", cls.color_mode)),
primary_color=str(payload.get("primary_color", cls.primary_color)),
secondary_color=str(payload.get("secondary_color", cls.secondary_color)),
palette=str(payload.get("palette", cls.palette)),
symmetry=str(payload.get("symmetry", cls.symmetry)),
center_pulse_mode=str(payload.get("center_pulse_mode", cls.center_pulse_mode)),
step_size=float(payload.get("step_size", cls.step_size)),
block_size=float(payload.get("block_size", cls.block_size)),
pixel_group_size=float(payload.get("pixel_group_size", cls.pixel_group_size)),
strobe_duty_cycle=float(payload.get("strobe_duty_cycle", cls.strobe_duty_cycle)),
randomness=float(payload.get("randomness", cls.randomness)),
).sanitized()
@dataclass
class SceneState:
pattern_id: str = "solid"
params: PatternParameters = field(default_factory=PatternParameters)
def clone(self) -> "SceneState":
return SceneState(
pattern_id=self.pattern_id,
params=self.params.clone(),
)
@dataclass
class TilePatternSample:
fill_color: RGBColor
glow_color: RGBColor
rim_color: RGBColor
label_color: RGBColor
intensity: float = 1.0
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class TileFrame:
tile_id: str
row: int
col: int
fill_color: RGBColor
glow_color: RGBColor
rim_color: RGBColor
label_color: RGBColor
intensity: float
enabled: bool
led_pixels: dict[str, list[RGBColor]] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class PreviewFrame:
timestamp: float
pattern_id: str
utility_mode: str
background_start: RGBColor
background_end: RGBColor
tiles: dict[str, TileFrame]
@dataclass
class SceneTransition:
started_at: float
duration_s: float
source_frame: PreviewFrame
target_scene: SceneState
def _blend_led_pixel_groups(
source_pixels: dict[str, list[RGBColor]],
target_pixels: dict[str, list[RGBColor]],
amount: float,
) -> dict[str, list[RGBColor]]:
if not source_pixels and not target_pixels:
return {}
blended: dict[str, list[RGBColor]] = {}
black = RGBColor.black()
for segment_name in sorted(set(source_pixels) | set(target_pixels)):
source_segment = source_pixels.get(segment_name, [])
target_segment = target_pixels.get(segment_name, [])
count = max(len(source_segment), len(target_segment))
segment_colors: list[RGBColor] = []
for index in range(count):
source_color = source_segment[index] if index < len(source_segment) else black
target_color = target_segment[index] if index < len(target_segment) else black
segment_colors.append(source_color.mix(target_color, amount))
blended[segment_name] = segment_colors
return blended
def _blend_metadata(source: dict[str, Any], target: dict[str, Any], amount: float) -> dict[str, Any]:
if not source and not target:
return {}
blended = {
key: value
for key, value in source.items()
if key not in {"diagonal_split", "led_pixels"}
}
for key, value in target.items():
if key not in {"diagonal_split", "led_pixels"}:
blended[key] = value
source_split = source.get("diagonal_split")
target_split = target.get("diagonal_split")
if isinstance(source_split, dict) or isinstance(target_split, dict):
source_split = source_split if isinstance(source_split, dict) else {}
target_split = target_split if isinstance(target_split, dict) else {}
source_a = source_split.get("color_a", RGBColor.black())
source_b = source_split.get("color_b", RGBColor.black())
target_a = target_split.get("color_a", RGBColor.black())
target_b = target_split.get("color_b", RGBColor.black())
if isinstance(source_a, RGBColor) and isinstance(source_b, RGBColor) and isinstance(target_a, RGBColor) and isinstance(target_b, RGBColor):
blended["diagonal_split"] = {
"orientation": str(target_split.get("orientation", source_split.get("orientation", "slash"))),
"color_a": source_a.mix(target_a, amount),
"color_b": source_b.mix(target_b, amount),
}
source_pixels = source.get("led_pixels")
target_pixels = target.get("led_pixels")
if isinstance(source_pixels, dict) or isinstance(target_pixels, dict):
source_pixels = source_pixels if isinstance(source_pixels, dict) else {}
target_pixels = target_pixels if isinstance(target_pixels, dict) else {}
blended["led_pixels"] = _blend_led_pixel_groups(source_pixels, target_pixels, amount)
return blended
def blend_preview_frames(source: PreviewFrame, target: PreviewFrame, amount: float) -> PreviewFrame:
amount = clamp(amount)
blended_tiles: dict[str, TileFrame] = {}
for tile_id in sorted(set(source.tiles) | set(target.tiles)):
source_tile = source.tiles.get(tile_id)
target_tile = target.tiles.get(tile_id)
if source_tile is None and target_tile is not None:
blended_tiles[tile_id] = target_tile
continue
if target_tile is None and source_tile is not None:
blended_tiles[tile_id] = source_tile
continue
if source_tile is None or target_tile is None:
continue
blended_tiles[tile_id] = TileFrame(
tile_id=target_tile.tile_id,
row=target_tile.row,
col=target_tile.col,
fill_color=source_tile.fill_color.mix(target_tile.fill_color, amount),
glow_color=source_tile.glow_color.mix(target_tile.glow_color, amount),
rim_color=source_tile.rim_color.mix(target_tile.rim_color, amount),
label_color=source_tile.label_color.mix(target_tile.label_color, amount),
intensity=source_tile.intensity + (target_tile.intensity - source_tile.intensity) * amount,
enabled=source_tile.enabled and target_tile.enabled,
led_pixels=_blend_led_pixel_groups(source_tile.led_pixels, target_tile.led_pixels, amount),
metadata=_blend_metadata(source_tile.metadata, target_tile.metadata, amount),
)
return PreviewFrame(
timestamp=target.timestamp,
pattern_id=target.pattern_id if amount >= 0.5 else source.pattern_id,
utility_mode=target.utility_mode if amount >= 0.5 else source.utility_mode,
background_start=source.background_start.mix(target.background_start, amount),
background_end=source.background_end.mix(target.background_end, amount),
tiles=blended_tiles,
)

35
app/main.py Normal file
View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import sys
from pathlib import Path
from app.qt_compat import QApplication, QT_API, QT_IMPORT_ERROR
def main() -> int:
from app.core.controller import InfinityMirrorController
from app.ui.main_window import MainWindow
from app.ui.theme import apply_dark_theme
if QApplication is None:
print("No supported Qt binding could be loaded.")
if QT_IMPORT_ERROR is not None:
print(f"Import error: {QT_IMPORT_ERROR}")
return 1
app = QApplication(sys.argv)
apply_dark_theme(app)
project_root = Path(__file__).resolve().parents[1]
controller = InfinityMirrorController(project_root)
controller.load_initial_config()
app.aboutToQuit.connect(controller.shutdown)
window = MainWindow(controller)
window.statusBar().showMessage(f"Using Qt binding: {QT_API}", 5000)
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())

23
app/network/__init__.py Normal file
View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from .wled import (
DiscoveredWledDevice,
build_scan_hosts,
discover_wled_devices,
fetch_wled_info,
identify_wled_device,
normalize_mac_address,
probe_wled_device,
scan_candidate_subnets,
)
__all__ = [
"DiscoveredWledDevice",
"build_scan_hosts",
"discover_wled_devices",
"fetch_wled_info",
"identify_wled_device",
"normalize_mac_address",
"probe_wled_device",
"scan_candidate_subnets",
]

Binary file not shown.

Binary file not shown.

292
app/network/wled.py Normal file
View File

@@ -0,0 +1,292 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
import ipaddress
import json
import socket
import struct
import time
from typing import Callable, Iterable, Sequence
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from app.config.models import InfinityMirrorConfig
WLED_INFO_TIMEOUT_S = 0.35
WLED_DISCOVERY_WORKERS = 32
WLED_DDP_PORT = 4048
WLED_DDP_HEADER_LENGTH = 10
WLED_DDP_MAX_DATA_LENGTH = 1440
WLED_DDP_VERSION_1 = 0x40
WLED_DDP_PUSH_FLAG = 0x01
WLED_DDP_RGB888 = 0x0B
WLED_DDP_DESTINATION_ID = 1
@dataclass(frozen=True)
class DiscoveredWledDevice:
ip_address: str
hostname: str = ""
instance_name: str = ""
mac_address: str = ""
led_count: int = 0
info_endpoint: str = ""
def normalize_mac_address(value: str) -> str:
raw = str(value or "").strip().replace("-", ":").upper()
if not raw:
return ""
parts = [part.zfill(2) for part in raw.split(":") if part]
if len(parts) == 6 and all(len(part) == 2 for part in parts):
return ":".join(parts)
return raw
def fetch_wled_info(host: str, timeout_s: float = WLED_INFO_TIMEOUT_S) -> tuple[dict[str, object], str] | None:
normalized_host = str(host or "").strip()
if not normalized_host:
return None
for endpoint in ("/json/info", "/json"):
payload = _load_json(f"http://{normalized_host}{endpoint}", timeout_s=timeout_s)
if not isinstance(payload, dict):
continue
if endpoint == "/json/info":
if _looks_like_wled_info(payload):
return payload, endpoint
continue
info = payload.get("info")
if isinstance(info, dict) and _looks_like_wled_info(info):
return info, endpoint
return None
def probe_wled_device(host: str, timeout_s: float = WLED_INFO_TIMEOUT_S) -> DiscoveredWledDevice | None:
info_result = fetch_wled_info(host, timeout_s=timeout_s)
if info_result is None:
return None
info, endpoint = info_result
hostname = _string_value(info.get("mdns")) or _reverse_dns_name(host)
instance_name = _string_value(info.get("name"))
mac_address = normalize_mac_address(_string_value(info.get("mac")))
leds = info.get("leds")
led_count = 0
if isinstance(leds, dict):
count = leds.get("count")
if isinstance(count, str):
try:
led_count = int(count)
except ValueError:
led_count = 0
elif isinstance(count, (int, float)):
led_count = int(count)
return DiscoveredWledDevice(
ip_address=str(host).strip(),
hostname=hostname,
instance_name=instance_name,
mac_address=mac_address,
led_count=max(0, led_count),
info_endpoint=endpoint,
)
def scan_candidate_subnets(config: InfinityMirrorConfig | None = None) -> list[ipaddress.IPv4Network]:
networks: list[ipaddress.IPv4Network] = []
seen: set[str] = set()
for address in _candidate_ipv4_addresses(config):
try:
ip_value = ipaddress.ip_address(address)
except ValueError:
continue
if not isinstance(ip_value, ipaddress.IPv4Address):
continue
if ip_value.is_loopback or ip_value.is_link_local or ip_value.is_unspecified:
continue
network = ipaddress.ip_network(f"{ip_value}/24", strict=False)
key = str(network)
if key in seen:
continue
seen.add(key)
networks.append(network)
return sorted(networks, key=lambda network: int(network.network_address))
def build_scan_hosts(config: InfinityMirrorConfig | None = None, max_subnets: int = 3) -> list[str]:
preferred_hosts = [tile.controller_ip.strip() for tile in config.sorted_tiles()] if config is not None else []
prioritized = [host for host in preferred_hosts if host]
hosts: list[str] = []
seen: set[str] = set()
for host in prioritized:
if host not in seen:
seen.add(host)
hosts.append(host)
for network in scan_candidate_subnets(config)[: max(1, max_subnets)]:
for host in network.hosts():
text = str(host)
if text in seen:
continue
seen.add(text)
hosts.append(text)
return hosts
def discover_wled_devices(
hosts: Sequence[str],
*,
timeout_s: float = WLED_INFO_TIMEOUT_S,
max_workers: int = WLED_DISCOVERY_WORKERS,
progress_callback: Callable[[int, int, DiscoveredWledDevice | None], None] | None = None,
) -> list[DiscoveredWledDevice]:
unique_hosts = [str(host).strip() for host in hosts if str(host).strip()]
total = len(unique_hosts)
if total == 0:
return []
devices: list[DiscoveredWledDevice] = []
completed = 0
seen_device_keys: set[str] = set()
with ThreadPoolExecutor(max_workers=max(1, min(max_workers, total))) as executor:
future_map = {
executor.submit(probe_wled_device, host, timeout_s): host
for host in unique_hosts
}
for future in as_completed(future_map):
completed += 1
device: DiscoveredWledDevice | None = None
try:
device = future.result()
except Exception:
device = None
if device is not None:
key = device.mac_address or device.ip_address
if key not in seen_device_keys:
seen_device_keys.add(key)
devices.append(device)
if progress_callback is not None:
progress_callback(completed, total, device)
return sorted(devices, key=lambda item: tuple(int(part) for part in item.ip_address.split(".")))
def identify_wled_device(
host: str,
*,
led_count: int | None = None,
duration_s: float = 1.6,
pulse_interval_s: float = 0.22,
) -> None:
device = probe_wled_device(host, timeout_s=max(WLED_INFO_TIMEOUT_S, 0.45))
if device is None:
raise OSError(f"WLED device at {host} is unreachable.")
pixel_count = int(led_count or device.led_count)
if pixel_count <= 0:
raise ValueError(f"Unable to determine LED count for {host}.")
on_payload = bytes((255, 32, 32)) * pixel_count
off_payload = bytes((0, 0, 0)) * pixel_count
deadline = time.monotonic() + max(0.2, float(duration_s))
pulse_delay = max(0.08, float(pulse_interval_s))
sequence = 1
visible_phase = True
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
while True:
_send_ddp_frame(sock, host, on_payload if visible_phase else off_payload, sequence)
sequence = 1 if sequence >= 15 else sequence + 1
remaining = deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(pulse_delay, remaining))
visible_phase = not visible_phase
_send_ddp_frame(sock, host, off_payload, sequence)
def _candidate_ipv4_addresses(config: InfinityMirrorConfig | None = None) -> Iterable[str]:
if config is not None:
for tile in config.sorted_tiles():
controller_ip = tile.controller_ip.strip()
if controller_ip:
yield controller_ip
yielded: set[str] = set()
for host in {socket.gethostname(), socket.getfqdn()}:
try:
_, _, addresses = socket.gethostbyname_ex(host)
except OSError:
continue
for address in addresses:
if address and address not in yielded:
yielded.add(address)
yield address
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect(("8.8.8.8", 80))
address = sock.getsockname()[0]
if address and address not in yielded:
yield address
except OSError:
return
def _load_json(url: str, *, timeout_s: float) -> dict[str, object] | None:
request = Request(url, headers={"Accept": "application/json"})
try:
with urlopen(request, timeout=timeout_s) as response:
payload = json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, OSError, TimeoutError, json.JSONDecodeError, UnicodeDecodeError):
return None
return payload if isinstance(payload, dict) else None
def _looks_like_wled_info(payload: dict[str, object]) -> bool:
if not isinstance(payload, dict):
return False
if "leds" not in payload:
return False
return any(key in payload for key in ("name", "ver", "mac"))
def _reverse_dns_name(host: str) -> str:
try:
name, _, _ = socket.gethostbyaddr(host)
except OSError:
return ""
return "" if name == host else name
def _string_value(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def _send_ddp_frame(sock: socket.socket, host: str, payload: bytes, sequence: int) -> None:
for offset in range(0, len(payload), WLED_DDP_MAX_DATA_LENGTH):
chunk = payload[offset : offset + WLED_DDP_MAX_DATA_LENGTH]
last = offset + WLED_DDP_MAX_DATA_LENGTH >= len(payload)
header = struct.pack(
"!BBBBLH",
WLED_DDP_VERSION_1 | (WLED_DDP_PUSH_FLAG if last else 0),
sequence,
WLED_DDP_RGB888,
WLED_DDP_DESTINATION_ID,
offset,
len(chunk),
)
sock.sendto(header + chunk, (host, WLED_DDP_PORT))

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)

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

@@ -0,0 +1,2 @@
"""Pattern registry and built-in pattern implementations."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

276
app/patterns/base.py Normal file
View File

@@ -0,0 +1,276 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Iterable
from app.config.models import InfinityMirrorConfig, TileConfig
from app.core.types import PatternParameters, TilePatternSample, clamp
@dataclass(frozen=True)
class ParameterSpec:
key: str
label: str
kind: str
minimum: float = 0.0
maximum: float = 1.0
step: float = 0.01
reset_value: float | None = None
options: tuple[tuple[str, str], ...] = ()
tooltip: str = ""
@dataclass(frozen=True)
class PatternDescriptor:
pattern_id: str
display_name: str
description: str
supported_parameters: tuple[str, ...]
accent_hex: str = "#4D7CFF"
temporal_profile: str = "smooth"
@dataclass
class PatternContext:
config: InfinityMirrorConfig
params: PatternParameters
time_s: float
tempo_bpm: float = 60.0
tempo_phase: float = 0.0
@property
def rows(self) -> int:
return self.config.logical_display.rows
@property
def cols(self) -> int:
return self.config.logical_display.cols
def sorted_tiles(self) -> list[TileConfig]:
return self.config.sorted_tiles()
@property
def tempo_hz(self) -> float:
return max(0.05, float(self.tempo_bpm) / 60.0)
@property
def tempo_multiplier(self) -> float:
return clamp(float(self.params.tempo_multiplier), 0.25, 8.0)
@property
def pattern_tempo_hz(self) -> float:
return self.tempo_hz * self.tempo_multiplier
@property
def pattern_tempo_phase(self) -> float:
return self.tempo_phase * self.tempo_multiplier
class BasePattern(ABC):
descriptor: PatternDescriptor
@abstractmethod
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
raise NotImplementedError
class PatternRegistry:
def __init__(self, patterns: Iterable[BasePattern]) -> None:
self._patterns = {pattern.descriptor.pattern_id: pattern for pattern in patterns}
def get(self, pattern_id: str) -> BasePattern:
return self._patterns[pattern_id]
def descriptors(self) -> list[PatternDescriptor]:
return [self._patterns[key].descriptor for key in sorted(self._patterns)]
def ids(self) -> list[str]:
return list(sorted(self._patterns))
COMMON_PARAMETER_SPECS: dict[str, ParameterSpec] = {
"brightness": ParameterSpec("brightness", "Brightness", "slider", 0.0, 2.0, 0.01, reset_value=1.0, tooltip="Pattern output level."),
"fade": ParameterSpec("fade", "Smoothing", "slider", 0.0, 1.0, 0.01, reset_value=0.0, tooltip="Higher values create softer transitions."),
"tempo_multiplier": ParameterSpec(
"tempo_multiplier",
"Tempo Multiplier",
"slider",
0.25,
8.0,
0.05,
reset_value=1.0,
tooltip="Scales this pattern relative to the global BPM.",
),
"direction": ParameterSpec(
"direction",
"Direction",
"combo",
options=(
("left_to_right", "Left to Right"),
("right_to_left", "Right to Left"),
("top_to_bottom", "Top to Bottom"),
("bottom_to_top", "Bottom to Top"),
("outward", "Outward"),
("inward", "Inward"),
),
tooltip="Primary motion direction.",
),
"checker_mode": ParameterSpec(
"checker_mode",
"Checker Mode",
"combo",
options=(
("classic", "Classic"),
("diagonal", "Diagonal Split"),
("checkerd", "Checkerd"),
),
tooltip="Classic checker, diagonal half-pixels, or diagonal flip animation.",
),
"scan_style": ParameterSpec(
"scan_style",
"Scan Style",
"combo",
options=(
("line", "Line"),
("bands", "Bands"),
),
tooltip="Single moving scan band or repeating band pattern.",
),
"angle": ParameterSpec(
"angle",
"Angle",
"angle",
minimum=0.0,
maximum=315.0,
step=45.0,
tooltip="Scan direction in 45 degree steps.",
),
"on_width": ParameterSpec(
"on_width",
"On Width",
"slider",
0.1,
2.0,
0.05,
tooltip="Length of the active scan window.",
),
"off_width": ParameterSpec(
"off_width",
"Off Width",
"slider",
0.1,
2.0,
0.05,
tooltip="Gap between active scan windows.",
),
"band_thickness": ParameterSpec(
"band_thickness",
"Band Thickness",
"slider",
0.1,
2.0,
0.05,
tooltip="Visible thickness of the lit band inside the active window.",
),
"flip_horizontal": ParameterSpec(
"flip_horizontal",
"Flip Horizontal",
"checkbox",
tooltip="Mirror scan evaluation left-to-right for installation alignment.",
),
"flip_vertical": ParameterSpec(
"flip_vertical",
"Flip Vertical",
"checkbox",
tooltip="Mirror scan evaluation top-to-bottom for installation alignment.",
),
"strobe_mode": ParameterSpec(
"strobe_mode",
"Strobe Mode",
"combo",
options=(
("global", "Global"),
("random_pixels", "Random Pixels"),
("random_leds", "Random LEDs"),
),
tooltip="Whole-wall strobe, grouped random pixel blocks, or fully shuffled per-LED timing.",
),
"stopwatch_mode": ParameterSpec(
"stopwatch_mode",
"Stopwatch Mode",
"combo",
options=(
("sync", "Sync"),
("random", "Random"),
),
tooltip="Run all tiles together or with deterministic random offsets.",
),
"color_mode": ParameterSpec(
"color_mode",
"Color Mode",
"combo",
options=(
("dual", "Dual"),
("palette", "Palette"),
("mono", "Mono"),
("complementary", "Complementary"),
("random_colors", "Random Colors"),
("custom_random", "Custom Random"),
),
tooltip="How colors are chosen for the pattern.",
),
"primary_color": ParameterSpec("primary_color", "Primary Color", "color", tooltip="Main color."),
"secondary_color": ParameterSpec("secondary_color", "Secondary Color", "color", tooltip="Secondary color."),
"palette": ParameterSpec("palette", "Palette", "combo", tooltip="Palette for palette-driven patterns."),
"symmetry": ParameterSpec(
"symmetry",
"Mirror",
"combo",
options=(("none", "None"), ("horizontal", "Horizontal"), ("vertical", "Vertical"), ("both", "Both")),
tooltip="Mirrors pattern coordinates around the center.",
),
"center_pulse_mode": ParameterSpec(
"center_pulse_mode",
"Pulse Mode",
"combo",
options=(
("expand", "Expand"),
("reverse", "Reverse"),
("outline", "Outline"),
("outline_reverse", "Outline Reverse"),
),
tooltip="Expand from the center, run inward, or use only the rectangular outline rings.",
),
"block_size": ParameterSpec("block_size", "Block Size", "slider", 0.1, 6.0, 0.1, tooltip="Width of active bands."),
"pixel_group_size": ParameterSpec(
"pixel_group_size",
"Pixel Group",
"slider",
1.0,
5.0,
1.0,
reset_value=1.0,
tooltip="Treat several adjacent LEDs as one strobe pixel.",
),
"strobe_duty_cycle": ParameterSpec(
"strobe_duty_cycle",
"Duty / Density",
"slider",
0.005,
0.98,
0.005,
reset_value=0.5,
tooltip="Controls strobe on-time or sparkle fill density depending on the pattern.",
),
"randomness": ParameterSpec(
"randomness",
"Randomness",
"slider",
0.0,
1.5,
0.01,
reset_value=0.35,
tooltip="Controls variation in patterns that intentionally use randomness.",
),
}

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from ..base import BasePattern
from .fills import (
BreathingPattern,
CenterPulsePattern,
CheckerPattern,
ColumnGradientPattern,
RowGradientPattern,
SolidPattern,
SparklePattern,
)
from .motion import (
ArrowPattern,
SawPattern,
ScanDualPattern,
ScanPattern,
SweepPattern,
TwoDotsPattern,
WaveLinePattern,
)
from .special import SnakePattern, StopwatchPattern, StrobePattern
def built_in_patterns() -> list[BasePattern]:
return [
ArrowPattern(),
BreathingPattern(),
CenterPulsePattern(),
CheckerPattern(),
ColumnGradientPattern(),
RowGradientPattern(),
SawPattern(),
ScanPattern(),
ScanDualPattern(),
SnakePattern(),
SolidPattern(),
SparklePattern(),
StopwatchPattern(),
StrobePattern(),
SweepPattern(),
TwoDotsPattern(),
WaveLinePattern(),
]
__all__ = [
"ArrowPattern",
"BreathingPattern",
"CenterPulsePattern",
"CheckerPattern",
"ColumnGradientPattern",
"RowGradientPattern",
"SawPattern",
"ScanDualPattern",
"ScanPattern",
"SnakePattern",
"SolidPattern",
"SparklePattern",
"StopwatchPattern",
"StrobePattern",
"SweepPattern",
"TwoDotsPattern",
"WaveLinePattern",
"built_in_patterns",
]

Binary file not shown.

View File

@@ -0,0 +1,212 @@
from __future__ import annotations
import math
from app.core.colors import (
brighten,
choose_pair,
custom_random_color_choices,
label_contrast,
sample_random_effect_color,
)
from app.core.types import RGBColor, TilePatternSample, clamp
from ..base import PatternContext
def _mirror_position(position: float, enabled: bool) -> float:
return min(position, 1.0 - position) * 2.0 if enabled else position
def _directional_amount(context: PatternContext, row_index: int, col_index: int) -> float:
rows = max(1, context.rows - 1)
cols = max(1, context.cols - 1)
row_position = row_index / rows
col_position = col_index / cols
direction = context.params.direction
if direction == "right_to_left":
return 1.0 - col_position
if direction == "top_to_bottom":
return row_position
if direction == "bottom_to_top":
return 1.0 - row_position
if direction == "outward":
return abs(col_position - 0.5) * 2.0
if direction == "inward":
return 1.0 - abs(col_position - 0.5) * 2.0
return col_position
def _random_color_pair(context: PatternContext, seed: float) -> tuple[RGBColor, RGBColor]:
primary = _random_vivid_color(seed)
secondary = primary.scaled(0.08)
return primary, secondary
def _custom_random_color_pair(context: PatternContext, seed: float) -> tuple[RGBColor, RGBColor]:
choices = custom_random_color_choices(context.params.primary_color, context.params.secondary_color)
primary = choices[int(_temporal_noise(seed) * len(choices)) % len(choices)]
secondary = primary.scaled(0.08)
return primary, secondary
def _spatial_color_seed(amount: float, row_index: int, col_index: int) -> float:
return (
amount * 7.31
+ (row_index + 1) * 0.613
+ (col_index + 1) * 1.137
+ (row_index + 1) * (col_index + 1) * 0.071
)
def _sample_for_tile(
context: PatternContext,
amount: float,
row_index: int | None = None,
col_index: int | None = None,
) -> tuple[RGBColor, RGBColor]:
if context.params.color_mode == "random_colors":
return _random_color_pair(context, _spatial_color_seed(amount, row_index or 0, col_index or 0))
if context.params.color_mode == "custom_random":
return _custom_random_color_pair(context, _spatial_color_seed(amount, row_index or 0, col_index or 0))
return choose_pair(
context.params.color_mode,
context.params.primary_color,
context.params.secondary_color,
context.params.palette,
amount,
)
def _sample_for_cycle(context: PatternContext, amount: float, seed: float) -> tuple[RGBColor, RGBColor]:
if context.params.color_mode == "random_colors":
return _random_color_pair(context, seed + amount * 3.1)
if context.params.color_mode == "custom_random":
return _custom_random_color_pair(context, seed + amount * 3.1)
return _sample_for_tile(context, amount)
def _blend_colors(primary: RGBColor, secondary: RGBColor, amount: float, floor: float = 0.0) -> RGBColor:
floor = clamp(floor)
return secondary.mix(primary, floor + (1.0 - floor) * clamp(amount))
def _tile_sample(fill: RGBColor, accent: RGBColor, intensity: float = 1.0, boost: float = 0.1) -> TilePatternSample:
intensity = clamp(intensity)
if intensity <= 0.0 and fill.to_8bit_tuple() == (0, 0, 0):
glow = RGBColor.black()
rim = RGBColor.black()
else:
glow = brighten(fill, boost)
rim = fill.mix(accent, 0.24)
return TilePatternSample(
fill_color=fill,
glow_color=glow,
rim_color=rim,
label_color=label_contrast(fill),
intensity=intensity,
)
def _diagonal_split_sample(
color_a: RGBColor,
color_b: RGBColor,
accent: RGBColor,
orientation: str,
intensity: float = 1.0,
boost: float = 0.1,
) -> TilePatternSample:
sample = _tile_sample(color_a.mix(color_b, 0.5), accent, intensity=intensity, boost=boost)
sample.metadata["diagonal_split"] = {
"orientation": orientation,
"color_a": color_a,
"color_b": color_b,
}
return sample
def _with_led_pixels(sample: TilePatternSample, led_pixels: dict[str, list[RGBColor]]) -> TilePatternSample:
sample.metadata["led_pixels"] = led_pixels
return sample
def _noise(value: float) -> float:
return value - math.floor(value)
def _temporal_noise(seed: float) -> float:
return _noise(math.sin(seed * 12.9898) * 43758.5453)
def _random_vivid_color(seed: float) -> RGBColor:
return sample_random_effect_color(_temporal_noise(seed))
def _axis_data(context: PatternContext, row_index: int, col_index: int) -> tuple[float, int, bool]:
vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"}
position = float(row_index if vertical else col_index)
count = context.rows if vertical else context.cols
return position, max(1, count), vertical
_SCAN_VECTORS: dict[int, tuple[int, int]] = {
0: (1, 0),
45: (1, 1),
90: (0, 1),
135: (-1, 1),
180: (-1, 0),
225: (-1, -1),
270: (0, -1),
315: (1, -1),
}
def _scan_vector(angle: float) -> tuple[int, int]:
return _SCAN_VECTORS[int(angle) % 360]
def _scan_point(context: PatternContext, row_index: int, col_index: int, local_x: float, local_y: float) -> tuple[float, float]:
return col_index + local_x, row_index + local_y
def _scan_projection(
context: PatternContext,
row_index: int,
col_index: int,
local_x: float,
local_y: float,
vector: tuple[int, int],
) -> float:
x_pos, y_pos = _scan_point(context, row_index, col_index, local_x, local_y)
return x_pos * vector[0] + y_pos * vector[1]
def _scan_bounds(context: PatternContext, vector: tuple[int, int]) -> tuple[float, float]:
corners = (
(0.0, 0.0),
(float(context.cols), 0.0),
(0.0, float(context.rows)),
(float(context.cols), float(context.rows)),
)
projections = [x_pos * vector[0] + y_pos * vector[1] for x_pos, y_pos in corners]
return min(projections), max(projections)
def _scan_band_amount(
progress: float,
phase: float,
min_progress: float,
max_progress: float,
on_width: float,
off_width: float,
scan_style: str,
) -> float:
if scan_style == "bands":
period = max(0.1, on_width + off_width)
local = (progress - min_progress - phase) % period
return 1.0 if local < on_width else 0.0
travel = max(0.1, (max_progress - min_progress) + on_width + max(0.0, off_width))
band_center = min_progress + (phase % travel)
return 1.0 if abs(progress - band_center) <= on_width * 0.5 else 0.0

View File

@@ -0,0 +1,264 @@
from __future__ import annotations
import math
from app.core.colors import ease_in_out_sine, oscillate, sample_palette, smoothstep
from app.core.types import RGBColor, TilePatternSample, clamp
from ..base import BasePattern, PatternContext, PatternDescriptor
from .common import (
_diagonal_split_sample,
_directional_amount,
_mirror_position,
_sample_for_cycle,
_sample_for_tile,
_temporal_noise,
_tile_sample,
_with_led_pixels,
)
_SEGMENT_NEIGHBOR_OFFSETS: dict[str, tuple[int, int]] = {
"left": (0, -1),
"right": (0, 1),
"top": (-1, 0),
"bottom": (1, 0),
}
def _front_position(phase: float, max_distance: int, reverse: bool) -> float:
if max_distance <= 0:
return 0.0
front = (phase * 1.35) % (max_distance + 1.0)
return max_distance - front if reverse else front
def _center_distances(context: PatternContext) -> tuple[dict[tuple[int, int], int], int]:
center_rows = [context.rows // 2] if context.rows % 2 == 1 else [max(0, context.rows // 2 - 1), context.rows // 2]
center_cols = [context.cols // 2] if context.cols % 2 == 1 else [max(0, context.cols // 2 - 1), context.cols // 2]
centers = {(row, col) for row in center_rows for col in center_cols}
distances = {
(tile.row - 1, tile.col - 1): min(abs((tile.row - 1) - center_row) + abs((tile.col - 1) - center_col) for center_row, center_col in centers)
for tile in context.sorted_tiles()
}
return distances, max(distances.values(), default=0)
def _outline_depths(context: PatternContext) -> tuple[dict[tuple[int, int], int], int]:
depths = {
(tile.row - 1, tile.col - 1): min(tile.row - 1, tile.col - 1, context.rows - tile.row, context.cols - tile.col)
for tile in context.sorted_tiles()
}
return depths, max(depths.values(), default=0)
def _outline_led_pixels(context: PatternContext, tile, depths: dict[tuple[int, int], int], depth: int, color: RGBColor) -> dict[str, list[RGBColor]]:
pixels: dict[str, list[RGBColor]] = {}
row_index = tile.row - 1
col_index = tile.col - 1
black = RGBColor.black()
for segment in tile.segments:
delta = _SEGMENT_NEIGHBOR_OFFSETS.get(segment.side)
active = False
if delta is not None:
neighbor = (row_index + delta[0], col_index + delta[1])
active = depths.get(neighbor, -1) < depth
pixels[segment.name] = [color if active else black for _ in range(segment.led_count)]
return pixels
class SolidPattern(BasePattern):
descriptor = PatternDescriptor(
"solid",
"Solid",
"Uniform wash across the whole wall.",
("brightness", "color_mode", "primary_color", "secondary_color", "palette"),
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
primary, _secondary = _sample_for_tile(context, 0.15)
return {tile.tile_id: _tile_sample(primary, primary) for tile in context.sorted_tiles()}
class CheckerPattern(BasePattern):
descriptor = PatternDescriptor(
"checker",
"Checkerd",
"Alternating dual-color checkerboard.",
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "checker_mode"),
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
phase = int(math.floor(context.pattern_tempo_phase))
checker_mode = context.params.checker_mode
result: dict[str, TilePatternSample] = {}
for tile in context.sorted_tiles():
row_index = tile.row - 1
col_index = tile.col - 1
amount = (tile.col - 1) / max(1, context.cols - 1)
primary, secondary = _sample_for_tile(context, amount, row_index, col_index)
parity = (row_index + col_index) % 2
if checker_mode == "diagonal":
primary_first = (parity + phase) % 2 == 0
orientation = "backslash"
color_a = primary if primary_first else secondary
color_b = secondary if primary_first else primary
result[tile.tile_id] = _diagonal_split_sample(color_a, color_b, primary, orientation, boost=0.06)
continue
if checker_mode == "checkerd":
orientation = "backslash" if phase % 2 == 0 else "slash"
color_a = primary if parity == 0 else secondary
color_b = secondary if parity == 0 else primary
result[tile.tile_id] = _diagonal_split_sample(color_a, color_b, primary, orientation, boost=0.06)
continue
fill = primary if (parity + phase) % 2 == 0 else secondary
accent = secondary if fill == primary else primary
result[tile.tile_id] = _tile_sample(fill, accent)
return result
class RowGradientPattern(BasePattern):
descriptor = PatternDescriptor(
"row_gradient",
"Row Gradient",
"Vertical blend across the rows.",
("brightness", "fade", "direction", "color_mode", "primary_color", "secondary_color", "palette", "symmetry"),
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
for tile in context.sorted_tiles():
row_amount = (tile.row - 1) / max(1, context.rows - 1)
if context.params.direction == "bottom_to_top":
row_amount = 1.0 - row_amount
row_amount = _mirror_position(row_amount, context.params.symmetry in {"vertical", "both"})
primary, secondary = _sample_for_tile(context, row_amount, tile.row - 1, tile.col - 1)
fill = secondary.mix(primary, row_amount)
result[tile.tile_id] = _tile_sample(fill, primary)
return result
class ColumnGradientPattern(BasePattern):
descriptor = PatternDescriptor(
"column_gradient",
"Column Gradient",
"Horizontal blend across the columns.",
("brightness", "fade", "direction", "color_mode", "primary_color", "secondary_color", "palette", "symmetry"),
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
for tile in context.sorted_tiles():
amount = _directional_amount(context, tile.row - 1, tile.col - 1)
amount = _mirror_position(amount, context.params.symmetry in {"horizontal", "both"})
primary, secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1)
fill = secondary.mix(primary, amount)
result[tile.tile_id] = _tile_sample(fill, primary)
return result
class CenterPulsePattern(BasePattern):
descriptor = PatternDescriptor(
"center_pulse",
"Center Pulse",
"Radial waves expanding from the center.",
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "center_pulse_mode"),
temporal_profile="direct",
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
mode = context.params.center_pulse_mode
if mode in {"outline", "outline_reverse"}:
return self._render_outline(context, reverse=mode == "outline_reverse")
return self._render_fill(context, reverse=mode == "reverse")
def _render_fill(self, context: PatternContext, *, reverse: bool) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
distances, max_distance = _center_distances(context)
front = _front_position(context.pattern_tempo_phase, max_distance, reverse)
for tile in context.sorted_tiles():
tile_distance = float(distances[(tile.row - 1, tile.col - 1)])
amount = 1.0 - smoothstep(0.0, 0.7, abs(tile_distance - front))
primary, secondary = _sample_for_tile(context, tile_distance / max(1, max_distance), tile.row - 1, tile.col - 1)
if context.params.color_mode == "mono":
fill = primary.scaled(amount)
else:
fill = secondary.mix(primary, amount).scaled(amount)
result[tile.tile_id] = _tile_sample(fill, primary, intensity=amount, boost=0.1)
return result
def _render_outline(self, context: PatternContext, *, reverse: bool) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
depths, max_depth = _outline_depths(context)
front = _front_position(context.pattern_tempo_phase, max_depth, reverse)
for tile in context.sorted_tiles():
row_index = tile.row - 1
col_index = tile.col - 1
depth = depths[(row_index, col_index)]
ring_index = max_depth - depth
amount = 1.0 - smoothstep(0.0, 0.7, abs(ring_index - front))
primary, _secondary = _sample_for_tile(context, ring_index / max(1, max_depth), row_index, col_index)
led_color = primary.scaled(amount)
led_pixels = _outline_led_pixels(context, tile, depths, depth, led_color)
lit_leds = sum(
1
for segment_pixels in led_pixels.values()
for color in segment_pixels
if color.to_8bit_tuple() != (0, 0, 0)
)
total_leds = max(1, sum(len(segment_pixels) for segment_pixels in led_pixels.values()))
preview_level = amount * (lit_leds / total_leds)
fill = primary.scaled(preview_level * 0.4) if lit_leds else RGBColor.black()
sample = _tile_sample(fill, primary, intensity=amount if lit_leds else 0.0, boost=0.08)
result[tile.tile_id] = _with_led_pixels(sample, led_pixels)
return result
class SparklePattern(BasePattern):
descriptor = PatternDescriptor(
"sparkle",
"Sparkle",
"Ambient base layer with random sparkling accents.",
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "strobe_duty_cycle"),
temporal_profile="direct",
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
density = clamp(context.params.strobe_duty_cycle, 0.02, 0.98)
time_bucket = context.pattern_tempo_phase * 7.0
bucket_index = math.floor(time_bucket)
for tile in context.sorted_tiles():
base_amount = (tile.col - 1) / max(1, context.cols - 1)
primary, _secondary = _sample_for_cycle(context, base_amount, bucket_index * 29.7 + 4.2)
sparkle = _temporal_noise((tile.row * 17.13) + (tile.col * 11.7) + math.floor(time_bucket))
burst = smoothstep(1.0 - density, 1.0, sparkle)
visible_burst = burst if burst >= 0.05 else 0.0
fill = primary.scaled(visible_burst)
result[tile.tile_id] = _tile_sample(fill, primary, intensity=visible_burst, boost=0.08 + visible_burst * 0.2)
return result
class BreathingPattern(BasePattern):
descriptor = PatternDescriptor(
"breathing",
"Breathing",
"Slow collective inhale and exhale.",
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette"),
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
breathe = ease_in_out_sine(oscillate(context.pattern_tempo_phase, 0.25))
primary, secondary = _sample_for_tile(context, breathe)
base_fill = secondary.mix(primary, breathe)
for tile in context.sorted_tiles():
fill = base_fill
if context.params.color_mode == "palette":
palette_color = sample_palette(context.params.palette, (tile.col - 1) / max(1, context.cols - 1))
fill = fill.mix(palette_color, 0.18)
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.24 + breathe * 0.76, boost=0.18)
return result

View File

@@ -0,0 +1,365 @@
from __future__ import annotations
import math
from app.core.colors import oscillate, smoothstep
from app.core.types import RGBColor, TilePatternSample, clamp
from ..base import BasePattern, PatternContext, PatternDescriptor
from .common import (
_blend_colors,
_diagonal_split_sample,
_directional_amount,
_mirror_position,
_sample_for_tile,
_scan_band_amount,
_scan_projection,
_scan_vector,
_tile_sample,
)
class WaveLinePattern(BasePattern):
descriptor = PatternDescriptor(
"wave_line",
"Wave Line",
"A discrete wave line that can travel left, right, up, or down.",
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette"),
temporal_profile="direct",
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
rows = max(1, context.rows)
cols = max(1, context.cols)
triangle_wave = [0, 1, 2, 1]
step = int(math.floor(context.pattern_tempo_phase))
row_scale = max(0, rows - 1) / 2.0
if context.params.direction in {"left_to_right", "right_to_left"}:
phase = step if context.params.direction == "left_to_right" else -step
active_coords = {
(int(round(triangle_wave[(col - phase) % len(triangle_wave)] * row_scale)), col)
for col in range(cols)
}
else:
col_scale = max(0, cols - 1) / 2.0
phase = step if context.params.direction == "top_to_bottom" else -step
active_coords = {
(row, int(round(triangle_wave[(row - phase) % len(triangle_wave)] * col_scale)))
for row in range(rows)
}
for tile in context.sorted_tiles():
row_index = tile.row - 1
col_index = tile.col - 1
amount = col_index / max(1, cols - 1)
primary, secondary = _sample_for_tile(context, amount, row_index, col_index)
active = (row_index, col_index) in active_coords
fill = primary if active else RGBColor.black()
result[tile.tile_id] = _tile_sample(fill, primary if active else RGBColor.black(), intensity=1.0 if active else 0.0, boost=0.08 if active else 0.0)
return result
class ScanPattern(BasePattern):
descriptor = PatternDescriptor(
"scan",
"Scan",
"Unified scan renderer for row, column, and diagonal motion.",
(
"brightness",
"fade",
"tempo_multiplier",
"scan_style",
"angle",
"on_width",
"off_width",
"color_mode",
"primary_color",
"secondary_color",
"palette",
),
temporal_profile="direct",
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
tiles = context.sorted_tiles()
angle = int(context.params.angle) % 360
vector = _scan_vector(angle)
diagonal = abs(vector[0]) == 1 and abs(vector[1]) == 1
orientation = "backslash" if vector[0] * vector[1] < 0 else "slash"
if diagonal and orientation == "backslash":
split_points = {"a": (2.0 / 3.0, 1.0 / 3.0), "b": (1.0 / 3.0, 2.0 / 3.0)}
else:
split_points = {"a": (1.0 / 3.0, 1.0 / 3.0), "b": (2.0 / 3.0, 2.0 / 3.0)}
lane_points = (split_points["a"], split_points["b"]) if diagonal else ((0.5, 0.5),)
lane_progress_values = [
_scan_projection(context, tile.row - 1, tile.col - 1, local_x, local_y, vector)
for tile in tiles
for local_x, local_y in lane_points
]
min_progress = min(lane_progress_values, default=0.0)
max_progress = max(lane_progress_values, default=0.0)
phase_scale = max(0.25, min(context.params.on_width, 1.0))
phase = context.pattern_tempo_phase * phase_scale
for tile in tiles:
row_index = tile.row - 1
col_index = tile.col - 1
center_progress = _scan_projection(context, row_index, col_index, 0.5, 0.5, vector)
amount = 0.0 if max_progress == min_progress else (center_progress - min_progress) / (max_progress - min_progress)
primary, secondary = _sample_for_tile(context, amount, row_index, col_index)
off_color = RGBColor.black()
if diagonal:
amount_a = _scan_band_amount(
_scan_projection(context, row_index, col_index, split_points["a"][0], split_points["a"][1], vector),
phase,
min_progress,
max_progress,
context.params.on_width,
context.params.off_width,
context.params.scan_style,
)
amount_b = _scan_band_amount(
_scan_projection(context, row_index, col_index, split_points["b"][0], split_points["b"][1], vector),
phase,
min_progress,
max_progress,
context.params.on_width,
context.params.off_width,
context.params.scan_style,
)
color_a = off_color.mix(primary, amount_a)
color_b = off_color.mix(primary, amount_b)
result[tile.tile_id] = _diagonal_split_sample(
color_a,
color_b,
primary,
orientation,
intensity=(amount_a + amount_b) * 0.5,
boost=0.08,
)
continue
coverage = _scan_band_amount(
center_progress,
phase,
min_progress,
max_progress,
context.params.on_width,
context.params.off_width,
context.params.scan_style,
)
fill = off_color.mix(primary, coverage)
result[tile.tile_id] = _tile_sample(fill, primary, intensity=coverage, boost=0.08)
return result
class ArrowPattern(BasePattern):
descriptor = PatternDescriptor(
"arrow",
"Arrow",
"Discrete chevrons like > > on the low-res wall.",
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "block_size"),
temporal_profile="direct",
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
horizontal = context.params.direction not in {"top_to_bottom", "bottom_to_top"}
major_count = max(1, context.cols if horizontal else context.rows)
minor_count = max(1, context.rows if horizontal else context.cols)
middle_minor = (minor_count - 1) / 2.0
gap = max(0, int(round(context.params.block_size - 1.0)))
span = 3 + gap
movement = int(math.floor(context.pattern_tempo_phase))
def row_band(minor_index: int) -> int:
if minor_count <= 1:
return 0
return 0 if abs(minor_index - middle_minor) <= 0.55 else 1
def chevron_target(orientation: str, minor_index: int) -> int:
band = row_band(minor_index)
if orientation in {"right", "down"}:
return 1 if band == 0 else 0
return 1 if band == 0 else 2
def cell_active(local_index: int, minor_index: int, orientation: str) -> bool:
if local_index >= 3:
return False
return local_index == chevron_target(orientation, minor_index)
half_size = max(1, math.ceil(major_count / 2))
for tile in context.sorted_tiles():
major_index = int(tile.col - 1 if horizontal else tile.row - 1)
minor_index = int(tile.row - 1 if horizontal else tile.col - 1)
sample_amount = major_index / max(1, major_count - 1)
primary, _secondary = _sample_for_tile(context, sample_amount, tile.row - 1, tile.col - 1)
if context.params.direction in {"outward", "inward"}:
left_half = major_index < half_size
local_major = major_index if left_half else major_index - half_size
if context.params.direction == "outward":
orientation = "left" if left_half else "right"
local_index = (local_major + movement) % span if left_half else (local_major - movement) % span
else:
orientation = "right" if left_half else "left"
local_index = (local_major - movement) % span if left_half else (local_major + movement) % span
else:
orientation = "right" if context.params.direction in {"left_to_right", "top_to_bottom"} else "left"
local_index = (major_index - movement) % span if orientation == "right" else (major_index + movement) % span
active = cell_active(local_index, minor_index, orientation)
fill = primary if active else RGBColor.black()
result[tile.tile_id] = _tile_sample(fill, primary if active else RGBColor.black(), intensity=1.0 if active else 0.0, boost=0.06 if active else 0.0)
return result
class ScanDualPattern(BasePattern):
descriptor = PatternDescriptor(
"scan_dual",
"Scan Dual",
"Mirrored scanner bands inspired by WLED Scan Dual.",
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "block_size"),
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"}
axis_count = max(1, context.rows if vertical else context.cols)
scan = oscillate(context.pattern_tempo_phase, 0.6) * (axis_count - 1)
if context.params.direction in {"right_to_left", "bottom_to_top"}:
scan = (axis_count - 1) - scan
mirror = (axis_count - 1) - scan
width = max(0.3, context.params.block_size * 0.28)
for tile in context.sorted_tiles():
pos = float(tile.row - 1 if vertical else tile.col - 1)
lead = 1.0 - smoothstep(width, width + 1.0, abs(pos - scan))
echo = (1.0 - smoothstep(width, width + 1.0, abs(pos - mirror))) * 0.62
sample_amount = pos / max(1, axis_count - 1)
primary, secondary = _sample_for_tile(context, sample_amount, tile.row - 1, tile.col - 1)
lead_fill = _blend_colors(primary, secondary, lead, floor=0.08)
echo_fill = _blend_colors(primary, secondary, echo, floor=0.02)
if lead >= echo:
fill = lead_fill
amount = lead
else:
fill = echo_fill
amount = echo
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.12 + amount * 0.88)
return result
class SweepPattern(BasePattern):
descriptor = PatternDescriptor(
"sweep",
"Sweep",
"Primary and secondary colors wipe through the wall like WLED Sweep.",
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette"),
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"}
axis_count = max(1, context.rows if vertical else context.cols)
softness = 0.26
if context.params.direction in {"outward", "inward"}:
center = (axis_count - 1) / 2.0
front = oscillate(context.pattern_tempo_phase, 0.45) * center
if context.params.direction == "inward":
front = center - front
for tile in context.sorted_tiles():
pos = float(tile.col - 1 if not vertical else tile.row - 1)
distance = abs(pos - center)
amount = 1.0 - smoothstep(front, front + softness + 0.6, distance)
primary, secondary = _sample_for_tile(context, pos / max(1, axis_count - 1), tile.row - 1, tile.col - 1)
fill = secondary.mix(primary, clamp(amount))
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.2 + clamp(amount) * 0.8)
return result
front = oscillate(context.pattern_tempo_phase, 0.45) * (axis_count - 1)
if context.params.direction in {"right_to_left", "bottom_to_top"}:
front = (axis_count - 1) - front
for tile in context.sorted_tiles():
pos = float(tile.row - 1 if vertical else tile.col - 1)
if context.params.direction in {"right_to_left", "bottom_to_top"}:
amount = smoothstep(front - softness, front + softness, pos)
else:
amount = 1.0 - smoothstep(front - softness, front + softness, pos)
primary, secondary = _sample_for_tile(context, pos / max(1, axis_count - 1), tile.row - 1, tile.col - 1)
fill = secondary.mix(primary, clamp(amount))
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.18 + clamp(amount) * 0.82)
return result
class SawPattern(BasePattern):
descriptor = PatternDescriptor(
"saw",
"Saw",
"A stepped saw-wave sweep inspired by WLED's sharper motion effects.",
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "symmetry"),
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
phase = context.pattern_tempo_phase * 0.7
quantization = max(context.cols, context.rows)
for tile in context.sorted_tiles():
amount = _directional_amount(context, tile.row - 1, tile.col - 1)
if context.params.direction in {"left_to_right", "right_to_left"}:
amount = _mirror_position(amount, context.params.symmetry in {"horizontal", "both"})
if context.params.direction in {"top_to_bottom", "bottom_to_top"}:
amount = _mirror_position(amount, context.params.symmetry in {"vertical", "both"})
wave = (amount - phase) % 1.0
saw = wave / 0.92 if wave < 0.92 else 0.0
saw = round(saw * quantization) / max(1, quantization)
primary, secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1)
if context.params.color_mode == "mono":
fill = primary.scaled(saw)
intensity = saw
else:
fill = _blend_colors(primary, secondary, saw, floor=0.04)
intensity = 0.16 + saw * 0.84
result[tile.tile_id] = _tile_sample(fill, primary, intensity=intensity)
return result
class TwoDotsPattern(BasePattern):
descriptor = PatternDescriptor(
"two_dots",
"Two Dots",
"Two mirrored highlights travel across the wall, inspired by WLED Two Dots.",
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "block_size"),
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"}
axis_count = max(1, context.rows if vertical else context.cols)
orbit = oscillate(context.pattern_tempo_phase, 0.75) * (axis_count - 1)
if context.params.direction in {"right_to_left", "bottom_to_top"}:
orbit = (axis_count - 1) - orbit
dot_a = orbit
dot_b = (axis_count - 1) - orbit
width = max(0.25, context.params.block_size * 0.22)
for tile in context.sorted_tiles():
pos = float(tile.row - 1 if vertical else tile.col - 1)
pulse_a = 1.0 - smoothstep(width, width + 0.95, abs(pos - dot_a))
pulse_b = 1.0 - smoothstep(width, width + 0.95, abs(pos - dot_b))
amount = max(pulse_a, pulse_b)
primary, secondary = _sample_for_tile(context, pos / max(1, axis_count - 1), tile.row - 1, tile.col - 1)
fill = _blend_colors(primary, secondary, amount, floor=0.05)
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.18 + amount * 0.82)
return result

View File

@@ -0,0 +1,328 @@
from __future__ import annotations
import math
import random
from app.core.colors import brighten, sample_palette, smoothstep
from app.core.types import RGBColor, TilePatternSample, clamp
from ..base import BasePattern, PatternContext, PatternDescriptor
from .common import (
_random_vivid_color,
_sample_for_cycle,
_sample_for_tile,
_temporal_noise,
_tile_sample,
_with_led_pixels,
)
class StrobePattern(BasePattern):
descriptor = PatternDescriptor(
"strobe",
"Strobe",
"Fast on/off pulses with duty-cycle control.",
(
"brightness",
"fade",
"tempo_multiplier",
"strobe_mode",
"color_mode",
"primary_color",
"secondary_color",
"palette",
"pixel_group_size",
"strobe_duty_cycle",
),
temporal_profile="direct",
)
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
if context.params.strobe_mode == "random_pixels":
return self._render_random_pixels(context, grouped=True)
if context.params.strobe_mode == "random_leds":
return self._render_random_pixels(context, grouped=False)
phase = context.pattern_tempo_phase * 4.0
bucket = math.floor(phase)
on = phase % 1.0 < context.params.strobe_duty_cycle
primary, _secondary = _sample_for_cycle(context, context.time_s * 0.1, bucket * 17.1 + 3.0)
if on:
return {tile.tile_id: _tile_sample(primary, primary, intensity=1.0) for tile in context.sorted_tiles()}
black = RGBColor.black()
return {tile.tile_id: _tile_sample(black, black, intensity=0.0, boost=0.0) for tile in context.sorted_tiles()}
def _render_random_pixels(self, context: PatternContext, *, grouped: bool) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
bucket = math.floor(context.pattern_tempo_phase * 10.0)
density = clamp(context.params.strobe_duty_cycle, 0.02, 0.98)
pixel_group_size = max(1, min(5, int(round(context.params.pixel_group_size)))) if grouped else 1
for tile in context.sorted_tiles():
amount = (tile.col - 1) / max(1, context.cols - 1)
primary, _secondary = _sample_for_cycle(context, amount, bucket * 29.7 + tile.row * 11.7 + tile.col * 17.9)
led_pixels: dict[str, list[RGBColor]] = {}
lit_leds = 0
total_leds = 0
for segment in tile.segments:
segment_pixels: list[RGBColor] = []
count = max(1, segment.led_count)
for group_start in range(0, count, pixel_group_size):
group_index = group_start // pixel_group_size
group_end = min(count, group_start + pixel_group_size)
seed = bucket * 19.7 + tile.row * 31.3 + tile.col * 17.9 + segment.start_channel * 0.11 + group_index * 1.73
active = _temporal_noise(seed) < density
if context.params.color_mode == "palette":
color = sample_palette(context.params.palette, (amount + group_start / max(1, count - 1) * 0.2) % 1.0)
else:
color = primary
for _index in range(group_start, group_end):
segment_pixels.append(color if active else RGBColor.black())
lit_leds += 1 if active else 0
total_leds += 1
led_pixels[segment.name] = segment_pixels
activity = lit_leds / max(1, total_leds)
preview_level = clamp(activity * 8.0, 0.0, 1.0)
fill = primary.scaled(preview_level) if lit_leds else RGBColor.black()
sample = _tile_sample(fill, primary, intensity=preview_level, boost=0.08)
result[tile.tile_id] = _with_led_pixels(sample, led_pixels)
return result
class StopwatchPattern(BasePattern):
descriptor = PatternDescriptor(
"stopwatch",
"Stopwatch",
"LEDs fill from 1 to N and then clear from N back to 1 on every tile.",
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "stopwatch_mode"),
)
def __init__(self) -> None:
self._last_base_phase_position: float | None = None
def _tile_led_count(self, tile) -> int:
return max(1, sum(segment.led_count for segment in tile.segments))
def _tile_cycle_color(self, context: PatternContext, tile, cycle_index: int) -> RGBColor:
amount = (tile.col - 1 + tile.row - 1) / max(1, context.rows + context.cols - 2)
if context.params.color_mode == "random_colors":
return _random_vivid_color(cycle_index * 53.1 + tile.row * 11.7 + tile.col * 17.9)
primary, _secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1)
return primary
def _crossed_full_count_peak(self, previous_position: float, current_position: float, cycle_length: int, led_count: int) -> bool:
if current_position <= previous_position or cycle_length <= 0:
return False
next_peak = math.floor(previous_position / cycle_length) * cycle_length + led_count
if next_peak < previous_position:
next_peak += cycle_length
return next_peak <= current_position
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
result: dict[str, TilePatternSample] = {}
base_phase_position = context.pattern_tempo_phase * 20.0
previous_base_phase_position = self._last_base_phase_position
use_phase_bridge = (
previous_base_phase_position is not None
and previous_base_phase_position <= base_phase_position
and (base_phase_position - previous_base_phase_position) <= 512.0
)
self._last_base_phase_position = base_phase_position
for tile in context.sorted_tiles():
led_count = self._tile_led_count(tile)
cycle_length = max(1, led_count * 2)
offset = 0
if context.params.stopwatch_mode == "random":
offset = int(_temporal_noise(tile.row * 13.7 + tile.col * 23.9) * cycle_length)
tile_phase_position = base_phase_position + offset
cycle_index = int(tile_phase_position // cycle_length)
phase = tile_phase_position % cycle_length
active_count = phase + 1 if phase < led_count else (2 * led_count) - phase
active_count = max(1, min(led_count, int(round(active_count))))
if use_phase_bridge and previous_base_phase_position is not None:
previous_tile_phase = previous_base_phase_position + offset
if self._crossed_full_count_peak(previous_tile_phase, tile_phase_position, cycle_length, led_count):
active_count = led_count
color = self._tile_cycle_color(context, tile, cycle_index)
remaining = active_count
led_pixels: dict[str, list[RGBColor]] = {}
for segment in tile.segments:
segment_pixels: list[RGBColor] = []
for _index in range(segment.led_count):
lit = remaining > 0
segment_pixels.append(color if lit else RGBColor.black())
if lit:
remaining -= 1
led_pixels[segment.name] = segment_pixels
activity = active_count / max(1, led_count)
fill = color.scaled(activity)
sample = _tile_sample(fill, color, intensity=activity, boost=0.08)
result[tile.tile_id] = _with_led_pixels(sample, led_pixels)
return result
class SnakePattern(BasePattern):
descriptor = PatternDescriptor(
"snake",
"Snake",
"A random self-playing snake roaming across the wall.",
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "randomness"),
)
def __init__(self) -> None:
self._rng = random.Random(1337)
self._shape: tuple[int, int] | None = None
self._snake: list[tuple[int, int]] = []
self._direction: tuple[int, int] = (0, 1)
self._apple: tuple[int, int] | None = None
self._blink_until_time: float | None = None
self._target_length = 4
self._last_time_s: float | None = None
self._step_progress = 0.0
def _reset(self, rows: int, cols: int) -> None:
start_row = rows // 2
length = max(2, min(self._target_length, cols))
start_col = min(cols - 1, max(length - 1, cols // 2))
self._snake = [(start_row, start_col - index) for index in range(length)]
self._direction = (0, 1)
self._apple = None
self._spawn_apple(rows, cols)
self._blink_until_time = None
self._shape = (rows, cols)
self._last_time_s = None
self._step_progress = 0.0
def _spawn_apple(self, rows: int, cols: int) -> None:
occupied = set(self._snake)
candidates = [(row, col) for row in range(rows) for col in range(cols) if (row, col) not in occupied]
self._apple = self._rng.choice(candidates) if candidates else None
def _neighbors(self, head: tuple[int, int], rows: int, cols: int) -> list[tuple[int, int]]:
row, col = head
neighbors = []
for d_row, d_col in ((0, 1), (1, 0), (0, -1), (-1, 0)):
next_row = row + d_row
next_col = col + d_col
if 0 <= next_row < rows and 0 <= next_col < cols:
neighbors.append((next_row, next_col))
return neighbors
def _manhattan(self, cell_a: tuple[int, int], cell_b: tuple[int, int]) -> int:
return abs(cell_a[0] - cell_b[0]) + abs(cell_a[1] - cell_b[1])
def _turn_left(self, direction: tuple[int, int]) -> tuple[int, int]:
return (-direction[1], direction[0])
def _turn_right(self, direction: tuple[int, int]) -> tuple[int, int]:
return (direction[1], -direction[0])
def _advance(self, rows: int, cols: int, randomness: float, current_time: float) -> None:
if not self._snake:
self._reset(rows, cols)
head = self._snake[0]
occupied = set(self._snake[:-1])
candidate_directions = [
self._direction,
self._turn_left(self._direction),
self._turn_right(self._direction),
]
candidates: list[tuple[tuple[int, int], tuple[int, int]]] = []
for next_direction in candidate_directions:
next_cell = (head[0] + next_direction[0], head[1] + next_direction[1])
if not (0 <= next_cell[0] < rows and 0 <= next_cell[1] < cols):
continue
if next_cell in occupied:
continue
candidates.append((next_cell, next_direction))
if not candidates:
reverse_direction = (-self._direction[0], -self._direction[1])
reverse_cell = (head[0] + reverse_direction[0], head[1] + reverse_direction[1])
if 0 <= reverse_cell[0] < rows and 0 <= reverse_cell[1] < cols and reverse_cell not in occupied:
candidates.append((reverse_cell, reverse_direction))
if not candidates:
self._reset(rows, cols)
return
def openness(cell: tuple[int, int]) -> int:
blocked = set(self._snake[:-2]) if len(self._snake) > 2 else set()
return sum(1 for neighbor in self._neighbors(cell, rows, cols) if neighbor not in blocked)
turniness = max(0.0, min(1.0, randomness / 1.5))
best_cell, best_direction = candidates[0]
best_score = -10_000.0
for cell, next_direction in candidates:
straight_bonus = 2.4 if next_direction == self._direction else 0.0
turn_penalty = -0.55 if next_direction != self._direction else 0.0
apple_bonus = 0.0
if self._apple is not None:
apple_bonus = max(0.0, (rows + cols) - self._manhattan(cell, self._apple)) * 0.7
if cell == self._apple:
apple_bonus += 5.0
score = openness(cell) + straight_bonus + turn_penalty + apple_bonus + self._rng.random() * (0.08 + turniness * 0.45)
if score > best_score:
best_score = score
best_cell = cell
best_direction = next_direction
self._direction = best_direction
self._snake.insert(0, best_cell)
ate_apple = best_cell == self._apple
if ate_apple:
self._blink_until_time = current_time + 0.12
self._spawn_apple(rows, cols)
while len(self._snake) > self._target_length:
self._snake.pop()
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
rows = max(1, context.rows)
cols = max(1, context.cols)
if self._shape != (rows, cols):
self._reset(rows, cols)
delta_s = 0.0
if self._last_time_s is not None:
raw_delta = context.time_s - self._last_time_s
if 0.0 < raw_delta <= 0.5:
delta_s = raw_delta
self._last_time_s = context.time_s
move_rate = context.pattern_tempo_hz * 2.2
self._step_progress += delta_s * move_rate
steps_to_run = min(3, int(self._step_progress))
if steps_to_run:
self._step_progress -= steps_to_run
for _ in range(steps_to_run):
self._advance(rows, cols, context.params.randomness, context.time_s)
if not self._snake:
self._reset(rows, cols)
self._last_time_s = context.time_s
body_lookup = {cell: index for index, cell in enumerate(self._snake)}
blinking = self._blink_until_time is not None and context.time_s <= self._blink_until_time
result: dict[str, TilePatternSample] = {}
for tile in context.sorted_tiles():
row_index = tile.row - 1
col_index = tile.col - 1
primary, secondary = _sample_for_tile(context, 0.0)
if (row_index, col_index) in body_lookup:
is_head = body_lookup[(row_index, col_index)] == 0
fill = brighten(primary, 0.22) if blinking and is_head else primary
result[tile.tile_id] = _tile_sample(fill, primary, intensity=1.0, boost=0.06 if blinking and is_head else 0.03)
elif self._apple == (row_index, col_index):
fill = secondary
accent = brighten(secondary, 0.15)
result[tile.tile_id] = _tile_sample(fill, accent, intensity=0.92, boost=0.03)
else:
fill = RGBColor.black()
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.0, boost=0.0)
return result

115
app/qt_compat.py Normal file
View File

@@ -0,0 +1,115 @@
from __future__ import annotations
QT_API = "unknown"
QT_IMPORT_ERROR: Exception | None = None
try:
from PySide6.QtCore import QObject, QPointF, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import (
QAction,
QColor,
QFont,
QKeySequence,
QLinearGradient,
QPainter,
QPainterPath,
QPalette,
QPen,
QRadialGradient,
)
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QColorDialog,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QScrollArea,
QSlider,
QSplitter,
QSpinBox,
QDoubleSpinBox,
QStatusBar,
QTabWidget,
QTableWidget,
QTableWidgetItem,
QToolBar,
QVBoxLayout,
QWidget,
QHeaderView,
)
QT_API = "PySide6"
except ImportError as exc:
QT_IMPORT_ERROR = exc
from PyQt5.QtCore import QObject, QPointF, QRectF, Qt, QTimer, pyqtSignal as Signal
from PyQt5.QtGui import (
QColor,
QFont,
QKeySequence,
QLinearGradient,
QPainter,
QPainterPath,
QPalette,
QPen,
QRadialGradient,
)
from PyQt5.QtWidgets import (
QApplication,
QAction,
QCheckBox,
QColorDialog,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QScrollArea,
QSlider,
QSplitter,
QSpinBox,
QDoubleSpinBox,
QStatusBar,
QTabWidget,
QTableWidget,
QTableWidgetItem,
QToolBar,
QVBoxLayout,
QWidget,
QHeaderView,
)
QT_API = "PyQt5"
def event_posf(event) -> QPointF:
if hasattr(event, "position"):
return event.position()
if hasattr(event, "localPos"):
return event.localPos()
return QPointF()

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

@@ -0,0 +1,2 @@
"""Qt UI modules for the Infinity Mirror control app."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

558
app/ui/main_window.py Normal file
View File

@@ -0,0 +1,558 @@
from __future__ import annotations
from pathlib import Path
from app.qt_compat import (
QAction,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QKeySequence,
QMainWindow,
QMessageBox,
QPushButton,
QSplitter,
QStatusBar,
Qt,
QTimer,
QToolBar,
QVBoxLayout,
QWidget,
)
from app.config.xml_mapping import MappingValidationError
from app.ui.pattern_panel import PatternPanel
from app.ui.preset_browser import PresetBrowser
from app.ui.preview_widget import (
PREVIEW_MODE_LEDS,
PREVIEW_MODE_TECHNICAL,
PREVIEW_MODE_TILE,
normalize_preview_mode,
)
from app.ui.scene_preview_area import ScenePreviewArea
from app.ui.section_panel import SectionPanel
from app.ui.settings_dialog import SettingsDialog
ACTIVE_UTILITY_STYLE = "QPushButton { background: #094771; color: #FFFFFF; border: 1px solid #007ACC; font-weight: 600; }"
ALERT_UTILITY_STYLE = "QPushButton { background: #C63B1E; color: #FFFFFF; border: 1px solid #F48771; font-weight: 600; }"
PATTERN_PANEL_MIN_WIDTH = 340
RIGHT_PANEL_MIN_WIDTH = 380
CENTER_PREVIEW_MIN_WIDTH = 720
class MainWindow(QMainWindow):
def __init__(self, controller, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.controller = controller
self.preview_mode = PREVIEW_MODE_TILE
self._startup_splitter_sized = False
self.setWindowTitle("Infinity Mirror Control")
self.resize(1840, 1040)
self.setMinimumSize(1480, 860)
self._blackout_blink_on = False
self._blackout_blink_timer = QTimer(self)
self._blackout_blink_timer.setInterval(420)
self._blackout_blink_timer.timeout.connect(self._toggle_blackout_blink)
self._diagnostics_timer = QTimer(self)
self._diagnostics_timer.setInterval(500)
self._diagnostics_timer.timeout.connect(self._refresh_diagnostics)
self._build_toolbar()
self._build_central_layout()
self._build_status_bar()
self.controller.status_message.connect(self.statusBar().showMessage)
self.controller.state_changed.connect(self._refresh_state)
self.controller.config_changed.connect(self._refresh_state)
self._diagnostics_timer.start()
self._refresh_state()
def _build_toolbar(self) -> None:
toolbar = QToolBar("Main")
toolbar.setMovable(False)
self.addToolBar(toolbar)
open_action = QAction("Open", self)
open_action.setShortcut(QKeySequence.Open)
open_action.triggered.connect(self.open_mapping)
toolbar.addAction(open_action)
save_action = QAction("Save", self)
save_action.setShortcut(QKeySequence.Save)
save_action.triggered.connect(self.save_mapping)
toolbar.addAction(save_action)
save_as_action = QAction("Save As", self)
save_as_action.setShortcut(QKeySequence("Ctrl+Shift+S"))
save_as_action.triggered.connect(self.save_mapping_as)
toolbar.addAction(save_as_action)
settings_action = QAction("Mapping Settings", self)
settings_action.setShortcut(QKeySequence("Ctrl+,"))
settings_action.triggered.connect(self.open_settings)
toolbar.addAction(settings_action)
toolbar.addWidget(QLabel("Tempo"))
self.tempo_spin = QDoubleSpinBox()
self.tempo_spin.setRange(10.0, 300.0)
self.tempo_spin.setDecimals(0)
self.tempo_spin.setSingleStep(1.0)
self.tempo_spin.setSuffix(" BPM")
self.tempo_spin.setFixedWidth(96)
self.tempo_spin.valueChanged.connect(self._change_tempo_bpm)
toolbar.addWidget(self.tempo_spin)
self.foh_toggle = QPushButton("FOH Mode")
self.foh_toggle.setCheckable(True)
self.foh_toggle.toggled.connect(self._toggle_foh_mode)
toolbar.addWidget(self.foh_toggle)
self.go_button = QPushButton("Go")
self.go_button.clicked.connect(lambda _checked=False: self._go_scene())
toolbar.addWidget(self.go_button)
self.fade_go_button = QPushButton("Fade Go")
self.fade_go_button.clicked.connect(lambda _checked=False: self._fade_go_scene())
toolbar.addWidget(self.fade_go_button)
toolbar.addWidget(QLabel("Fade"))
self.fade_time_spin = QDoubleSpinBox()
self.fade_time_spin.setRange(0.1, 30.0)
self.fade_time_spin.setDecimals(1)
self.fade_time_spin.setSingleStep(0.1)
self.fade_time_spin.setSuffix(" s")
self.fade_time_spin.setFixedWidth(84)
self.fade_time_spin.valueChanged.connect(self._change_transition_duration)
toolbar.addWidget(self.fade_time_spin)
self.foh_target_label = QLabel("Edit: Live")
self.foh_target_label.setStyleSheet("color: #CCCCCC; padding-left: 8px;")
toolbar.addWidget(self.foh_target_label)
self.fullscreen_action = QAction("Fullscreen Preview", self)
self.fullscreen_action.setShortcut(QKeySequence("F11"))
self.fullscreen_action.triggered.connect(self.toggle_fullscreen_preview)
self.addAction(self.fullscreen_action)
toolbar.addSeparator()
self.blackout_action = QAction("Blackout", self)
self.blackout_action.setShortcut(QKeySequence("Ctrl+B"))
self.blackout_action.triggered.connect(lambda: self._set_utility_mode("blackout"))
toolbar.addAction(self.blackout_action)
self._add_tempo_shortcuts()
def _add_tempo_shortcuts(self) -> None:
for shortcut, delta in (
("Left", -1.0),
("Right", 1.0),
("Shift+Left", -5.0),
("Shift+Right", 5.0),
):
action = QAction(self)
action.setShortcut(QKeySequence(shortcut))
action.setShortcutContext(Qt.ApplicationShortcut)
action.triggered.connect(lambda _checked=False, amount=delta: self._nudge_tempo_bpm(amount))
self.addAction(action)
def _build_central_layout(self) -> None:
splitter = QSplitter(Qt.Horizontal, self)
splitter.setChildrenCollapsible(False)
self.main_splitter = splitter
self.pattern_panel = PatternPanel(self.controller)
self.pattern_panel.setMinimumWidth(PATTERN_PANEL_MIN_WIDTH)
splitter.addWidget(self.pattern_panel)
self.preview_area = ScenePreviewArea(self.controller, preview_mode=self.preview_mode)
splitter.addWidget(self.preview_area)
side_panel = QWidget()
side_panel.setMinimumWidth(RIGHT_PANEL_MIN_WIDTH)
self.side_panel = side_panel
side_layout = QVBoxLayout(side_panel)
side_layout.setContentsMargins(0, 12, 0, 0)
side_layout.setSpacing(14)
self.preset_browser = PresetBrowser(self.controller)
side_layout.addWidget(self.preset_browser)
side_layout.addWidget(self._build_selected_tile_panel())
side_layout.addWidget(self._build_utility_panel())
side_layout.addStretch(1)
side_layout.addWidget(self._build_system_panel())
splitter.addWidget(side_panel)
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setStretchFactor(2, 0)
splitter.setSizes([PATTERN_PANEL_MIN_WIDTH, 1120, RIGHT_PANEL_MIN_WIDTH])
self.setCentralWidget(splitter)
def showEvent(self, event) -> None: # type: ignore[override]
super().showEvent(event)
if not self._startup_splitter_sized:
self._startup_splitter_sized = True
QTimer.singleShot(0, self._apply_startup_splitter_sizes)
def _apply_startup_splitter_sizes(self) -> None:
splitter_width = self.main_splitter.size().width()
if splitter_width <= 0:
return
left_width = max(self.pattern_panel.minimumWidth(), min(420, int(splitter_width * 0.22)))
right_width = max(self.side_panel.minimumWidth(), min(440, int(splitter_width * 0.24)))
center_width = max(CENTER_PREVIEW_MIN_WIDTH, splitter_width - left_width - right_width)
overshoot = (left_width + center_width + right_width) - splitter_width
if overshoot > 0:
reducible_left = max(0, left_width - self.pattern_panel.minimumWidth())
reduce_left = min(reducible_left, overshoot // 2)
left_width -= reduce_left
overshoot -= reduce_left
reducible_right = max(0, right_width - self.side_panel.minimumWidth())
reduce_right = min(reducible_right, overshoot)
right_width -= reduce_right
overshoot -= reduce_right
center_width = max(1, splitter_width - left_width - right_width)
self.main_splitter.setSizes([left_width, center_width, right_width])
def _build_selected_tile_panel(self) -> QWidget:
group = SectionPanel("Selected Tile")
layout = QVBoxLayout(group.body)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
self.selected_tile_label = QLabel("Click a tile in the preview.")
self.selected_tile_label.setWordWrap(True)
self.selected_tile_label.setStyleSheet("font-size: 14px;")
layout.addWidget(self.selected_tile_label)
button_row = QHBoxLayout()
self.single_tile_button = QPushButton("White Test")
self.single_tile_button.clicked.connect(lambda: self._set_utility_mode("single_tile"))
self.clear_test_button = QPushButton("Live Pattern")
self.clear_test_button.clicked.connect(lambda: self._set_utility_mode("none"))
button_row.addWidget(self.single_tile_button)
button_row.addWidget(self.clear_test_button)
layout.addLayout(button_row)
return group
def _build_utility_panel(self) -> QWidget:
group = SectionPanel("Utilities")
layout = QVBoxLayout(group.body)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
self.utility_buttons: dict[str, QPushButton] = {}
for label, mode in [
("Blackout", "blackout"),
("Live Pattern", "none"),
]:
button = QPushButton(label)
button.setCheckable(True)
button.clicked.connect(lambda _checked=False, utility=mode: self._set_utility_mode(utility))
layout.addWidget(button)
self.utility_buttons[mode] = button
return group
def _build_system_panel(self) -> QWidget:
group = SectionPanel("View & Output")
layout = QFormLayout(group.body)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
self.preview_mode_combo = QComboBox()
self.preview_mode_combo.addItem("Tile Colors", PREVIEW_MODE_TILE)
self.preview_mode_combo.addItem("Technical", PREVIEW_MODE_TECHNICAL)
self.preview_mode_combo.addItem("LEDs Only", PREVIEW_MODE_LEDS)
self.preview_mode_combo.currentIndexChanged.connect(self._change_preview_mode)
layout.addRow("Preview", self.preview_mode_combo)
self.backend_combo = QComboBox()
for backend_id, name in self.controller.output_manager.backend_names():
self.backend_combo.addItem(name, backend_id)
self.backend_combo.currentIndexChanged.connect(self._change_backend)
layout.addRow("Backend", self.backend_combo)
self.output_toggle = QPushButton("Enable Output")
self.output_toggle.setCheckable(True)
self.output_toggle.clicked.connect(self._toggle_output)
layout.addRow("Output", self.output_toggle)
self.output_fps_spin = QDoubleSpinBox()
self.output_fps_spin.setRange(1.0, 60.0)
self.output_fps_spin.setDecimals(0)
self.output_fps_spin.setSingleStep(1.0)
self.output_fps_spin.setSuffix(" fps")
self.output_fps_spin.setFixedWidth(84)
self.output_fps_spin.valueChanged.connect(self._change_output_target_fps)
layout.addRow("Output FPS", self.output_fps_spin)
self.render_fps_value = QLabel("--")
self.render_fps_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addRow("Render FPS", self.render_fps_value)
self.send_fps_value = QLabel("--")
self.send_fps_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addRow("Send FPS", self.send_fps_value)
self.output_health_value = QLabel("--")
self.output_health_value.setWordWrap(True)
self.output_health_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addRow("Output Health", self.output_health_value)
self.controller_fps_value = QLabel("n/a")
self.controller_fps_value.setWordWrap(True)
self.controller_fps_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addRow("Controller FPS", self.controller_fps_value)
self.fullscreen_button = QPushButton("Fullscreen Preview")
self.fullscreen_button.clicked.connect(self.toggle_fullscreen_preview)
layout.addRow("Window", self.fullscreen_button)
return group
def _build_status_bar(self) -> None:
self.setStatusBar(QStatusBar(self))
self.statusBar().showMessage("Ready")
self.mapping_status_label = QLabel("")
self.mapping_status_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.statusBar().addPermanentWidget(self.mapping_status_label, 1)
def _toggle_blackout_blink(self) -> None:
self._blackout_blink_on = not self._blackout_blink_on
self._apply_live_pattern_blink()
def _apply_live_pattern_blink(self) -> None:
override_mode = self.controller.utility_mode
override_active = override_mode in {"blackout", "single_tile"}
style = ALERT_UTILITY_STYLE if override_active and self._blackout_blink_on else ""
self.clear_test_button.setStyleSheet(style)
live_pattern_button = self.utility_buttons.get("none")
if live_pattern_button is not None:
live_pattern_button.setStyleSheet(style)
self.single_tile_button.setStyleSheet(ACTIVE_UTILITY_STYLE if override_mode == "single_tile" else "")
blackout_button = self.utility_buttons.get("blackout")
if blackout_button is not None:
blackout_button.setStyleSheet(ACTIVE_UTILITY_STYLE if override_mode == "blackout" else "")
def open_mapping(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
"Open Mapping",
str(self.controller.mapping_path.parent if self.controller.mapping_path else Path.home()),
"XML Files (*.xml)",
)
if not path:
return
try:
self.controller.load_mapping(path)
except MappingValidationError as exc:
QMessageBox.warning(self, "Mapping Error", "\n".join(exc.errors))
def save_mapping(self) -> None:
if self.controller.mapping_path is None:
self.save_mapping_as()
return
try:
self.controller.save_mapping()
except MappingValidationError as exc:
QMessageBox.warning(self, "Save Error", "\n".join(exc.errors))
def save_mapping_as(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self,
"Save Mapping As",
str(self.controller.mapping_path or (Path.home() / "infinity_mirror_mapping.xml")),
"XML Files (*.xml)",
)
if not path:
return
try:
self.controller.save_mapping(path)
except MappingValidationError as exc:
QMessageBox.warning(self, "Save Error", "\n".join(exc.errors))
def open_settings(self) -> None:
dialog = SettingsDialog(self.controller.config, controller=self.controller, parent=self)
if dialog.exec() == SettingsDialog.Accepted and dialog.result_config is not None:
self.controller.replace_config(dialog.result_config)
self.statusBar().showMessage("Mapping updated in memory. Save to write XML.", 4000)
def _change_preview_mode(self) -> None:
self.preview_mode = normalize_preview_mode(self.preview_mode_combo.currentData())
self.preview_area.set_preview_mode(self.preview_mode)
def _toggle_foh_mode(self, enabled: bool) -> None:
self.controller.set_foh_mode(enabled)
def _go_scene(self) -> None:
self.controller.go_scene()
def _fade_go_scene(self) -> None:
self.controller.fade_go(self.fade_time_spin.value())
def _change_transition_duration(self, value: float) -> None:
self.controller.set_transition_duration(value)
def _change_tempo_bpm(self, value: float) -> None:
self.controller.set_tempo_bpm(value)
def _nudge_tempo_bpm(self, delta: float) -> None:
self.controller.set_tempo_bpm(self.controller.tempo_bpm + delta)
def toggle_technical_preview(self, enabled: bool) -> None:
self.preview_mode = PREVIEW_MODE_TECHNICAL if enabled else PREVIEW_MODE_TILE
self.preview_area.set_preview_mode(self.preview_mode)
def toggle_fullscreen_preview(self) -> None:
self.preview_area.toggle_fullscreen()
def _change_backend(self) -> None:
self.controller.set_backend(self.backend_combo.currentData())
def _toggle_output(self) -> None:
self.controller.set_output_enabled(self.output_toggle.isChecked())
def _change_output_target_fps(self, value: float) -> None:
self.controller.set_output_target_fps(value)
def _set_utility_mode(self, mode: str) -> None:
if mode == "none":
self.controller.clear_utility_mode()
else:
self.controller.set_utility_mode(mode)
def _pattern_display_name(self, pattern_id: str) -> str:
for descriptor in self.controller.available_patterns():
if descriptor.pattern_id == pattern_id:
return descriptor.display_name
return pattern_id.replace("_", " ").title()
def _refresh_scene_labels(self) -> None:
live_name = self._pattern_display_name(self.controller.scene_state("live").pattern_id)
next_name = self._pattern_display_name(self.controller.scene_state("next").pattern_id)
self.preview_area.set_scene_labels(
"Live | Fading" if self.controller.transition_active else f"Live | {live_name}",
f"Next | {next_name}",
)
def _refresh_state(self) -> None:
mapping_name = self.controller.mapping_path.name if self.controller.mapping_path else "Unsaved Mapping"
self.mapping_status_label.setText(mapping_name)
self._refresh_scene_labels()
self.preview_area.set_foh_mode(self.controller.foh_mode_enabled)
preview_index = self.preview_mode_combo.findData(self.preview_mode)
self.preview_mode_combo.blockSignals(True)
self.preview_mode_combo.setCurrentIndex(max(0, preview_index))
self.preview_mode_combo.blockSignals(False)
self.tempo_spin.blockSignals(True)
self.tempo_spin.setValue(self.controller.tempo_bpm)
self.tempo_spin.blockSignals(False)
self.foh_toggle.blockSignals(True)
self.foh_toggle.setChecked(self.controller.foh_mode_enabled)
self.foh_toggle.blockSignals(False)
self.go_button.setEnabled(self.controller.foh_mode_enabled)
self.fade_go_button.setEnabled(self.controller.foh_mode_enabled)
self.fade_time_spin.blockSignals(True)
self.fade_time_spin.setValue(self.controller.transition_duration_s)
self.fade_time_spin.blockSignals(False)
self.fade_time_spin.setEnabled(self.controller.foh_mode_enabled)
self.foh_target_label.setText("Edit: Next" if self.controller.foh_mode_enabled else "Edit: Live")
backend_index = self.backend_combo.findData(self.controller.output_manager.active_backend_id)
self.backend_combo.blockSignals(True)
self.backend_combo.setCurrentIndex(max(0, backend_index))
self.backend_combo.blockSignals(False)
self.output_toggle.blockSignals(True)
self.output_toggle.setChecked(self.controller.output_manager.output_enabled)
self.output_toggle.setText("Output Enabled" if self.controller.output_manager.output_enabled else "Enable Output")
self.output_toggle.blockSignals(False)
self.output_fps_spin.blockSignals(True)
self.output_fps_spin.setValue(self.controller.output_manager.target_fps())
self.output_fps_spin.blockSignals(False)
if self.controller.utility_mode in {"blackout", "single_tile"}:
self._blackout_blink_on = True
if not self._blackout_blink_timer.isActive():
self._blackout_blink_timer.start()
self._apply_live_pattern_blink()
else:
if self._blackout_blink_timer.isActive():
self._blackout_blink_timer.stop()
self._blackout_blink_on = False
self._apply_live_pattern_blink()
tile = self.controller.config.tile_lookup().get(self.controller.selected_tile_id) if self.controller.selected_tile_id else None
if tile is None:
self.selected_tile_label.setText("Click a tile in the preview to inspect or run a single-tile white test.")
self.single_tile_button.setEnabled(False)
else:
self.selected_tile_label.setText(
f"{tile.tile_id}\n{tile.screen_name or tile.controller_ip}\nRow {tile.row}, Col {tile.col} | Universe {tile.universe} | {tile.led_total} LEDs"
)
self.single_tile_button.setEnabled(True)
for mode, button in self.utility_buttons.items():
active = self.controller.utility_mode == mode or (mode == "none" and self.controller.utility_mode == "none")
button.setChecked(active)
self._refresh_diagnostics()
def _refresh_diagnostics(self) -> None:
diagnostics = self.controller.realtime_diagnostics()
render_text = "--" if diagnostics.render_fps <= 0.0 else f"{diagnostics.render_fps:.1f} fps"
self.render_fps_value.setText(render_text)
if diagnostics.output_enabled:
send_text = f"{diagnostics.send_fps:.1f} fps via {diagnostics.backend_name}"
else:
send_text = f"0.0 fps via {diagnostics.backend_name}"
self.send_fps_value.setText(send_text)
self.send_fps_value.setToolTip(
f"Target {diagnostics.target_output_fps:.0f} fps\n"
f"Last send {diagnostics.last_send_time_ms:.1f} ms\n"
f"Last schedule slip {diagnostics.last_schedule_slip_ms:.1f} ms"
)
health_parts = [
f"target {diagnostics.target_output_fps:.0f} fps",
f"stale drops {diagnostics.stale_frame_drops}",
f"budget misses {diagnostics.send_budget_misses}",
f"last send {diagnostics.last_send_time_ms:.1f} ms",
]
if diagnostics.send_failures:
health_parts.append(f"send failures {diagnostics.send_failures}")
self.output_health_value.setText(" | ".join(health_parts))
if diagnostics.controller_fps is None:
controller_text = "n/a"
if diagnostics.controller_source:
if "disabled during live output" in diagnostics.controller_source.lower():
controller_text = "n/a (disabled)"
elif diagnostics.controller_total_devices > 0:
controller_text = f"n/a ({diagnostics.controller_live_devices}/{diagnostics.controller_total_devices} live)"
else:
controller_text = "n/a"
else:
controller_text = (
f"{diagnostics.controller_fps:.1f} fps avg "
f"({diagnostics.controller_live_devices}/{diagnostics.controller_total_devices} live)"
)
self.controller_fps_value.setText(controller_text)
self.controller_fps_value.setToolTip(diagnostics.controller_source or "No verified controller-side FPS source for this backend.")

View File

@@ -0,0 +1,193 @@
from __future__ import annotations
from app.qt_compat import (
QColor,
QFont,
QLinearGradient,
QPainter,
QPainterPath,
QPen,
QRectF,
Qt,
Signal,
QWidget,
event_posf,
)
from app.config.device_assignment import tile_matches_device
from app.config.models import InfinityMirrorConfig
from app.network.wled import DiscoveredWledDevice, normalize_mac_address
from app.ui.preview_layout import compute_preview_layout
class MappingAssignmentPreview(QWidget):
tileClicked = Signal(str)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._config = InfinityMirrorConfig()
self._discovered_devices: list[DiscoveredWledDevice] = []
self._active_device: DiscoveredWledDevice | None = None
self._selected_tile_id: str | None = None
self._tile_rects: dict[str, QRectF] = {}
self.setMinimumSize(520, 360)
self.setMouseTracking(True)
def set_assignment_state(
self,
config: InfinityMirrorConfig,
discovered_devices: list[DiscoveredWledDevice],
*,
active_device: DiscoveredWledDevice | None = None,
selected_tile_id: str | None = None,
) -> None:
self._config = config
self._discovered_devices = list(discovered_devices)
self._active_device = active_device
self._selected_tile_id = selected_tile_id
self.update()
def mousePressEvent(self, event) -> None:
point = event_posf(event)
for tile_id, rect in self._tile_rects.items():
if rect.contains(point):
self.tileClicked.emit(tile_id)
break
super().mousePressEvent(event)
def paintEvent(self, event) -> None: # type: ignore[override]
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setRenderHint(QPainter.TextAntialiasing, True)
rect = QRectF(self.rect())
background = QLinearGradient(rect.topLeft(), rect.bottomRight())
background.setColorAt(0.0, QColor("#12161D"))
background.setColorAt(1.0, QColor("#1B2230"))
painter.fillRect(rect, background)
if not self._config.tiles:
painter.setPen(QColor("#A8B3C7"))
painter.drawText(rect, Qt.AlignCenter, "Open a mapping to assign WLED devices.")
return
layout = compute_preview_layout(rect, self._config)
self._tile_rects = layout.tile_rects
self._draw_canvas_shell(painter, layout.canvas_rect)
discovered_ips = {device.ip_address for device in self._discovered_devices}
discovered_macs = {normalize_mac_address(device.mac_address) for device in self._discovered_devices if device.mac_address}
active_tile_id = None
if self._active_device is not None:
for tile in self._config.sorted_tiles():
if tile_matches_device(tile, self._active_device):
active_tile_id = tile.tile_id
break
for tile in self._config.sorted_tiles():
tile_rect = layout.tile_rects[tile.tile_id]
tile_assigned = bool(tile.controller_ip.strip() or tile.controller_mac.strip())
tile_is_active = active_tile_id == tile.tile_id
tile_is_selected = self._selected_tile_id == tile.tile_id
if tile_is_active:
fill_color = QColor("#1D4E89")
outline_color = QColor("#90CAF9")
subtitle = self._active_device.instance_name or self._active_device.ip_address
status = "Active Device"
elif not tile_assigned:
fill_color = QColor("#2A2F3A")
outline_color = QColor("#4A5568")
subtitle = "Unmapped"
status = "Click to assign"
elif (tile.controller_mac and normalize_mac_address(tile.controller_mac) in discovered_macs) or tile.controller_ip.strip() in discovered_ips:
fill_color = QColor("#1D5A45")
outline_color = QColor("#81E6D9")
subtitle = tile.controller_name or tile.controller_host or tile.controller_ip
status = "Mapped"
else:
fill_color = QColor("#6B4F1D")
outline_color = QColor("#F6AD55")
subtitle = tile.controller_name or tile.controller_host or tile.controller_ip
status = "Assigned, not seen"
self._draw_tile(
painter,
rect=tile_rect,
tile_id=tile.tile_id,
row=tile.row,
col=tile.col,
subtitle=subtitle,
status=status,
fill_color=fill_color,
outline_color=outline_color,
selected=tile_is_selected,
)
def _draw_canvas_shell(self, painter: QPainter, rect: QRectF) -> None:
path = QPainterPath()
path.addRoundedRect(rect, 10.0, 10.0)
painter.fillPath(path, QColor("#202632"))
painter.setPen(QPen(QColor("#334155"), 1.0))
painter.drawPath(path)
def _draw_tile(
self,
painter: QPainter,
*,
rect: QRectF,
tile_id: str,
row: int,
col: int,
subtitle: str,
status: str,
fill_color: QColor,
outline_color: QColor,
selected: bool,
) -> None:
base = min(rect.width(), rect.height())
rounding = max(4.0, base * 0.045)
tile_path = QPainterPath()
tile_path.addRoundedRect(rect, rounding, rounding)
painter.fillPath(tile_path, fill_color)
highlight = QLinearGradient(rect.topLeft(), rect.bottomLeft())
highlight.setColorAt(0.0, QColor(255, 255, 255, 24))
highlight.setColorAt(0.18, QColor(255, 255, 255, 8))
highlight.setColorAt(1.0, QColor(0, 0, 0, 0))
painter.fillPath(tile_path, highlight)
painter.setPen(QPen(outline_color, 1.3))
painter.drawPath(tile_path)
if selected:
painter.setPen(QPen(QColor("#E2E8F0"), 2.1))
painter.drawRoundedRect(rect.adjusted(-3, -3, 3, 3), rounding + 2.0, rounding + 2.0)
padding_x = max(12.0, rect.width() * 0.08)
padding_top = max(10.0, rect.height() * 0.08)
padding_bottom = max(12.0, rect.height() * 0.08)
title_font = QFont()
title_font.setPointSizeF(max(13.0, base * 0.1))
title_font.setWeight(QFont.DemiBold)
painter.setFont(title_font)
painter.setPen(QColor("#F8FAFC"))
painter.drawText(
rect.adjusted(padding_x, padding_top, -padding_x, -rect.height() * 0.56),
Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap,
tile_id,
)
meta_font = QFont(title_font)
meta_font.setPointSizeF(max(9.2, base * 0.06))
meta_font.setWeight(QFont.Normal)
painter.setFont(meta_font)
painter.setPen(QColor(235, 244, 249, 175))
painter.drawText(
rect.adjusted(padding_x, rect.height() * 0.48, -padding_x, -padding_bottom),
Qt.AlignLeft | Qt.AlignBottom | Qt.TextWordWrap,
f"{subtitle}\n{status} | R{row} C{col}",
)

382
app/ui/pattern_panel.py Normal file
View File

@@ -0,0 +1,382 @@
from __future__ import annotations
import math
from app.qt_compat import (
QCheckBox,
QColor,
QColorDialog,
QComboBox,
QFont,
QFormLayout,
QHBoxLayout,
QLabel,
QPainter,
QPen,
QPointF,
QPushButton,
QRectF,
QScrollArea,
QSlider,
Qt,
Signal,
QVBoxLayout,
QWidget,
event_posf,
)
from app.core.colors import PALETTES, canonical_palette_name
from app.patterns.base import COMMON_PARAMETER_SPECS
from app.ui.section_panel import SectionPanel
class SliderField(QWidget):
valueChanged = Signal(float)
def __init__(self, minimum: float, maximum: float, step: float, decimals: int = 2, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.minimum = minimum
self.maximum = maximum
self.step = step
self.decimals = decimals
self.slider = QSlider(Qt.Horizontal, self)
self.slider.setMinimum(0)
self.slider.setMaximum(int(round((maximum - minimum) / step)))
self.value_label = QLabel(self)
self.value_label.setFixedWidth(64)
self.value_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.slider, 1)
layout.addWidget(self.value_label)
self.slider.valueChanged.connect(self._on_slider_changed)
self.set_value(minimum)
def value(self) -> float:
return self.minimum + self.slider.value() * self.step
def set_value(self, value: float) -> None:
clamped = max(self.minimum, min(self.maximum, float(value)))
scaled = int(round((clamped - self.minimum) / self.step))
scaled = max(self.slider.minimum(), min(self.slider.maximum(), scaled))
self.slider.blockSignals(True)
self.slider.setValue(scaled)
self.slider.blockSignals(False)
self._update_label()
def _on_slider_changed(self, _: int) -> None:
self._update_label()
self.valueChanged.emit(self.value())
def _update_label(self) -> None:
self.value_label.setText(f"{self.value():.{self.decimals}f}")
class ClickableLabel(QLabel):
clicked = Signal()
def mousePressEvent(self, event) -> None: # type: ignore[override]
if event.button() == Qt.LeftButton:
self.clicked.emit()
event.accept()
return
super().mousePressEvent(event)
class ColorButton(QPushButton):
colorChanged = Signal(str)
def __init__(self, color_hex: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._color_hex = color_hex
self.clicked.connect(self.choose_color)
self.setToolTip("Open a color picker.")
self.set_color(color_hex)
def color(self) -> str:
return self._color_hex
def set_color(self, color_hex: str) -> None:
self._color_hex = color_hex
color = QColor(color_hex)
text_color = "#09120F" if color.lightnessF() > 0.62 else "#E8F0F4"
self.setText(color_hex.upper())
self.setStyleSheet(
f"QPushButton {{ background: {color_hex}; color: {text_color}; border: 1px solid rgba(255,255,255,0.16); }}"
)
def choose_color(self) -> None:
color = QColorDialog.getColor(QColor(self._color_hex), self.window(), "Choose Color")
if color.isValid():
self.set_color(color.name())
self.colorChanged.emit(color.name())
class AngleSelector(QWidget):
valueChanged = Signal(float)
_ANGLES = (0, 45, 90, 135, 180, 225, 270, 315)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._value = 0
self.setMinimumSize(118, 118)
self.setMaximumHeight(132)
def value(self) -> float:
return float(self._value)
def set_value(self, value: float) -> None:
snapped = self._snap_angle(value)
if snapped != self._value:
self._value = snapped
self.update()
def _snap_angle(self, value: float) -> int:
angle = int(round(float(value))) % 360
return min(self._ANGLES, key=lambda candidate: min((candidate - angle) % 360, (angle - candidate) % 360))
def _point_for_angle(self, center: QPointF, radius: float, angle: int) -> QPointF:
radians = math.radians(angle)
return QPointF(center.x() + math.cos(radians) * radius, center.y() + math.sin(radians) * radius)
def mousePressEvent(self, event) -> None: # type: ignore[override]
pos = event_posf(event)
center = QPointF(self.width() / 2.0, self.height() / 2.0)
dx = pos.x() - center.x()
dy = pos.y() - center.y()
if dx == 0.0 and dy == 0.0:
return
angle = (math.degrees(math.atan2(dy, dx)) + 360.0) % 360.0
snapped = self._snap_angle(angle)
if snapped != self._value:
self._value = snapped
self.update()
self.valueChanged.emit(float(snapped))
def paintEvent(self, _event) -> None: # type: ignore[override]
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, True)
bounds = QRectF(self.rect()).adjusted(8.0, 8.0, -8.0, -8.0)
center = bounds.center()
outer_radius = min(bounds.width(), bounds.height()) * 0.34
label_radius = outer_radius + 15.0
painter.setPen(QPen(QColor("#3C3C3C"), 1.2))
painter.setBrush(QColor("#252526"))
painter.drawEllipse(center, outer_radius, outer_radius)
selected_point = self._point_for_angle(center, outer_radius - 10.0, self._value)
painter.setPen(QPen(QColor("#007ACC"), 3.0))
painter.drawLine(center, selected_point)
painter.setBrush(QColor("#007ACC"))
painter.drawEllipse(selected_point, 6.5, 6.5)
label_font = QFont(self.font())
label_font.setPointSizeF(7.6)
label_font.setWeight(QFont.Medium)
painter.setFont(label_font)
for angle in self._ANGLES:
node = self._point_for_angle(center, outer_radius, angle)
active = angle == self._value
painter.setPen(QPen(QColor("#007ACC") if active else QColor("#5A5A5A"), 1.2))
painter.setBrush(QColor("#007ACC") if active else QColor("#2D2D30"))
painter.drawEllipse(node, 5.5 if active else 4.5, 5.5 if active else 4.5)
label_point = self._point_for_angle(center, label_radius, angle)
label_rect = QRectF(label_point.x() - 16.0, label_point.y() - 8.0, 32.0, 16.0)
painter.setPen(QColor("#FFFFFF") if active else QColor("#A8A8A8"))
painter.drawText(label_rect, Qt.AlignCenter, f"{angle}\N{DEGREE SIGN}")
painter.setPen(QColor("#A8A8A8"))
painter.drawText(QRectF(center.x() - 26.0, center.y() - 10.0, 52.0, 20.0), Qt.AlignCenter, f"{self._value}\N{DEGREE SIGN}")
painter.end()
class PatternPanel(QWidget):
def __init__(self, controller, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.controller = controller
self._updating = False
self._rows: dict[str, tuple[QLabel, QWidget]] = {}
root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
scroll = QScrollArea(self)
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setStyleSheet("QScrollArea { border: 0; background: transparent; }")
root_layout.addWidget(scroll, 1)
content = QWidget()
scroll.setWidget(content)
content_layout = QVBoxLayout(content)
content_layout.setContentsMargins(0, 12, 0, 0)
content_layout.setSpacing(14)
pattern_group = SectionPanel("Pattern")
pattern_form = QFormLayout(pattern_group.body)
pattern_form.setContentsMargins(12, 12, 12, 12)
pattern_form.setSpacing(8)
self.pattern_combo = QComboBox()
for descriptor in self.controller.available_patterns():
self.pattern_combo.addItem(descriptor.display_name, descriptor.pattern_id)
pattern_form.addRow("Pattern", self.pattern_combo)
content_layout.addWidget(pattern_group)
controls_group = SectionPanel("Look & Motion")
self.controls_form = QFormLayout(controls_group.body)
self.controls_form.setContentsMargins(12, 12, 12, 12)
self.controls_form.setSpacing(8)
content_layout.addWidget(controls_group)
content_layout.addStretch(1)
self.widgets: dict[str, QWidget] = {}
self._build_controls()
self.pattern_combo.currentIndexChanged.connect(self._on_pattern_changed)
self.controller.state_changed.connect(self.refresh_from_state)
self.refresh_from_state()
def _build_controls(self) -> None:
self._add_combo("color_mode", list(COMMON_PARAMETER_SPECS["color_mode"].options))
self._add_combo("palette", [(name, name) for name in PALETTES])
self._add_color("primary_color")
self._add_color("secondary_color")
self._add_combo("direction", list(COMMON_PARAMETER_SPECS["direction"].options))
self._add_angle("angle")
self._add_combo("scan_style", list(COMMON_PARAMETER_SPECS["scan_style"].options))
self._add_combo("checker_mode", list(COMMON_PARAMETER_SPECS["checker_mode"].options))
self._add_combo("strobe_mode", list(COMMON_PARAMETER_SPECS["strobe_mode"].options))
self._add_combo("stopwatch_mode", list(COMMON_PARAMETER_SPECS["stopwatch_mode"].options))
self._add_combo("symmetry", list(COMMON_PARAMETER_SPECS["symmetry"].options))
self._add_combo("center_pulse_mode", list(COMMON_PARAMETER_SPECS["center_pulse_mode"].options))
self._add_slider("brightness")
self._add_slider("fade")
self._add_slider("on_width")
self._add_slider("off_width")
self._add_slider("block_size")
self._add_slider("pixel_group_size")
self._add_slider("strobe_duty_cycle")
self._add_slider("randomness")
self._add_slider("tempo_multiplier")
def _add_row(self, key: str, label_text: str, widget: QWidget) -> None:
spec = COMMON_PARAMETER_SPECS[key]
label: QLabel = QLabel(label_text)
if spec.kind == "slider" and spec.reset_value is not None:
clickable_label = ClickableLabel(label_text)
clickable_label.setCursor(Qt.PointingHandCursor)
clickable_label.clicked.connect(lambda field=key, reset_value=spec.reset_value: self._reset_slider(field, reset_value))
label = clickable_label
self.controls_form.addRow(label, widget)
self._rows[key] = (label, widget)
self.widgets[key] = widget
tooltip = spec.tooltip
if spec.kind == "slider" and spec.reset_value is not None:
tooltip = f"{tooltip} Click the label to reset." if tooltip else "Click the label to reset."
label.setToolTip(tooltip)
widget.setToolTip(tooltip)
def _add_combo(self, key: str, options: list[tuple[str, str]]) -> None:
combo = QComboBox()
for value, label in options:
combo.addItem(label, value)
combo.currentIndexChanged.connect(lambda _: self._on_combo_changed(key))
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, combo)
def _add_slider(self, key: str) -> None:
spec = COMMON_PARAMETER_SPECS[key]
decimals = 2 if spec.step < 0.1 else 1
slider = SliderField(spec.minimum, spec.maximum, spec.step, decimals=decimals)
slider.valueChanged.connect(lambda value, field=key: self._on_slider_changed(field, value))
self._add_row(key, spec.label, slider)
def _add_angle(self, key: str) -> None:
selector = AngleSelector()
selector.valueChanged.connect(lambda value, field=key: self._on_slider_changed(field, value))
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, selector)
def _add_checkbox(self, key: str) -> None:
checkbox = QCheckBox()
checkbox.stateChanged.connect(lambda _state, field=key, widget=checkbox: self._on_checkbox_changed(field, widget.isChecked()))
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, checkbox)
def _add_color(self, key: str) -> None:
button = ColorButton("#4D7CFF" if key == "primary_color" else "#0E1630")
button.colorChanged.connect(lambda value, field=key: self._on_color_changed(field, value))
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, button)
def _on_pattern_changed(self) -> None:
if self._updating:
return
self.controller.set_pattern(self.pattern_combo.currentData())
def _on_combo_changed(self, key: str) -> None:
if self._updating:
return
widget = self.widgets[key]
self.controller.set_parameter(key, widget.currentData())
def _on_slider_changed(self, key: str, value: float) -> None:
if self._updating:
return
self.controller.set_parameter(key, value)
def _reset_slider(self, key: str, value: float) -> None:
if self._updating:
return
widget = self.widgets.get(key)
if isinstance(widget, SliderField):
widget.set_value(value)
self.controller.set_parameter(key, value)
def _on_checkbox_changed(self, key: str, value: bool) -> None:
if self._updating:
return
self.controller.set_parameter(key, value)
def _on_color_changed(self, key: str, value: str) -> None:
if self._updating:
return
self.controller.set_parameter(key, value)
def refresh_from_state(self) -> None:
self._updating = True
self.pattern_combo.setCurrentIndex(max(0, self.pattern_combo.findData(self.controller.pattern_id)))
params = self.controller.params
for key, widget in self.widgets.items():
value = getattr(params, key)
if key == "palette":
value = canonical_palette_name(str(value))
if isinstance(widget, QComboBox):
index = widget.findData(value)
widget.setCurrentIndex(max(0, index))
elif isinstance(widget, SliderField):
widget.set_value(float(value))
elif isinstance(widget, AngleSelector):
widget.set_value(float(value))
elif isinstance(widget, QCheckBox):
widget.setChecked(bool(value))
elif isinstance(widget, ColorButton):
widget.set_color(str(value))
descriptor = next(
(descriptor for descriptor in self.controller.available_patterns() if descriptor.pattern_id == self.controller.pattern_id),
None,
)
supported = set(descriptor.supported_parameters) if descriptor is not None else set()
for key, (label, widget) in self._rows.items():
visible = key in supported
label.setVisible(visible)
widget.setVisible(visible)
self._updating = False

75
app/ui/preset_browser.py Normal file
View File

@@ -0,0 +1,75 @@
from __future__ import annotations
from app.qt_compat import QHBoxLayout, QInputDialog, QListWidget, QListWidgetItem, QMessageBox, QPushButton, Qt, QVBoxLayout, QWidget
from app.ui.section_panel import SectionPanel
class PresetBrowser(QWidget):
def __init__(self, controller, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.controller = controller
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
group = SectionPanel("Presets")
group_layout = QVBoxLayout(group.body)
group_layout.setContentsMargins(12, 12, 12, 12)
group_layout.setSpacing(8)
self.list_widget = QListWidget()
self.list_widget.itemDoubleClicked.connect(self._load_selected)
group_layout.addWidget(self.list_widget, 1)
button_row = QHBoxLayout()
self.save_button = QPushButton("Save Current")
self.load_button = QPushButton("Load")
self.delete_button = QPushButton("Delete")
button_row.addWidget(self.save_button)
button_row.addWidget(self.load_button)
button_row.addWidget(self.delete_button)
group_layout.addLayout(button_row)
layout.addWidget(group)
self.save_button.clicked.connect(self._save_current)
self.load_button.clicked.connect(self._load_selected)
self.delete_button.clicked.connect(self._delete_selected)
self.controller.presets_changed.connect(self.refresh)
self.refresh()
def refresh(self) -> None:
self.list_widget.clear()
for preset in self.controller.available_presets():
item = QListWidgetItem(preset.name)
item.setToolTip(f"{preset.pattern_id}\nPalette: {preset.palette}")
self.list_widget.addItem(item)
def _selected_name(self) -> str | None:
item = self.list_widget.currentItem()
return item.text() if item else None
def _save_current(self) -> None:
name, ok = QInputDialog.getText(self, "Save Preset", "Preset name:")
if ok and name.strip():
self.controller.save_current_preset(name.strip())
self.refresh()
def _load_selected(self, *_args) -> None:
name = self._selected_name()
if name:
self.controller.apply_preset(name)
def _delete_selected(self) -> None:
name = self._selected_name()
if not name:
return
confirm = QMessageBox.question(
self,
"Delete Preset",
f"Delete preset '{name}'?",
QMessageBox.Yes | QMessageBox.No,
)
if confirm == QMessageBox.Yes:
self.controller.delete_preset(name)
self.refresh()

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from app.qt_compat import QLabel, Qt, QVBoxLayout, QWidget
from app.ui.preview_modes import PREVIEW_MODE_TILE
from app.ui.preview_widget import PreviewWidget
class FullscreenPreviewWindow(QWidget):
def __init__(
self,
controller,
preview_mode: str = PREVIEW_MODE_TILE,
scene_role: str = "live",
technical_preview: bool | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setWindowTitle("Infinity Mirror Preview")
self.setWindowFlag(Qt.Window, True)
self.setAttribute(Qt.WA_DeleteOnClose, False)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.preview_widget = PreviewWidget(
controller,
preview_mode=preview_mode,
scene_role=scene_role,
technical_preview=technical_preview,
)
self.preview_widget.tileClicked.connect(controller.set_selected_tile)
layout.addWidget(self.preview_widget, 1)
hint = QLabel("Press F11 or Escape to leave fullscreen preview")
hint.setAlignment(Qt.AlignCenter)
hint.setStyleSheet("background: #2D2D30; color: #CCCCCC; padding: 8px; font-size: 12px; border-top: 1px solid #3C3C3C;")
layout.addWidget(hint)
def set_preview_mode(self, mode: str) -> None:
self.preview_widget.set_preview_mode(mode)
def set_technical_preview(self, enabled: bool) -> None:
self.preview_widget.set_technical_preview(enabled)
def keyPressEvent(self, event) -> None:
if event.key() in {Qt.Key_F11, Qt.Key_Escape}:
self.hide()
event.accept()
return
super().keyPressEvent(event)

68
app/ui/preview_layout.py Normal file
View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from dataclasses import dataclass
from app.qt_compat import QRectF
from app.config.models import InfinityMirrorConfig, TileConfig
@dataclass(frozen=True)
class PreviewLayout:
canvas_rect: QRectF
tile_rects: dict[str, QRectF]
def compute_preview_layout(widget_rect: QRectF, config: InfinityMirrorConfig) -> PreviewLayout:
rows = config.logical_display.rows
cols = config.logical_display.cols
outer = QRectF(widget_rect).adjusted(28, 28, -28, -28)
gap = min(22.0, max(10.0, min(outer.width() / 48.0, outer.height() / 22.0)))
tile_aspect = tile_aspect_ratio(config)
usable_width = max(1.0, outer.width() - gap * (cols - 1))
usable_height = max(1.0, outer.height() - gap * (rows - 1))
tile_width = usable_width / max(1, cols)
tile_height = tile_width / tile_aspect
if tile_height * rows > usable_height:
tile_height = usable_height / max(1, rows)
tile_width = tile_height * tile_aspect
shell_padding = max(18.0, min(tile_width, tile_height) * 0.16)
grid_width = tile_width * cols + gap * (cols - 1)
grid_height = tile_height * rows + gap * (rows - 1)
left = outer.left() + (outer.width() - grid_width) / 2.0
top = outer.top() + (outer.height() - grid_height) / 2.0
canvas_rect = QRectF(left - shell_padding, top - shell_padding, grid_width + shell_padding * 2.0, grid_height + shell_padding * 2.0)
tile_rects: dict[str, QRectF] = {}
for tile in config.sorted_tiles():
x = left + (tile.col - 1) * (tile_width + gap)
y = top + (tile.row - 1) * (tile_height + gap)
tile_rects[tile.tile_id] = QRectF(x, y, tile_width, tile_height)
return PreviewLayout(canvas_rect=canvas_rect, tile_rects=tile_rects)
def tile_aspect_ratio(config: InfinityMirrorConfig) -> float:
logical_display = config.logical_display
tile_width = max(1.0, float(logical_display.tile_width))
tile_height = max(1.0, float(logical_display.tile_height))
return max(0.55, min(1.8, tile_width / tile_height))
def segment_display_rect(tile: TileConfig, rect: QRectF) -> QRectF:
tile_width = max(0.001, tile.x1 - tile.x0)
tile_height = max(0.001, tile.y1 - tile.y0)
aspect_ratio = max(0.5, min(1.8, tile_width / tile_height))
base = min(rect.width(), rect.height())
margin = max(6.0, base * 0.07)
available_rect = rect.adjusted(margin, margin, -margin, -margin)
fitted_width = available_rect.width()
fitted_height = fitted_width / aspect_ratio
if fitted_height > available_rect.height():
fitted_height = available_rect.height()
fitted_width = fitted_height * aspect_ratio
left = available_rect.left() + (available_rect.width() - fitted_width) / 2.0
top = available_rect.top() + (available_rect.height() - fitted_height) / 2.0
return QRectF(left, top, fitted_width, fitted_height)

24
app/ui/preview_modes.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
PREVIEW_MODE_TILE = "tile"
PREVIEW_MODE_TECHNICAL = "technical"
PREVIEW_MODE_LEDS = "leds"
PREVIEW_MODES = (PREVIEW_MODE_TILE, PREVIEW_MODE_TECHNICAL, PREVIEW_MODE_LEDS)
def normalize_preview_mode(mode: str | None) -> str:
normalized = str(mode or PREVIEW_MODE_TILE).strip().lower()
return normalized if normalized in PREVIEW_MODES else PREVIEW_MODE_TILE
def preview_mode_flags(mode: str) -> dict[str, bool]:
preview_mode = normalize_preview_mode(mode)
return {
"show_fill": preview_mode in {PREVIEW_MODE_TILE, PREVIEW_MODE_TECHNICAL},
"show_labels": preview_mode in {PREVIEW_MODE_TILE, PREVIEW_MODE_TECHNICAL},
"show_leds": preview_mode in {PREVIEW_MODE_TECHNICAL, PREVIEW_MODE_LEDS},
"show_guides": preview_mode == PREVIEW_MODE_TECHNICAL,
"show_direction": preview_mode == PREVIEW_MODE_TECHNICAL,
"show_overlay_title": preview_mode == PREVIEW_MODE_TECHNICAL,
"show_technical_meta": preview_mode == PREVIEW_MODE_TECHNICAL,
}

274
app/ui/preview_painter.py Normal file
View File

@@ -0,0 +1,274 @@
from __future__ import annotations
import math
from app.qt_compat import QColor, QFont, QLinearGradient, QPainter, QPainterPath, QPen, QPointF, QRectF, Qt
from app.config.models import SegmentConfig, TileConfig
from app.core.geometry import segment_led_positions
from app.core.types import PreviewFrame, RGBColor
from .preview_layout import PreviewLayout, segment_display_rect
from .preview_modes import preview_mode_flags
def _qcolor(color: RGBColor, alpha: float = 1.0) -> QColor:
red, green, blue = color.to_8bit_tuple()
qt_color = QColor(red, green, blue)
qt_color.setAlphaF(max(0.0, min(1.0, alpha)))
return qt_color
def paint_empty_preview(painter: QPainter, rect: QRectF) -> None:
painter.fillRect(rect, QColor("#1E1E1E"))
painter.setPen(QColor("#8C8C8C"))
painter.drawText(rect, Qt.AlignCenter, "Open a mapping to start the preview.")
def paint_preview_scene(
painter: QPainter,
*,
config,
frame: PreviewFrame,
preview_mode: str,
selected_tile_id: str | None,
target_rect: QRectF,
layout: PreviewLayout,
) -> None:
flags = preview_mode_flags(preview_mode)
background = QLinearGradient(0, 0, target_rect.width(), target_rect.height())
background.setColorAt(0.0, _qcolor(frame.background_start))
background.setColorAt(1.0, _qcolor(frame.background_end))
painter.fillRect(target_rect, background)
_draw_canvas_shell(painter, layout.canvas_rect)
for tile in config.sorted_tiles():
tile_frame = frame.tiles.get(tile.tile_id)
tile_rect = layout.tile_rects[tile.tile_id]
_draw_tile(
painter,
tile=tile,
tile_frame=tile_frame,
rect=tile_rect,
flags=flags,
selected_tile_id=selected_tile_id,
)
if flags["show_overlay_title"]:
painter.setPen(QColor(204, 204, 204, 140))
painter.drawText(
target_rect.adjusted(24, 18, -24, -18),
Qt.AlignTop | Qt.AlignRight,
"Technical Preview",
)
def _draw_canvas_shell(painter: QPainter, rect: QRectF) -> None:
path = QPainterPath()
path.addRoundedRect(rect, 8, 8)
painter.fillPath(path, QColor("#252526"))
painter.setPen(QPen(QColor("#3C3C3C"), 1.0))
painter.drawPath(path)
def _draw_tile(
painter: QPainter,
*,
tile: TileConfig,
tile_frame,
rect: QRectF,
flags: dict[str, bool],
selected_tile_id: str | None,
) -> None:
if tile_frame is None:
return
base = min(rect.width(), rect.height())
rounding = max(4.0, base * 0.045)
fill_color = _qcolor(tile_frame.fill_color)
rim_color = _qcolor(tile_frame.rim_color)
diagonal_split = tile_frame.metadata.get("diagonal_split")
tile_path = QPainterPath()
tile_path.addRoundedRect(rect, rounding, rounding)
if flags["show_fill"] and isinstance(diagonal_split, dict):
_draw_diagonal_split_fill(painter, tile_path, rect, diagonal_split)
elif flags["show_fill"]:
painter.fillPath(tile_path, fill_color)
else:
painter.fillPath(tile_path, QColor("#090B12"))
if flags["show_fill"]:
highlight = QLinearGradient(rect.topLeft(), rect.bottomLeft())
highlight.setColorAt(0.0, QColor(255, 255, 255, 26))
highlight.setColorAt(0.12, QColor(255, 255, 255, 10))
highlight.setColorAt(1.0, QColor(0, 0, 0, 0))
painter.fillPath(tile_path, highlight)
outline_color = rim_color if flags["show_fill"] else QColor(255, 255, 255, 32)
painter.setPen(QPen(outline_color, 1.2 if flags["show_leds"] else 1.0))
painter.drawPath(tile_path)
if flags["show_fill"]:
inner_rect = rect.adjusted(rect.width() * 0.08, rect.height() * 0.08, -rect.width() * 0.08, -rect.height() * 0.08)
painter.setPen(QPen(QColor(255, 255, 255, 14), 1.0))
painter.drawRoundedRect(inner_rect, rounding * 0.66, rounding * 0.66)
if not tile.enabled:
painter.fillPath(tile_path, QColor(0, 0, 0, 125))
painter.setPen(QPen(QColor(255, 255, 255, 36), 1.0, Qt.DashLine))
painter.drawRoundedRect(rect.adjusted(6, 6, -6, -6), rounding * 0.8, rounding * 0.8)
if selected_tile_id == tile.tile_id:
painter.setPen(QPen(QColor("#007ACC"), 2.0))
painter.drawRoundedRect(rect.adjusted(-3, -3, 3, 3), rounding + 2, rounding + 2)
if flags["show_labels"]:
_draw_labels(painter, tile, tile_frame, rect, technical_meta=flags["show_technical_meta"])
if flags["show_leds"]:
_draw_segment_preview(
painter,
tile,
tile_frame,
rect,
show_guides=flags["show_guides"],
show_direction=flags["show_direction"],
)
def _draw_diagonal_split_fill(painter: QPainter, tile_path: QPainterPath, rect: QRectF, diagonal_split: dict[str, object]) -> None:
color_a = diagonal_split.get("color_a")
color_b = diagonal_split.get("color_b")
if not isinstance(color_a, RGBColor) or not isinstance(color_b, RGBColor):
painter.fillPath(tile_path, QColor("#000000"))
return
painter.save()
painter.setClipPath(tile_path)
orientation = str(diagonal_split.get("orientation", "slash"))
first = QPainterPath()
second = QPainterPath()
if orientation == "backslash":
first.moveTo(rect.topLeft())
first.lineTo(rect.topRight())
first.lineTo(rect.bottomRight())
first.closeSubpath()
second.moveTo(rect.topLeft())
second.lineTo(rect.bottomLeft())
second.lineTo(rect.bottomRight())
second.closeSubpath()
else:
first.moveTo(rect.topLeft())
first.lineTo(rect.topRight())
first.lineTo(rect.bottomLeft())
first.closeSubpath()
second.moveTo(rect.topRight())
second.lineTo(rect.bottomRight())
second.lineTo(rect.bottomLeft())
second.closeSubpath()
painter.fillPath(first, _qcolor(color_a))
painter.fillPath(second, _qcolor(color_b))
painter.restore()
def _draw_labels(painter: QPainter, tile: TileConfig, tile_frame, rect: QRectF, technical_meta: bool = False) -> None:
painter.save()
base = min(rect.width(), rect.height())
horizontal_padding = max(12.0, rect.width() * 0.08)
top_padding = max(10.0, rect.height() * 0.07)
bottom_padding = max(12.0, rect.height() * 0.08)
font = QFont()
font.setPointSizeF(max(14.0, base * 0.105))
font.setWeight(QFont.DemiBold)
painter.setFont(font)
painter.setPen(_qcolor(tile_frame.label_color, 0.92))
title_rect = rect.adjusted(horizontal_padding, top_padding, -horizontal_padding, -rect.height() * 0.52)
painter.drawText(title_rect, Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap, tile.tile_id)
meta_font = QFont(font)
meta_font.setPointSizeF(max(11.5, base * (0.07 if technical_meta else 0.082)))
meta_font.setWeight(QFont.Normal)
painter.setFont(meta_font)
text = f"R{tile.row} C{tile.col}"
if technical_meta:
text = f"{tile.screen_name or tile.controller_ip}\nU{tile.universe} S{tile.subnet} {tile.led_total} LEDs"
painter.setPen(QColor(235, 244, 249, 165))
meta_rect = rect.adjusted(horizontal_padding, rect.height() * 0.56, -horizontal_padding, -bottom_padding)
painter.drawText(meta_rect, Qt.AlignLeft | Qt.AlignBottom | Qt.TextWordWrap, text)
painter.restore()
def _draw_segment_preview(
painter: QPainter,
tile: TileConfig,
tile_frame,
rect: QRectF,
*,
show_guides: bool,
show_direction: bool,
) -> None:
painter.save()
led_radius = max(2.0, min(rect.width(), rect.height()) / 64.0)
guide_pen = QPen(QColor(220, 228, 236, 46), max(0.9, led_radius * 0.55))
guide_pen.setCapStyle(Qt.RoundCap)
guide_pen.setJoinStyle(Qt.RoundJoin)
for segment in tile.segments:
points = _segment_points(tile, segment, rect)
colors = tile_frame.led_pixels.get(segment.name, [])
if show_guides and len(points) >= 2:
painter.setPen(guide_pen)
for start, end in zip(points, points[1:]):
painter.drawLine(start, end)
painter.setPen(Qt.NoPen)
for index, point in enumerate(points):
color = colors[index] if index < len(colors) else tile_frame.rim_color
if color.to_8bit_tuple() == (0, 0, 0):
continue
painter.setBrush(_qcolor(color, 0.94))
painter.drawEllipse(point, led_radius, led_radius)
if show_direction and points:
_draw_direction_arrow(painter, points, segment)
painter.restore()
def _segment_points(tile: TileConfig, segment: SegmentConfig, rect: QRectF) -> list[QPointF]:
display_rect = segment_display_rect(tile, rect)
inset = max(2.0, min(display_rect.width(), display_rect.height()) * 0.02)
insets = (
inset / max(1.0, display_rect.width()),
inset / max(1.0, display_rect.height()),
)
return [
QPointF(display_rect.left() + x_pos * display_rect.width(), display_rect.top() + y_pos * display_rect.height())
for x_pos, y_pos in segment_led_positions(tile, segment, insets=insets)
]
def _draw_direction_arrow(painter: QPainter, points: list[QPointF], segment: SegmentConfig) -> None:
if len(points) < 2:
return
start = points[0]
end = points[-1]
mid = QPointF((start.x() + end.x()) / 2.0, (start.y() + end.y()) / 2.0)
dx = end.x() - start.x()
dy = end.y() - start.y()
length = math.hypot(dx, dy) or 1.0
ux, uy = dx / length, dy / length
arrow_len = 14.0
left = QPointF(mid.x() - ux * arrow_len + -uy * arrow_len * 0.5, mid.y() - uy * arrow_len + ux * arrow_len * 0.5)
right = QPointF(mid.x() - ux * arrow_len - -uy * arrow_len * 0.5, mid.y() - uy * arrow_len - ux * arrow_len * 0.5)
painter.setPen(QPen(QColor(255, 255, 255, 85), 1.0))
painter.drawLine(start, end)
painter.drawLine(mid, left)
painter.drawLine(mid, right)

118
app/ui/preview_widget.py Normal file
View File

@@ -0,0 +1,118 @@
from __future__ import annotations
from app.qt_compat import QPainter, QPointF, QRectF, Qt, Signal, QWidget, event_posf
from app.config.models import SegmentConfig, TileConfig
from app.core.geometry import segment_led_positions, segment_side
from app.core.types import PreviewFrame
from .preview_layout import compute_preview_layout, segment_display_rect, tile_aspect_ratio
from .preview_modes import (
PREVIEW_MODE_LEDS,
PREVIEW_MODE_TECHNICAL,
PREVIEW_MODE_TILE,
normalize_preview_mode,
preview_mode_flags,
)
from .preview_painter import paint_empty_preview, paint_preview_scene
class PreviewWidget(QWidget):
tileClicked = Signal(str)
def __init__(
self,
controller,
preview_mode: str = PREVIEW_MODE_TILE,
scene_role: str = "live",
technical_preview: bool | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.controller = controller
self.scene_role = "next" if str(scene_role).strip().lower() == "next" else "live"
if technical_preview is not None:
preview_mode = PREVIEW_MODE_TECHNICAL if technical_preview else PREVIEW_MODE_TILE
self.preview_mode = normalize_preview_mode(preview_mode)
self.technical_preview = self.preview_mode == PREVIEW_MODE_TECHNICAL
self.current_frame: PreviewFrame | None = self.controller.preview_frame_for(self.scene_role)
self._tile_rects: dict[str, QRectF] = {}
self.setMinimumSize(640, 360)
self.setMouseTracking(True)
if self.scene_role == "next":
self.controller.next_frame_ready.connect(self._on_frame_ready)
else:
self.controller.frame_ready.connect(self._on_frame_ready)
self.controller.config_changed.connect(self.update)
self.controller.state_changed.connect(self.update)
def set_preview_mode(self, mode: str) -> None:
self.preview_mode = normalize_preview_mode(mode)
self.technical_preview = self.preview_mode == PREVIEW_MODE_TECHNICAL
self.update()
def set_technical_preview(self, enabled: bool) -> None:
self.set_preview_mode(PREVIEW_MODE_TECHNICAL if enabled else PREVIEW_MODE_TILE)
def _mode_flags(self) -> dict[str, bool]:
return preview_mode_flags(self.preview_mode)
def _on_frame_ready(self, frame: PreviewFrame) -> None:
self.current_frame = frame
self.update()
def mousePressEvent(self, event) -> None:
point = event_posf(event)
for tile_id, rect in self._tile_rects.items():
if rect.contains(point):
self.tileClicked.emit(tile_id)
break
super().mousePressEvent(event)
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setRenderHint(QPainter.TextAntialiasing, True)
frame = self.current_frame
if frame is None or not self.controller.config.tiles:
paint_empty_preview(painter, QRectF(self.rect()))
return
layout = compute_preview_layout(QRectF(self.rect()), self.controller.config)
self._tile_rects = layout.tile_rects
paint_preview_scene(
painter,
config=self.controller.config,
frame=frame,
preview_mode=self.preview_mode,
selected_tile_id=self.controller.selected_tile_id,
target_rect=QRectF(self.rect()),
layout=layout,
)
def _compute_layout(self) -> tuple[QRectF, dict[str, QRectF]]:
layout = compute_preview_layout(QRectF(self.rect()), self.controller.config)
return layout.canvas_rect, layout.tile_rects
def _tile_aspect_ratio(self) -> float:
return tile_aspect_ratio(self.controller.config)
def _segment_display_rect(self, tile: TileConfig, rect: QRectF) -> QRectF:
return segment_display_rect(tile, rect)
def _segment_side(self, tile: TileConfig, segment: SegmentConfig) -> str | None:
return segment_side(tile, segment)
def _segment_points_for_side(self, tile: TileConfig, segment: SegmentConfig, rect: QRectF) -> list[QPointF]:
inset = max(2.0, min(rect.width(), rect.height()) * 0.02)
insets = (
inset / max(1.0, rect.width()),
inset / max(1.0, rect.height()),
)
return [
QPointF(rect.left() + x_pos * rect.width(), rect.top() + y_pos * rect.height())
for x_pos, y_pos in segment_led_positions(tile, segment, insets=insets)
]

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from app.qt_compat import QLabel, QVBoxLayout, QWidget
from app.ui.preview_fullscreen import FullscreenPreviewWindow
from app.ui.preview_modes import PREVIEW_MODE_TILE, normalize_preview_mode
from app.ui.preview_widget import PreviewWidget
class ScenePreviewArea(QWidget):
def __init__(self, controller, preview_mode: str = PREVIEW_MODE_TILE, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.controller = controller
self.preview_mode = normalize_preview_mode(preview_mode)
self.fullscreen_preview = FullscreenPreviewWindow(self.controller, preview_mode=self.preview_mode, scene_role="live")
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 12, 0, 0)
layout.setSpacing(14)
self.preview_title = QLabel("Preview")
self.preview_title.setStyleSheet("font-size: 12px; font-weight: 600; color: #CCCCCC; padding: 0 0 6px 2px;")
layout.addWidget(self.preview_title)
self.single_preview_container = QWidget()
single_layout = QVBoxLayout(self.single_preview_container)
single_layout.setContentsMargins(0, 0, 0, 0)
single_layout.setSpacing(0)
self.preview_widget = PreviewWidget(self.controller, preview_mode=self.preview_mode, scene_role="live")
self.preview_widget.tileClicked.connect(self.controller.set_selected_tile)
single_layout.addWidget(self.preview_widget, 1)
layout.addWidget(self.single_preview_container, 1)
self.foh_preview_container = QWidget()
foh_layout = QVBoxLayout(self.foh_preview_container)
foh_layout.setContentsMargins(0, 0, 0, 0)
foh_layout.setSpacing(12)
self.live_preview_widget = PreviewWidget(self.controller, preview_mode=self.preview_mode, scene_role="live")
self.live_preview_widget.tileClicked.connect(self.controller.set_selected_tile)
live_panel, self.live_preview_label = self._build_scene_preview_panel("Live", self.live_preview_widget)
foh_layout.addWidget(live_panel, 1)
self.next_preview_widget = PreviewWidget(self.controller, preview_mode=self.preview_mode, scene_role="next")
self.next_preview_widget.tileClicked.connect(self.controller.set_selected_tile)
next_panel, self.next_preview_label = self._build_scene_preview_panel("Next", self.next_preview_widget)
foh_layout.addWidget(next_panel, 1)
layout.addWidget(self.foh_preview_container, 1)
def set_preview_mode(self, mode: str) -> None:
self.preview_mode = normalize_preview_mode(mode)
self.preview_widget.set_preview_mode(self.preview_mode)
self.live_preview_widget.set_preview_mode(self.preview_mode)
self.next_preview_widget.set_preview_mode(self.preview_mode)
self.fullscreen_preview.set_preview_mode(self.preview_mode)
def set_foh_mode(self, enabled: bool) -> None:
self.single_preview_container.setVisible(not enabled)
self.foh_preview_container.setVisible(enabled)
def set_scene_labels(self, live_label: str, next_label: str) -> None:
self.live_preview_label.setText(live_label)
self.next_preview_label.setText(next_label)
def toggle_fullscreen(self) -> None:
if self.fullscreen_preview.isVisible():
self.fullscreen_preview.hide()
else:
self.fullscreen_preview.showFullScreen()
def _build_scene_preview_panel(self, title: str, preview_widget: PreviewWidget) -> tuple[QWidget, QLabel]:
panel = QWidget()
layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
title_label = QLabel(title)
title_label.setStyleSheet("font-size: 13px; font-weight: 600; color: #E6E6E6; padding-left: 2px;")
layout.addWidget(title_label)
preview_widget.setMinimumHeight(220)
layout.addWidget(preview_widget, 1)
return panel, title_label

36
app/ui/section_panel.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from app.qt_compat import QHBoxLayout, QLabel, Qt, QVBoxLayout, QWidget
class SectionPanel(QWidget):
def __init__(self, title: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("sectionPanel")
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.header_widget = QWidget(self)
self.header_widget.setObjectName("sectionHeader")
self.header_widget.setFixedHeight(40)
header_layout = QHBoxLayout(self.header_widget)
header_layout.setContentsMargins(12, 0, 12, 0)
header_layout.setSpacing(0)
self.title_label = QLabel(self.header_widget)
self.title_label.setObjectName("sectionHeaderLabel")
self.title_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
header_layout.addWidget(self.title_label, 1)
self.body = QWidget(self)
self.body.setObjectName("sectionBody")
layout.addWidget(self.header_widget)
layout.addWidget(self.body)
self.setTitle(title)
def setTitle(self, title: str) -> None:
self.title_label.setText(title)

833
app/ui/settings_dialog.py Normal file
View File

@@ -0,0 +1,833 @@
from __future__ import annotations
import threading
from app.qt_compat import QComboBox, QDialog, QDialogButtonBox, QFormLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, QMessageBox, QObject, QPushButton, QPlainTextEdit, QSpinBox, QTabWidget, QTableWidget, QTableWidgetItem, Qt, QVBoxLayout, QWidget, Signal
from app.config.device_assignment import assign_device_to_tile, find_tile_for_device, tile_matches_device
from app.config.models import InfinityMirrorConfig, SegmentConfig
from app.config.xml_mapping import MappingValidationError, config_to_xml_string, load_config_from_string, save_config, validate_config
from app.network.wled import DiscoveredWledDevice, build_scan_hosts, discover_wled_devices, identify_wled_device
from app.ui.mapping_assignment_preview import MappingAssignmentPreview
class NetworkScanWorker(QObject):
progress = Signal(int, int, object)
finished = Signal(object, str, int)
def __init__(self, config: InfinityMirrorConfig) -> None:
super().__init__()
self._config = config.clone()
def start(self) -> None:
thread = threading.Thread(target=self._run, name="InfinityMirrorNetworkScan", daemon=True)
thread.start()
def _run(self) -> None:
try:
hosts = build_scan_hosts(self._config)
devices = discover_wled_devices(hosts, progress_callback=self._emit_progress)
self.finished.emit(devices, "", len(hosts))
except Exception as exc:
self.finished.emit([], str(exc), 0)
def _emit_progress(self, completed: int, total: int, device: DiscoveredWledDevice | None) -> None:
self.progress.emit(completed, total, device)
class DeviceIdentifyWorker(QObject):
finished = Signal(object, str)
def __init__(self, device: DiscoveredWledDevice) -> None:
super().__init__()
self._device = device
def start(self) -> None:
thread = threading.Thread(target=self._run, name=f"WledIdentify-{self._device.ip_address}", daemon=True)
thread.start()
def _run(self) -> None:
try:
identify_wled_device(self._device.ip_address, led_count=self._device.led_count)
except Exception as exc:
self.finished.emit(self._device, str(exc))
return
self.finished.emit(self._device, "")
class SettingsDialog(QDialog):
TILE_COLUMNS = [
("tile_id", "Tile ID"),
("row", "Row"),
("col", "Col"),
("screen_name", "Screen Name"),
("controller_ip", "Controller IP"),
("controller_name", "Controller Name"),
("controller_host", "Controller Host"),
("controller_mac", "Controller MAC"),
("subnet", "Subnet"),
("universe", "Universe"),
("led_total", "LED Count"),
("brightness_factor", "Brightness"),
("enabled", "Enabled"),
]
SEGMENT_COLUMNS = [
("name", "Name"),
("side", "Side"),
("start_channel", "Start Ch"),
("led_count", "LED Count"),
("orientation_rad", "Orientation"),
("x0", "x0"),
("y0", "y0"),
("x1", "x1"),
("y1", "y1"),
("reverse", "Reverse"),
]
DEVICE_COLUMNS = [
"IP Address",
"Host / mDNS",
"WLED Name",
"MAC Address",
"Status",
"Map",
"Identify",
]
def __init__(self, config: InfinityMirrorConfig, controller=None, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle("Mapping Settings")
self.resize(1320, 900)
self.controller = controller
self.working_config = config.clone()
self.result_config: InfinityMirrorConfig | None = None
self._active_segment_tile_id: str | None = None
self._device_table_refreshing = False
self._active_device_key: str | None = None
self._selected_assignment_tile_id: str | None = None
self._scan_worker: NetworkScanWorker | None = None
self._identify_workers: set[DeviceIdentifyWorker] = set()
self.discovered_devices: list[DiscoveredWledDevice] = []
self._last_scan_host_count = 0
root_layout = QVBoxLayout(self)
header_group = QGroupBox("Mapping")
header_form = QFormLayout(header_group)
self.name_edit = QLineEdit(self.working_config.name)
self.rows_spin = QSpinBox()
self.rows_spin.setRange(1, 24)
self.rows_spin.setValue(self.working_config.logical_display.rows)
self.cols_spin = QSpinBox()
self.cols_spin.setRange(1, 24)
self.cols_spin.setValue(self.working_config.logical_display.cols)
header_form.addRow("Name", self.name_edit)
header_form.addRow("Rows", self.rows_spin)
header_form.addRow("Cols", self.cols_spin)
root_layout.addWidget(header_group)
self.tabs = QTabWidget()
self.tabs.currentChanged.connect(self._handle_tab_changed)
root_layout.addWidget(self.tabs, 1)
self.tiles_tab = QWidget()
self.network_tab = QWidget()
self.segments_tab = QWidget()
self.raw_tab = QWidget()
self.tabs.addTab(self.tiles_tab, "Tiles")
self.tabs.addTab(self.network_tab, "Network Mapping")
self.tabs.addTab(self.segments_tab, "Segments")
self.tabs.addTab(self.raw_tab, "Raw XML")
self._build_tiles_tab()
self._build_network_tab()
self._build_segments_tab()
self._build_raw_tab()
self._populate_tiles_table()
self._rebuild_segment_tile_combo()
self._refresh_raw_xml()
self._refresh_network_mapping_ui()
button_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
button_box.accepted.connect(self._save_and_accept)
button_box.rejected.connect(self.reject)
root_layout.addWidget(button_box)
def _build_tiles_tab(self) -> None:
layout = QVBoxLayout(self.tiles_tab)
self.tiles_table = QTableWidget(0, len(self.TILE_COLUMNS))
self.tiles_table.setHorizontalHeaderLabels([label for _, label in self.TILE_COLUMNS])
self.tiles_table.verticalHeader().setVisible(False)
self.tiles_table.setAlternatingRowColors(True)
self.tiles_table.setSelectionBehavior(QTableWidget.SelectRows)
self.tiles_table.setSortingEnabled(False)
layout.addWidget(self.tiles_table)
def _build_network_tab(self) -> None:
layout = QVBoxLayout(self.network_tab)
layout.setSpacing(12)
intro = QLabel(
"Scan the local network for WLED controllers, flash one device at a time, "
"then click the matching physical tile to save the assignment."
)
intro.setWordWrap(True)
layout.addWidget(intro)
hint = QLabel(
"Assignments save immediately to the open mapping file when the current mapping validates. "
"If the mapping cannot be saved yet, the assignment stays in this dialog and you will see a warning."
)
hint.setWordWrap(True)
hint.setStyleSheet("color: #A8B3C7;")
layout.addWidget(hint)
content_row = QHBoxLayout()
content_row.setSpacing(12)
layout.addLayout(content_row, 1)
preview_group = QGroupBox("Mapping Preview")
preview_layout = QVBoxLayout(preview_group)
preview_layout.setSpacing(8)
self.assignment_workflow_label = QLabel("Scan the network to begin assisted mapping.")
self.assignment_workflow_label.setWordWrap(True)
preview_layout.addWidget(self.assignment_workflow_label)
self.mapping_preview = MappingAssignmentPreview()
self.mapping_preview.tileClicked.connect(self._handle_mapping_preview_click)
preview_layout.addWidget(self.mapping_preview, 1)
content_row.addWidget(preview_group, 1)
device_group = QGroupBox("Discovered WLED Devices")
device_layout = QVBoxLayout(device_group)
device_layout.setSpacing(8)
scan_row = QHBoxLayout()
self.scan_button = QPushButton("Scan Network")
self.scan_button.clicked.connect(self._scan_network)
scan_row.addWidget(self.scan_button)
self.scan_status_label = QLabel("Not scanned yet.")
self.scan_status_label.setWordWrap(True)
scan_row.addWidget(self.scan_status_label, 1)
device_layout.addLayout(scan_row)
self.mapping_summary_label = QLabel("No devices discovered.")
self.mapping_summary_label.setWordWrap(True)
self.mapping_summary_label.setStyleSheet("color: #C7D2E1;")
device_layout.addWidget(self.mapping_summary_label)
self.discovered_devices_table = QTableWidget(0, len(self.DEVICE_COLUMNS))
self.discovered_devices_table.setHorizontalHeaderLabels(self.DEVICE_COLUMNS)
self.discovered_devices_table.verticalHeader().setVisible(False)
self.discovered_devices_table.setAlternatingRowColors(True)
self.discovered_devices_table.setSelectionBehavior(QTableWidget.SelectRows)
self.discovered_devices_table.setSortingEnabled(False)
self.discovered_devices_table.itemSelectionChanged.connect(self._on_discovered_device_selection_changed)
header = self.discovered_devices_table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
header.setSectionResizeMode(4, QHeaderView.Stretch)
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
header.setSectionResizeMode(6, QHeaderView.ResizeToContents)
device_layout.addWidget(self.discovered_devices_table, 1)
self.assignment_feedback_label = QLabel("Select a device or press Identify, then click the matching tile.")
self.assignment_feedback_label.setWordWrap(True)
self.assignment_feedback_label.setStyleSheet("color: #A8B3C7;")
device_layout.addWidget(self.assignment_feedback_label)
content_row.addWidget(device_group, 1)
def _build_segments_tab(self) -> None:
layout = QVBoxLayout(self.segments_tab)
top_row = QHBoxLayout()
top_row.addWidget(QLabel("Tile"))
self.segment_tile_combo = QComboBox()
self.segment_tile_combo.currentIndexChanged.connect(self._on_segment_tile_changed)
top_row.addWidget(self.segment_tile_combo, 1)
self.add_segment_button = QPushButton("Add Segment")
self.remove_segment_button = QPushButton("Remove Selected")
top_row.addWidget(self.add_segment_button)
top_row.addWidget(self.remove_segment_button)
layout.addLayout(top_row)
self.segments_table = QTableWidget(0, len(self.SEGMENT_COLUMNS))
self.segments_table.setHorizontalHeaderLabels([label for _, label in self.SEGMENT_COLUMNS])
self.segments_table.verticalHeader().setVisible(False)
self.segments_table.setAlternatingRowColors(True)
self.segments_table.setSelectionBehavior(QTableWidget.SelectRows)
layout.addWidget(self.segments_table, 1)
self.add_segment_button.clicked.connect(self._add_segment_row)
self.remove_segment_button.clicked.connect(self._remove_selected_segments)
def _build_raw_tab(self) -> None:
layout = QVBoxLayout(self.raw_tab)
button_row = QHBoxLayout()
self.refresh_raw_button = QPushButton("Refresh From Tables")
self.validate_raw_button = QPushButton("Validate XML")
self.apply_raw_button = QPushButton("Apply XML To Tables")
button_row.addWidget(self.refresh_raw_button)
button_row.addWidget(self.validate_raw_button)
button_row.addWidget(self.apply_raw_button)
button_row.addStretch(1)
layout.addLayout(button_row)
self.raw_editor = QPlainTextEdit()
self.raw_editor.setLineWrapMode(QPlainTextEdit.NoWrap)
self.raw_editor.document().setModified(False)
layout.addWidget(self.raw_editor, 1)
layout.addWidget(QLabel("Validation"))
self.error_list = QListWidget()
layout.addWidget(self.error_list)
self.refresh_raw_button.clicked.connect(self._refresh_raw_xml)
self.validate_raw_button.clicked.connect(self._validate_raw_xml)
self.apply_raw_button.clicked.connect(self._apply_raw_xml)
def _populate_tiles_table(self) -> None:
self.tiles_table.setRowCount(len(self.working_config.tiles))
for row, tile in enumerate(self.working_config.sorted_tiles()):
values = {
"tile_id": tile.tile_id,
"row": str(tile.row),
"col": str(tile.col),
"screen_name": tile.screen_name,
"controller_ip": tile.controller_ip,
"controller_name": tile.controller_name,
"controller_host": tile.controller_host,
"controller_mac": tile.controller_mac,
"subnet": str(tile.subnet),
"universe": str(tile.universe),
"led_total": str(tile.led_total),
"brightness_factor": f"{tile.brightness_factor:.3f}",
"enabled": tile.enabled,
}
for column, (key, _) in enumerate(self.TILE_COLUMNS):
if key == "enabled":
item = QTableWidgetItem()
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
item.setCheckState(Qt.Checked if tile.enabled else Qt.Unchecked)
else:
item = QTableWidgetItem(str(values[key]))
self.tiles_table.setItem(row, column, item)
self.tiles_table.resizeColumnsToContents()
def _rebuild_segment_tile_combo(self) -> None:
self.segment_tile_combo.blockSignals(True)
self.segment_tile_combo.clear()
for tile in self.working_config.sorted_tiles():
self.segment_tile_combo.addItem(tile.tile_id, tile.tile_id)
current_tile_id = self._active_segment_tile_id or (self.working_config.sorted_tiles()[0].tile_id if self.working_config.tiles else None)
if current_tile_id:
index = self.segment_tile_combo.findData(current_tile_id)
self.segment_tile_combo.setCurrentIndex(max(0, index))
self._active_segment_tile_id = self.segment_tile_combo.currentData()
self.segment_tile_combo.blockSignals(False)
self._populate_segments_table()
def _populate_segments_table(self) -> None:
tile = self._active_tile_for_segments()
self.segments_table.setRowCount(0)
if tile is None:
return
self.segments_table.setRowCount(len(tile.segments))
for row, segment in enumerate(tile.segments):
values = {
"name": segment.name,
"side": segment.side,
"start_channel": str(segment.start_channel),
"led_count": str(segment.led_count),
"orientation_rad": f"{segment.orientation_rad:.5f}",
"x0": f"{segment.x0:.3f}",
"y0": f"{segment.y0:.3f}",
"x1": f"{segment.x1:.3f}",
"y1": f"{segment.y1:.3f}",
"reverse": segment.reverse,
}
for column, (key, _) in enumerate(self.SEGMENT_COLUMNS):
if key == "reverse":
item = QTableWidgetItem()
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
item.setCheckState(Qt.Checked if segment.reverse else Qt.Unchecked)
else:
item = QTableWidgetItem(str(values[key]))
self.segments_table.setItem(row, column, item)
self.segments_table.resizeColumnsToContents()
def _active_tile_for_segments(self):
return self.working_config.tile_lookup().get(self.segment_tile_combo.currentData())
def _on_segment_tile_changed(self) -> None:
previous_tile_id = self._active_segment_tile_id
self._sync_segments_table(previous_tile_id)
self._active_segment_tile_id = self.segment_tile_combo.currentData()
self._populate_segments_table()
def _add_segment_row(self) -> None:
row = self.segments_table.rowCount()
self.segments_table.insertRow(row)
defaults = ["New Segment", "left", "1", "1", "0.0", "0.0", "0.0", "0.0", "0.0", False]
for column, (_, _) in enumerate(self.SEGMENT_COLUMNS):
value = defaults[column]
if column == len(self.SEGMENT_COLUMNS) - 1:
item = QTableWidgetItem()
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
item.setCheckState(Qt.Unchecked)
else:
item = QTableWidgetItem(str(value))
self.segments_table.setItem(row, column, item)
def _remove_selected_segments(self) -> None:
rows = sorted({item.row() for item in self.segments_table.selectedItems()}, reverse=True)
for row in rows:
self.segments_table.removeRow(row)
def _refresh_raw_xml(self) -> None:
self._sync_tables_to_config()
self._set_raw_xml_snapshot()
self.error_list.clear()
def _set_raw_xml_snapshot(self) -> None:
self.raw_editor.setPlainText(config_to_xml_string(self.working_config))
self.raw_editor.document().setModified(False)
def _validate_raw_xml(self) -> None:
self.error_list.clear()
try:
config = load_config_from_string(self.raw_editor.toPlainText(), validate=True)
self.error_list.addItem(f"XML valid. {len(config.tiles)} tiles loaded.")
except MappingValidationError as exc:
for error in exc.errors:
self.error_list.addItem(error)
def _apply_raw_xml(self) -> None:
self.error_list.clear()
try:
self.working_config = load_config_from_string(self.raw_editor.toPlainText(), validate=True)
self.name_edit.setText(self.working_config.name)
self.rows_spin.setValue(self.working_config.logical_display.rows)
self.cols_spin.setValue(self.working_config.logical_display.cols)
self._populate_tiles_table()
self._rebuild_segment_tile_combo()
self._refresh_network_mapping_ui()
self.raw_editor.document().setModified(False)
self.error_list.addItem("Applied XML to editable tables.")
except MappingValidationError as exc:
for error in exc.errors:
self.error_list.addItem(error)
def _handle_tab_changed(self, index: int) -> None:
try:
if self.tabs.widget(index) is self.network_tab:
self._sync_general_fields()
self._sync_tiles_table()
self._sync_segments_table()
self._refresh_network_mapping_ui()
elif self.tabs.widget(index) is self.segments_tab:
self._sync_tiles_table()
self._rebuild_segment_tile_combo()
elif self.tabs.widget(index) is self.raw_tab and not self.raw_editor.document().isModified():
self._refresh_raw_xml()
except (MappingValidationError, ValueError, KeyError, StopIteration) as exc:
self.tabs.blockSignals(True)
self.tabs.setCurrentWidget(self.tiles_tab)
self.tabs.blockSignals(False)
QMessageBox.warning(
self,
"Mapping Error",
f"Please resolve the current tile or segment values before switching tabs.\n\n{exc}",
)
def _sync_tables_to_config(self) -> None:
self._sync_general_fields()
self._sync_tiles_table()
self._sync_segments_table()
def _sync_general_fields(self) -> None:
self.working_config.name = self.name_edit.text().strip() or self.working_config.name
self.working_config.logical_display.rows = self.rows_spin.value()
self.working_config.logical_display.cols = self.cols_spin.value()
def _sync_tiles_table(self) -> None:
tiles = self.working_config.sorted_tiles()
for row, tile in enumerate(tiles):
tile.tile_id = self._text(row, "tile_id")
tile.row = int(self._text(row, "row"))
tile.col = int(self._text(row, "col"))
tile.screen_name = self._text(row, "screen_name")
tile.controller_ip = self._text(row, "controller_ip")
tile.controller_name = self._text(row, "controller_name")
tile.controller_host = self._text(row, "controller_host")
tile.controller_mac = self._text(row, "controller_mac")
tile.subnet = int(self._text(row, "subnet"))
tile.universe = int(self._text(row, "universe"))
tile.led_total = int(self._text(row, "led_total"))
tile.brightness_factor = float(self._text(row, "brightness_factor"))
tile.enabled = self._check(row, "enabled")
self.working_config.tiles = tiles
def _sync_segments_table(self, tile_id: str | None = None) -> None:
lookup_id = tile_id if tile_id is not None else self._active_segment_tile_id or self.segment_tile_combo.currentData()
tile = self.working_config.tile_lookup().get(lookup_id)
if tile is None:
return
segments: list[SegmentConfig] = []
for row in range(self.segments_table.rowCount()):
segments.append(
SegmentConfig(
name=self._segment_text(row, "name"),
side=self._segment_text(row, "side"),
start_channel=int(self._segment_text(row, "start_channel")),
led_count=int(self._segment_text(row, "led_count")),
orientation_rad=float(self._segment_text(row, "orientation_rad")),
x0=float(self._segment_text(row, "x0")),
y0=float(self._segment_text(row, "y0")),
x1=float(self._segment_text(row, "x1")),
y1=float(self._segment_text(row, "y1")),
reverse=self._segment_check(row, "reverse"),
)
)
tile.segments = segments
def _scan_network(self) -> None:
self._sync_general_fields()
self._sync_tiles_table()
self._sync_segments_table()
self.discovered_devices = []
self._active_device_key = None
self._selected_assignment_tile_id = None
self._scan_worker = NetworkScanWorker(self.working_config)
self._scan_worker.progress.connect(self._on_scan_progress)
self._scan_worker.finished.connect(self._on_scan_finished)
self.scan_button.setEnabled(False)
self.scan_status_label.setText("Scanning local subnets for WLED devices...")
self.assignment_feedback_label.setText("Scanning the network. Results will appear below as devices respond.")
self._refresh_network_mapping_ui()
self._scan_worker.start()
def _on_scan_progress(self, completed: int, total: int, device: DiscoveredWledDevice | None) -> None:
self.scan_status_label.setText(f"Scanning {completed}/{total} hosts...")
if device is None:
return
if any(self._device_key(existing) == self._device_key(device) for existing in self.discovered_devices):
return
self.discovered_devices.append(device)
self.discovered_devices.sort(key=lambda item: tuple(int(part) for part in item.ip_address.split(".")))
if self._active_device() is None:
self._active_device_key = self._device_key(self.discovered_devices[0])
self._refresh_network_mapping_ui()
def _on_scan_finished(self, devices: list[DiscoveredWledDevice], error_message: str, host_count: int) -> None:
self.scan_button.setEnabled(True)
self._scan_worker = None
self._last_scan_host_count = host_count
if error_message:
self.scan_status_label.setText("Network scan failed.")
self.assignment_feedback_label.setText(error_message)
QMessageBox.warning(self, "Network Scan Error", error_message)
return
self.discovered_devices = list(devices)
if self._active_device() is None and self.discovered_devices:
self._active_device_key = self._device_key(self.discovered_devices[0])
if not self.discovered_devices:
self.scan_status_label.setText(f"No WLED devices found after scanning {host_count} hosts.")
self.assignment_feedback_label.setText("No WLED devices were detected. Check the subnet, power, and network connectivity.")
else:
self.scan_status_label.setText(f"Found {len(self.discovered_devices)} WLED device(s) across {host_count} hosts.")
self._refresh_network_mapping_ui()
def _on_discovered_device_selection_changed(self) -> None:
if self._device_table_refreshing:
return
row = self.discovered_devices_table.currentRow()
if row < 0:
return
item = self.discovered_devices_table.item(row, 0)
if item is None:
return
device_key = item.data(Qt.UserRole)
if isinstance(device_key, str):
self._select_device_for_assignment(device_key, select_table=False)
def _select_device_for_assignment(self, device_key: str, *, select_table: bool = True) -> None:
self._active_device_key = device_key
active_device = self._active_device()
mapped_tile = find_tile_for_device(self.working_config, active_device) if active_device is not None else None
self._selected_assignment_tile_id = mapped_tile.tile_id if mapped_tile is not None else self._selected_assignment_tile_id
if select_table:
self._select_device_row(device_key)
self._refresh_network_mapping_ui()
def _select_device_row(self, device_key: str) -> None:
for row in range(self.discovered_devices_table.rowCount()):
item = self.discovered_devices_table.item(row, 0)
if item is None:
continue
if item.data(Qt.UserRole) == device_key:
self.discovered_devices_table.selectRow(row)
return
def _identify_device(self, device_key: str) -> None:
device = self._device_for_key(device_key)
if device is None:
return
self._select_device_for_assignment(device_key)
worker = DeviceIdentifyWorker(device)
self._identify_workers.add(worker)
worker.finished.connect(self._on_identify_finished)
self.assignment_feedback_label.setText(
f"Identifying {device.ip_address}. Watch for the red pulse, then click the matching tile."
)
worker.start()
def _on_identify_finished(self, device: DiscoveredWledDevice, error_message: str) -> None:
for worker in list(self._identify_workers):
if worker._device == device:
self._identify_workers.discard(worker)
break
if error_message:
self.assignment_feedback_label.setText(error_message)
QMessageBox.warning(self, "Identify Failed", error_message)
else:
mapped_tile = find_tile_for_device(self.working_config, device)
if mapped_tile is not None:
self._selected_assignment_tile_id = mapped_tile.tile_id
self.assignment_feedback_label.setText(
f"{device.ip_address} pulsed successfully. Click the matching tile to store the assignment."
)
self._refresh_network_mapping_ui()
def _handle_mapping_preview_click(self, tile_id: str) -> None:
self._selected_assignment_tile_id = tile_id
active_device = self._active_device()
if active_device is None:
self.assignment_feedback_label.setText(f"{tile_id} selected. Choose a device row or press Identify first.")
self._refresh_network_mapping_ui()
return
self._assign_active_device_to_tile(active_device, tile_id)
def _assign_active_device_to_tile(self, device: DiscoveredWledDevice, tile_id: str) -> None:
tile_lookup = self.working_config.tile_lookup()
target_tile = tile_lookup.get(tile_id)
if target_tile is None:
self.assignment_feedback_label.setText(f"Tile {tile_id} no longer exists in the current mapping.")
return
previous_tile = find_tile_for_device(self.working_config, device)
displaced_tile_text = ""
if target_tile.controller_ip or target_tile.controller_mac:
if not tile_matches_device(target_tile, device):
displaced_tile_text = target_tile.controller_ip or target_tile.controller_name or target_tile.controller_mac
assign_device_to_tile(self.working_config, device, tile_id)
self._selected_assignment_tile_id = tile_id
self._populate_tiles_table()
if not self.raw_editor.document().isModified():
self._set_raw_xml_snapshot()
persisted, persistence_message = self._persist_working_mapping()
summary_parts = [f"{device.ip_address} -> {tile_id}"]
if previous_tile is not None and previous_tile.tile_id != tile_id:
summary_parts.append(f"moved from {previous_tile.tile_id}")
if displaced_tile_text:
summary_parts.append(f"replaced {displaced_tile_text}")
summary = ", ".join(summary_parts)
if persisted:
self.assignment_feedback_label.setText(f"Saved {summary}. {persistence_message}")
else:
self.assignment_feedback_label.setText(f"Stored {summary} in the dialog. {persistence_message}")
QMessageBox.warning(self, "Assignment Not Saved Yet", persistence_message)
self._refresh_network_mapping_ui()
self._select_next_unmapped_device()
def _persist_working_mapping(self) -> tuple[bool, str]:
if self.raw_editor.document().isModified():
return (
False,
"Raw XML has unapplied changes. Apply or discard those edits before assisted assignments can be written to disk.",
)
target_path = None
if self.controller is not None and self.controller.mapping_path is not None:
target_path = self.controller.mapping_path
elif self.working_config.file_path is not None:
target_path = self.working_config.file_path
if target_path is None:
return False, "No mapping file is currently open. Use Save or Save As after closing Mapping Settings."
validation = validate_config(self.working_config)
if not validation.is_valid:
return False, "The mapping is currently invalid and could not be saved: " + "; ".join(validation.errors)
try:
save_config(self.working_config, target_path)
except (MappingValidationError, OSError, ValueError) as exc:
if isinstance(exc, MappingValidationError):
return False, "The mapping could not be saved: " + "; ".join(exc.errors)
return False, f"The mapping file could not be written: {exc}"
if self.controller is not None:
self.controller.replace_config(self.working_config.clone(), path=target_path)
return True, f"Wrote {target_path.name}."
def _select_next_unmapped_device(self) -> None:
for device in self.discovered_devices:
if find_tile_for_device(self.working_config, device) is None:
self._select_device_for_assignment(self._device_key(device))
return
self._refresh_network_mapping_ui()
def _refresh_network_mapping_ui(self) -> None:
active_device = self._active_device()
mapped_count = sum(1 for device in self.discovered_devices if find_tile_for_device(self.working_config, device) is not None)
stale_tile_count = sum(
1
for tile in self.working_config.sorted_tiles()
if (tile.controller_ip.strip() or tile.controller_mac.strip())
and not any(tile_matches_device(tile, device) for device in self.discovered_devices)
)
unmapped_count = max(0, len(self.discovered_devices) - mapped_count)
if not self.discovered_devices:
summary = "No devices discovered yet."
else:
summary = f"{len(self.discovered_devices)} discovered | {mapped_count} mapped | {unmapped_count} unmapped"
if stale_tile_count:
summary += f" | {stale_tile_count} saved assignment(s) not seen in this scan"
self.mapping_summary_label.setText(summary)
if active_device is None:
self.assignment_workflow_label.setText("Select a device row or press Identify, then click the matching tile.")
else:
mapped_tile = find_tile_for_device(self.working_config, active_device)
if mapped_tile is None:
self.assignment_workflow_label.setText(
f"Active device: {active_device.ip_address}. Click the physical tile that just flashed."
)
else:
self.assignment_workflow_label.setText(
f"Active device: {active_device.ip_address} is currently mapped to {mapped_tile.tile_id}. "
"Click a different tile to reassign it."
)
self.mapping_preview.set_assignment_state(
self.working_config,
self.discovered_devices,
active_device=active_device,
selected_tile_id=self._selected_assignment_tile_id,
)
self._refresh_discovered_devices_table()
def _refresh_discovered_devices_table(self) -> None:
self._device_table_refreshing = True
try:
self.discovered_devices_table.setRowCount(len(self.discovered_devices))
for row, device in enumerate(self.discovered_devices):
mapped_tile = find_tile_for_device(self.working_config, device)
status = "Unmapped"
if mapped_tile is not None:
if mapped_tile.controller_ip.strip() and mapped_tile.controller_ip.strip() != device.ip_address:
status = f"Mapped to {mapped_tile.tile_id} (saved IP {mapped_tile.controller_ip})"
else:
status = f"Mapped to {mapped_tile.tile_id}"
values = [
device.ip_address,
device.hostname,
device.instance_name,
device.mac_address,
status,
]
device_key = self._device_key(device)
for column, value in enumerate(values):
item = QTableWidgetItem(value)
item.setData(Qt.UserRole, device_key)
if device_key == self._active_device_key:
item.setBackground(Qt.darkBlue)
item.setForeground(Qt.white)
elif mapped_tile is not None:
item.setBackground(Qt.darkGreen)
self.discovered_devices_table.setItem(row, column, item)
map_button = QPushButton("Map This")
map_button.clicked.connect(lambda _checked=False, key=device_key: self._select_device_for_assignment(key))
self.discovered_devices_table.setCellWidget(row, 5, map_button)
identify_button = QPushButton("Identify")
identify_button.clicked.connect(lambda _checked=False, key=device_key: self._identify_device(key))
self.discovered_devices_table.setCellWidget(row, 6, identify_button)
self.discovered_devices_table.resizeRowsToContents()
if self._active_device_key is not None:
self._select_device_row(self._active_device_key)
finally:
self._device_table_refreshing = False
def _device_for_key(self, device_key: str | None) -> DiscoveredWledDevice | None:
if not device_key:
return None
for device in self.discovered_devices:
if self._device_key(device) == device_key:
return device
return None
def _active_device(self) -> DiscoveredWledDevice | None:
return self._device_for_key(self._active_device_key)
def _device_key(self, device: DiscoveredWledDevice) -> str:
return device.mac_address or device.ip_address
def _text(self, row: int, key: str) -> str:
column = next(index for index, (field, _) in enumerate(self.TILE_COLUMNS) if field == key)
item = self.tiles_table.item(row, column)
return item.text().strip() if item else ""
def _segment_text(self, row: int, key: str) -> str:
column = next(index for index, (field, _) in enumerate(self.SEGMENT_COLUMNS) if field == key)
item = self.segments_table.item(row, column)
return item.text().strip() if item else ""
def _check(self, row: int, key: str) -> bool:
column = next(index for index, (field, _) in enumerate(self.TILE_COLUMNS) if field == key)
item = self.tiles_table.item(row, column)
return bool(item and item.checkState() == Qt.Checked)
def _segment_check(self, row: int, key: str) -> bool:
column = next(index for index, (field, _) in enumerate(self.SEGMENT_COLUMNS) if field == key)
item = self.segments_table.item(row, column)
return bool(item and item.checkState() == Qt.Checked)
def _save_and_accept(self) -> None:
try:
if self.raw_editor.document().isModified():
self.working_config = load_config_from_string(self.raw_editor.toPlainText(), validate=True)
else:
self._sync_tables_to_config()
result = validate_config(self.working_config)
if not result.is_valid:
raise MappingValidationError(result.errors)
except (MappingValidationError, ValueError) as exc:
errors = exc.errors if isinstance(exc, MappingValidationError) else [str(exc)]
self.error_list.clear()
for error in errors:
self.error_list.addItem(error)
self.tabs.setCurrentWidget(self.raw_tab)
QMessageBox.warning(self, "Validation Error", "Please resolve the validation errors before saving.")
return
self.result_config = self.working_config
self.accept()

189
app/ui/theme.py Normal file
View File

@@ -0,0 +1,189 @@
from __future__ import annotations
from app.qt_compat import QApplication, QColor, QFont, QPalette
def apply_dark_theme(app: QApplication) -> None:
app.setStyle("Fusion")
font = QFont(app.font())
if font.pointSizeF() < 11.5:
font.setPointSizeF(11.5)
if hasattr(font, "setFamilies"):
font.setFamilies(["Segoe UI Variable Text", "Segoe UI", "Bahnschrift"])
else:
font.setFamily("Segoe UI")
app.setFont(font)
palette = QPalette()
palette.setColor(QPalette.Window, QColor("#1E1E1E"))
palette.setColor(QPalette.WindowText, QColor("#CCCCCC"))
palette.setColor(QPalette.Base, QColor("#252526"))
palette.setColor(QPalette.AlternateBase, QColor("#2D2D30"))
palette.setColor(QPalette.ToolTipBase, QColor("#252526"))
palette.setColor(QPalette.ToolTipText, QColor("#CCCCCC"))
palette.setColor(QPalette.Text, QColor("#CCCCCC"))
palette.setColor(QPalette.Button, QColor("#2D2D30"))
palette.setColor(QPalette.ButtonText, QColor("#CCCCCC"))
palette.setColor(QPalette.Highlight, QColor("#007ACC"))
palette.setColor(QPalette.HighlightedText, QColor("#FFFFFF"))
palette.setColor(QPalette.BrightText, QColor("#FFFFFF"))
palette.setColor(QPalette.PlaceholderText, QColor("#7A7A7A"))
app.setPalette(palette)
app.setStyleSheet(
"""
QWidget {
background: #1E1E1E;
color: #CCCCCC;
}
QMainWindow, QDialog {
background: #1E1E1E;
}
QToolBar {
background: #2D2D30;
border: none;
border-bottom: 1px solid #3C3C3C;
spacing: 4px;
padding: 4px 6px;
}
QToolButton {
background: transparent;
color: #CCCCCC;
border: 1px solid transparent;
border-radius: 3px;
padding: 6px 10px;
margin: 0 1px;
}
QToolButton:hover {
background: #37373D;
border-color: #37373D;
}
QToolButton:pressed, QToolButton:checked {
background: #094771;
border-color: #007ACC;
color: #FFFFFF;
}
QStatusBar {
background: #007ACC;
color: #FFFFFF;
}
QStatusBar QLabel {
background: transparent;
color: #FFFFFF;
padding: 0 4px;
}
QWidget#sectionHeader {
background: transparent;
border: none;
}
QLabel#sectionHeaderLabel {
background: transparent;
color: #CCCCCC;
font-size: 26px;
font-weight: 600;
padding: 0;
margin: 0;
}
QWidget#sectionBody {
background: #252526;
border: 1px solid #3C3C3C;
border-radius: 0;
}
QGroupBox {
background: #252526;
border: 1px solid #3C3C3C;
border-radius: 0;
margin-top: 30px;
font-weight: 600;
padding: 10px 12px 12px 12px;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
top: 7px;
left: 12px;
background: transparent;
padding: 0 0 0 0;
color: #CCCCCC;
font-size: 12px;
}
QLabel {
background: transparent;
border: none;
padding: 0;
}
QPushButton, QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QPlainTextEdit, QListWidget, QTableWidget {
background: #1F1F1F;
border: 1px solid #3C3C3C;
border-radius: 3px;
padding: 7px 10px;
selection-background-color: #094771;
selection-color: #FFFFFF;
}
QPushButton:hover, QComboBox:hover, QLineEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover {
border-color: #007ACC;
}
QPushButton {
background: #2D2D30;
min-height: 32px;
}
QPushButton:checked {
background: #094771;
color: #FFFFFF;
border-color: #007ACC;
}
QComboBox::drop-down {
border: none;
width: 24px;
}
QListWidget::item {
padding: 6px 8px;
border-radius: 2px;
margin: 1px 0;
}
QListWidget::item:selected {
background: #094771;
border: 1px solid #007ACC;
}
QHeaderView::section {
background: #2D2D30;
color: #CCCCCC;
border: none;
border-right: 1px solid #3C3C3C;
padding: 6px 8px;
}
QTabWidget::pane {
border: 1px solid #3C3C3C;
border-radius: 0;
top: -1px;
}
QTabBar::tab {
background: #2D2D30;
border: 1px solid #3C3C3C;
border-bottom: none;
padding: 8px 14px;
margin-right: 2px;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
QTabBar::tab:selected {
background: #1E1E1E;
color: #FFFFFF;
}
QScrollBar:vertical {
background: #252526;
width: 12px;
}
QScrollBar::handle:vertical {
background: #424242;
border-radius: 6px;
min-height: 30px;
}
QScrollBar::handle:vertical:hover {
background: #4F4F4F;
}
QSplitter::handle {
background: #2D2D30;
}
"""
)