from __future__ import annotations from dataclasses import dataclass, field import itertools import numpy as np from scipy.spatial import ConvexHull, QhullError from ha_deconz_bridge.color import Color from ha_deconz_bridge.lights import ControllerModeName, VirtualLightSpec @dataclass(slots=True, frozen=True) class CompiledChannel: address: str controller_id: str mode_name: ControllerModeName role: str color: Color semantic_label: str | None order_index: int @dataclass(slots=True, frozen=True) class CompiledOption: key: str controller_modes: dict[str, ControllerModeName | None] dimmable_channels: tuple[CompiledChannel, ...] onoff_channels: tuple[CompiledChannel, ...] @dataclass(slots=True, frozen=True) class CompiledCombination: key: str controller_modes: dict[str, ControllerModeName | None] dimmable_channels: tuple[CompiledChannel, ...] onoff_channels: tuple[CompiledChannel, ...] matrix: np.ndarray base_xyz: np.ndarray xy_bounds: tuple[float, float, float, float] xy_hull_equations: np.ndarray output_channel_count: int total_available_lumens: float order_key: tuple[str, ...] @dataclass(slots=True) class CompiledLightModel: spec: VirtualLightSpec combinations: tuple[CompiledCombination, ...] reference_combinations: tuple[CompiledCombination, ...] chroma_tolerance: float target_cache_scale: int approximate_chroma_band: float = 0.025 reference_cache: dict[tuple[str, int, int], tuple[object, ...]] = field(default_factory=dict) max_brightness_cache: dict[tuple[str, int, int], float] = field(default_factory=dict) def compile_light_model( spec: VirtualLightSpec, *, chroma_tolerance: float = 1e-6, target_cache_scale: int = 10000, approximate_chroma_band: float = 0.025, ) -> CompiledLightModel: choice_groups: list[tuple[CompiledOption, ...]] = [] order_index = 0 for controller in spec.controllers.values(): options: list[CompiledOption] = [] if controller.supported_modes == {"onoff"}: options.append( CompiledOption( key=f"{controller.zigbee_id}:off", controller_modes={controller.zigbee_id: None}, dimmable_channels=(), onoff_channels=(), ) ) for mode in controller.modes.values(): dimmable_channels: list[CompiledChannel] = [] onoff_channels: list[CompiledChannel] = [] target_list = onoff_channels if mode.name == "onoff" else dimmable_channels for role, channel in mode.channels.items(): target_list.append( CompiledChannel( address=f"{controller.zigbee_id}.{mode.name}.{role}", controller_id=controller.zigbee_id, mode_name=mode.name, role=role, color=channel.color, semantic_label=channel.effective_label(mode.name), order_index=order_index, ) ) order_index += 1 options.append( CompiledOption( key=f"{controller.zigbee_id}:{mode.name}", controller_modes={controller.zigbee_id: mode.name}, dimmable_channels=tuple(dimmable_channels), onoff_channels=tuple(onoff_channels), ) ) choice_groups.append(tuple(options)) combinations: list[CompiledCombination] = [] for product in itertools.product(*choice_groups): controller_modes: dict[str, ControllerModeName | None] = {} dimmable_channels: list[CompiledChannel] = [] onoff_channels: list[CompiledChannel] = [] order_key: list[str] = [] for option in product: controller_modes.update(option.controller_modes) dimmable_channels.extend(option.dimmable_channels) onoff_channels.extend(option.onoff_channels) order_key.append(option.key) dimmable_channels.sort(key=lambda channel: channel.order_index) onoff_channels.sort(key=lambda channel: channel.order_index) if dimmable_channels: matrix = np.column_stack([channel.color.xyz for channel in dimmable_channels]) else: matrix = np.zeros((3, 0), dtype=float) if onoff_channels: base_xyz = Color.weighted_sum( [channel.color for channel in onoff_channels], [1.0] * len(onoff_channels), ).xyz else: base_xyz = np.zeros(3, dtype=float) all_channels = dimmable_channels + onoff_channels if all_channels: xs = [channel.color.xy[0] for channel in all_channels] ys = [channel.color.xy[1] for channel in all_channels] xy_bounds = (min(xs), max(xs), min(ys), max(ys)) else: xy_bounds = (0.0, 0.0, 0.0, 0.0) xy_hull_equations = _xy_hull_equations(all_channels, base_xyz) combinations.append( CompiledCombination( key="|".join(order_key), controller_modes=controller_modes, dimmable_channels=tuple(dimmable_channels), onoff_channels=tuple(onoff_channels), matrix=matrix, base_xyz=base_xyz, xy_bounds=xy_bounds, xy_hull_equations=xy_hull_equations, output_channel_count=len(all_channels), total_available_lumens=sum(channel.color.brightness for channel in all_channels), order_key=tuple(order_key), ) ) return CompiledLightModel( spec=spec, combinations=tuple(combinations), reference_combinations=_deduplicate_reference_combinations(combinations), chroma_tolerance=chroma_tolerance, target_cache_scale=max(1, int(target_cache_scale)), approximate_chroma_band=approximate_chroma_band, ) def _deduplicate_reference_combinations( combinations: list[CompiledCombination], ) -> tuple[CompiledCombination, ...]: seen: set[tuple[object, ...]] = set() unique: list[CompiledCombination] = [] for combination in combinations: key = _reference_signature(combination) if key in seen: continue seen.add(key) unique.append(combination) return tuple(unique) def _reference_signature(combination: CompiledCombination) -> tuple[object, ...]: dimmable = tuple( sorted( ( tuple(round(float(value), 8) for value in channel.color.xyz), channel.semantic_label, channel.mode_name, channel.role, ) for channel in combination.dimmable_channels ) ) onoff = tuple( sorted( ( tuple(round(float(value), 8) for value in channel.color.xyz), channel.semantic_label, channel.mode_name, channel.role, ) for channel in combination.onoff_channels ) ) return (dimmable, onoff) def _xy_hull_equations( channels: list[CompiledChannel], base_xyz: np.ndarray, ) -> np.ndarray: points = [channel.color.xy for channel in channels] if float(base_xyz[1]) > 0.0: base_sum = float(np.sum(base_xyz)) if base_sum > 0.0: points.append((float(base_xyz[0] / base_sum), float(base_xyz[1] / base_sum))) unique = sorted({(round(x, 10), round(y, 10)) for x, y in points}) if len(unique) < 3: return np.zeros((0, 3), dtype=float) try: hull = ConvexHull(np.asarray(unique, dtype=float)) except QhullError: return np.zeros((0, 3), dtype=float) return np.asarray(hull.equations, dtype=float)