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