"""Controller holding and managing Hubspace resources of type `light`."""
from contextlib import suppress
import copy
import logging
from aioafero import device, errors
from aioafero.device import AferoDevice, AferoState
from aioafero.util import process_range
from aioafero.v1.models import features
from aioafero.v1.models.light import Light, LightPut
from aioafero.v1.models.resource import DeviceInformation, ResourceTypes
from .base import AferoBinarySensor, AferoSensor, BaseResourcesController, NumbersName
from .event import CallbackResponse
SPLIT_IDENTIFIER: str = "light"
logger = logging.getLogger(__name__)
[docs]
def process_names(values: list[dict]) -> set[str]:
"""Extract unique names from the elements."""
vals = set()
for val in values:
vals.add(val["name"])
return vals
[docs]
def generate_split_name(afero_device: AferoDevice, instance: str) -> str:
"""Generate the name for an instanced element."""
return f"{afero_device.id}-{SPLIT_IDENTIFIER}-{instance}"
def _dual_channel_brightness_instances(afero_dev: AferoDevice) -> set[str]:
"""Collect non-primary brightness zone names from states, functions, and capabilities."""
instances: set[str] = set()
for state in afero_dev.states:
if state.functionClass == "brightness" and state.functionInstance not in (
None,
"global",
"primary",
):
instances.add(state.functionInstance)
for func in afero_dev.functions or []:
instance = func.get("functionInstance")
if func.get("functionClass") == "brightness" and instance not in (
None,
"global",
"primary",
):
instances.add(instance)
for cap in getattr(afero_dev, "capabilities", None) or []:
if cap.functionClass == "brightness" and cap.functionInstance not in (
None,
"global",
"primary",
):
instances.add(cap.functionInstance)
return instances
[docs]
def is_dual_channel_rgb_fixture(afero_dev: AferoDevice) -> bool:
"""Return True for one fixture with separate RGB and warm-white LED drivers."""
instances = _dual_channel_brightness_instances(afero_dev)
return "color" in instances and "white" in instances
[docs]
def preferred_brightness_instance(afero_dev: AferoDevice) -> str | None:
"""Return the functionInstance used for overall brightness on dual-channel lights."""
instances = [
state.functionInstance
for state in afero_dev.states
if state.functionClass == "brightness"
]
if "primary" in instances:
return "primary"
if None in instances:
return None
return instances[-1] if instances else None
[docs]
def should_use_brightness_state(afero_dev: AferoDevice, state: AferoState) -> bool:
"""Return whether a brightness state should populate the single light model."""
if state.functionClass != "brightness":
return True
if afero_dev.split_identifier or not is_dual_channel_rgb_fixture(afero_dev):
return True
return state.functionInstance == preferred_brightness_instance(afero_dev)
[docs]
def is_dual_channel_light(cur_item: Light) -> bool:
"""Return True when a light exposes separate color and white brightness controls."""
return cur_item.is_dual_channel
[docs]
def resolve_brightness_instance(
cur_item: Light,
*,
color_mode: str | None = None,
color: tuple[int, int, int] | None = None,
temperature: int | None = None,
effect: str | None = None,
) -> str | None:
"""Return the brightness functionInstance to PUT for dual-channel fixtures."""
if not is_dual_channel_light(cur_item):
return cur_item.dimming.func_instance if cur_item.dimming else None
if color is not None or effect is not None or color_mode in ("color", "sequence"):
return "color"
if temperature is not None or color_mode == "white":
return "white"
active_mode = color_mode
if active_mode is None and cur_item.color_mode is not None:
active_mode = cur_item.color_mode.mode
if active_mode in ("color", "sequence"):
return "color"
if active_mode == "white":
return "white"
return cur_item.dimming.func_instance if cur_item.dimming else "primary"
[docs]
def get_split_instances(afero_dev: AferoDevice) -> list[tuple[str, ResourceTypes]]:
"""Determine available instances from the states."""
instances = set()
lights = []
toggles = []
dual_channel = is_dual_channel_rgb_fixture(afero_dev)
for state in afero_dev.states:
# We do not want to add something that controls everything, but individual only
# We should skip None as its typically a single instance
if state.functionInstance in ["global", "primary", None]:
continue
if state.functionClass == "brightness":
lights.append(state.functionInstance)
elif state.functionClass == "toggle":
toggles.append(state.functionInstance)
# Dual-channel RGB+WW fixtures stay one HA light; only split true multi-zone lights.
if len(lights) > 1 and not dual_channel:
for light_instance in lights:
instances.add((light_instance, ResourceTypes.LIGHT))
for toggle_instance in toggles:
if toggle_instance in [x[0] for x in instances]:
continue
if dual_channel and toggle_instance in ("color", "white"):
continue
instances.add((toggle_instance, ResourceTypes.SWITCH))
return sorted(instances)
[docs]
def state_belongs_to_light_instance(
afero_dev: AferoDevice, state: AferoState, instance: str
) -> bool:
"""Return whether a state belongs to a light zone instance."""
if state.functionClass == "available":
return True
if state.functionInstance in (None, "global", "primary"):
return False
return state.functionInstance == instance
[docs]
def state_matches_instance(afero_device: AferoDevice, state: AferoState) -> bool:
"""Return whether a state belongs to a split light instance."""
if not afero_device.split_identifier:
return True
instance = afero_device.id.rsplit(f"-{afero_device.split_identifier}-", 1)[1]
return state_belongs_to_light_instance(afero_device, state, instance)
[docs]
def resolve_function_instance(afero_device: AferoDevice) -> str | None:
"""Return the functionInstance key for this light resource (split zone or single)."""
if afero_device.split_identifier:
return afero_device.id.rsplit(f"-{afero_device.split_identifier}-", 1)[1]
for state in afero_device.states:
if state.functionClass == "color-mode":
return state.functionInstance
return None
[docs]
def get_color_modes_for_device(afero_device: AferoDevice) -> list[str]:
"""Return supported color-mode names for this zone's color-mode function."""
instance = resolve_function_instance(afero_device)
for function in afero_device.functions:
if function.get("functionClass") != "color-mode":
continue
if function.get("functionInstance") == instance:
return [value["name"] for value in function.get("values", [])]
for function in afero_device.functions:
if function.get("functionClass") == "color-mode":
return [value["name"] for value in function.get("values", [])]
return []
[docs]
def get_valid_states(afero_dev: AferoDevice, instance: str) -> list:
"""Find states associated with the specific instance."""
return [
state
for state in afero_dev.states
if state_belongs_to_light_instance(afero_dev, state, instance)
]
[docs]
def apply_brightness_state_update(
afero_device: AferoDevice,
cur_item: Light,
state: AferoState,
updated_keys: set[str],
) -> None:
"""Apply inbound brightness state to a light model."""
if cur_item.dual_channel and state.functionInstance == "color":
new_val = int(state.value)
if cur_item.color_brightness != new_val:
cur_item.color_brightness = new_val
updated_keys.add("color_brightness")
elif cur_item.dual_channel and state.functionInstance == "white":
new_val = int(state.value)
if cur_item.white_brightness != new_val:
cur_item.white_brightness = new_val
updated_keys.add("white_brightness")
if not should_use_brightness_state(afero_device, state):
return
new_val = int(state.value)
dimming_changed = False
if cur_item.dimming.brightness != new_val:
cur_item.dimming.brightness = new_val
dimming_changed = True
if cur_item.dimming.func_instance != state.functionInstance:
cur_item.dimming.func_instance = state.functionInstance
dimming_changed = True
if dimming_changed:
updated_keys.add("dimming")
[docs]
def light_callback(afero_device: AferoDevice) -> CallbackResponse:
"""Convert an AferoDevice into multiple devices."""
multi_devs: list[AferoDevice] = []
instances: list[tuple[str, ResourceTypes]] = []
remove_parent: bool = False
instances = get_split_instances(afero_device)
logger.debug("Light instances found: %s", instances)
light_instances = [x[0] for x in instances if x[1] == ResourceTypes.LIGHT]
children: list[str] = []
if afero_device.device_class == ResourceTypes.LIGHT.value:
for instance, resource_type in instances:
instance_name = instance or "primary"
cloned = copy.deepcopy(afero_device)
cloned.device_class = resource_type.value
cloned.id = generate_split_name(afero_device, instance)
cloned.split_identifier = SPLIT_IDENTIFIER
cloned.friendly_name = f"{afero_device.friendly_name} - {instance_name}"
cloned.states = get_valid_states(afero_device, instance)
cloned.children = []
multi_devs.append(cloned)
children.append(cloned.id)
if len(light_instances) > 1 and None not in light_instances:
remove_parent = True
cloned = copy.deepcopy(afero_device)
valid_states = [
state
for state in afero_device.states
if state.functionClass
in [
"available",
"wifi-ssid",
"wifi-rssi",
"wifi-steady-state",
"wifi-setup-state",
"wifi-mac-address",
"ble-mac-address",
]
]
cloned.states = valid_states
cloned.children = children
cloned.device_class = "parent-device"
multi_devs.append(cloned)
return CallbackResponse(
split_devices=multi_devs,
remove_original=remove_parent,
)
[docs]
class LightController(BaseResourcesController[Light]):
"""Light devices on ``bridge.lights`` (including split zones)."""
ITEM_TYPE_ID = ResourceTypes.DEVICE
ITEM_TYPES = [ResourceTypes.LIGHT]
ITEM_CLS = Light
ITEM_MAPPING = {
"color": "color-rgb",
"color_mode": "color-mode",
"color_temperature": "color-temperature",
"dimming": "brightness",
"effect": "color-sequence",
}
ITEM_NUMBERS: dict[tuple[str, str | None], NumbersName] = {
("speed", "color-sequence"): NumbersName(unit="speed"),
}
# Split Lights from the primary payload
DEVICE_SPLIT_CALLBACKS: dict[str, callable] = {
ResourceTypes.LIGHT.value: light_callback
}
[docs]
async def turn_on(self, device_id: str) -> None:
"""Turn on the light.
Args:
device_id: Device ID from this controller.
"""
await self.set_state(device_id, on=True)
[docs]
async def turn_off(self, device_id: str) -> None:
"""Turn off the light.
Args:
device_id: Device ID from this controller.
"""
await self.set_state(device_id, on=False)
[docs]
async def set_color_temperature(self, device_id: str, temperature: int) -> None:
"""Set color temperature or white mode when CCT is unavailable.
Args:
device_id: Device ID from this controller.
temperature: Color temperature in kelvin.
"""
try:
cur_item = self.get_device(device_id)
except errors.DeviceNotFound:
self._logger.info("Unable to find device %s", device_id)
return
if cur_item.color_temperature is not None:
await self.set_state(
device_id, on=True, temperature=temperature, color_mode="white"
)
elif cur_item.supports_color_white:
self._logger.info(
"Device %s has no color-temperature function; ignoring %d K",
device_id,
temperature,
)
await self.set_white(device_id, on=True)
else:
self._logger.info(
"Device %s does not support color temperature or white mode", device_id
)
[docs]
async def set_white(
self,
device_id: str,
*,
on: bool | None = True,
brightness: int | None = None,
) -> None:
"""Set white mode without a color-temperature function.
Args:
device_id: Device ID from this controller.
on: Power state (defaults to ``True``).
brightness: Brightness percentage when ``on`` is ``True``.
"""
await self.set_state(
device_id, on=on, color_mode="white", brightness=brightness
)
[docs]
async def set_brightness(self, device_id: str, brightness: int) -> None:
"""Set brightness, turning the light on if needed.
Args:
device_id: Device ID from this controller.
brightness: Brightness percentage.
"""
await self.set_state(device_id, on=True, brightness=brightness)
[docs]
async def set_rgb(self, device_id: str, red: int, green: int, blue: int) -> None:
"""Set RGB color, turning the light on if needed.
Args:
device_id: Device ID from this controller.
red: Red channel ``0``–``255``.
green: Green channel ``0``–``255``.
blue: Blue channel ``0``–``255``.
"""
await self.set_state(
device_id, on=True, color=(red, green, blue), color_mode="color"
)
[docs]
async def set_effect(self, device_id: str, effect: str) -> None:
"""Set a color sequence effect, turning the light on if needed.
Args:
device_id: Device ID from this controller.
effect: Effect name from the model's ``effect.effects`` list.
"""
await self.set_state(device_id, on=True, effect=effect, color_mode="sequence")
[docs]
async def initialize_elem(self, afero_device: AferoDevice) -> Light:
"""Initialize the element.
:param afero_device: Afero Device that contains the updated states
:return: Newly initialized resource
"""
available: bool = False
on: features.OnFeature | None = None
color_temp: features.ColorTemperatureFeature | None = None
color: features.ColorFeature | None = None
color_mode: features.ColorModeFeature | None = None
dimming: features.DimmingFeature | None = None
effect: features.EffectFeature | None = None
sensors: dict[str, AferoSensor] = {}
binary_sensors: dict[str, AferoBinarySensor] = {}
numbers: dict[tuple[str, str], features.NumbersFeature] = {}
dual_channel = is_dual_channel_rgb_fixture(afero_device)
color_brightness: int | None = None
white_brightness: int | None = None
for state in afero_device.states:
func_def = device.get_function_from_device(
afero_device.functions, state.functionClass, state.functionInstance
)
if state.functionClass == "power" or (
afero_device.split_identifier and state.functionClass == "toggle"
):
on = features.OnFeature(
on=state.value == "on",
func_class=state.functionClass,
func_instance=state.functionInstance,
)
elif state.functionClass == "color-temperature":
if len(func_def["values"]) > 1:
avail_temps = process_color_temps(func_def["values"])
else:
avail_temps = process_range(func_def["values"][0])
prefix = "K" if func_def.get("type", None) != "numeric" else ""
current_temp = state.value
if isinstance(current_temp, str) and current_temp.endswith("K"):
current_temp = current_temp[:-1]
color_temp = features.ColorTemperatureFeature(
temperature=int(current_temp), supported=avail_temps, prefix=prefix
)
elif state.functionClass == "brightness":
if dual_channel and state.functionInstance == "color":
color_brightness = int(state.value)
elif dual_channel and state.functionInstance == "white":
white_brightness = int(state.value)
if not should_use_brightness_state(afero_device, state):
continue
temp_bright = process_range(func_def["values"][0])
dimming = features.DimmingFeature(
brightness=int(state.value),
supported=temp_bright,
func_instance=state.functionInstance,
)
elif state.functionClass == "color-sequence":
current_effect = state.value
effects = process_effects(afero_device.functions)
effect = features.EffectFeature(effect=current_effect, effects=effects)
elif state.functionClass == "color-rgb":
color = features.ColorFeature(
red=state.value["color-rgb"].get("r", 0),
green=state.value["color-rgb"].get("g", 0),
blue=state.value["color-rgb"].get("b", 0),
)
elif state.functionClass == "color-mode":
color_mode = features.ColorModeFeature(state.value)
elif state.functionClass == "available":
available = state.value
if number := await self.initialize_number(func_def, state):
numbers[number[0]] = number[1]
supported_color_modes = get_color_modes_for_device(afero_device)
self._items[afero_device.id] = Light(
_id=afero_device.id,
available=available,
split_identifier=afero_device.split_identifier,
dual_channel=dual_channel,
color_brightness=color_brightness,
white_brightness=white_brightness,
sensors=sensors,
binary_sensors=binary_sensors,
device_information=DeviceInformation(
device_class=afero_device.device_class,
default_image=afero_device.default_image,
default_name=afero_device.default_name,
manufacturer=afero_device.manufacturerName,
model=afero_device.model,
name=afero_device.friendly_name,
parent_id=afero_device.device_id,
children=afero_device.children,
functions=afero_device.functions,
),
on=on,
dimming=dimming,
color_mode=color_mode,
color_temperature=color_temp,
color=color,
color_modes=supported_color_modes,
effect=effect,
numbers=numbers,
)
return self._items[afero_device.id]
[docs]
async def update_elem(self, afero_device: AferoDevice) -> set:
"""Update the Light with the latest API data.
:param afero_device: Afero Device that contains the updated states
:return: States that have been modified
"""
cur_item = self.get_device(afero_device.id)
updated_keys = set()
color_seq_states: dict[str, AferoState] = {}
for state in afero_device.states:
# Split clones are pre-filtered in light_callback; this guards other paths.
if not state_matches_instance(afero_device, state):
continue
if state.functionClass == "power" or (
afero_device.split_identifier and state.functionClass == "toggle"
):
new_val = state.value == "on"
if cur_item.on.on != new_val:
cur_item.on.on = new_val
updated_keys.add("on")
elif state.functionClass == "color-temperature":
if cur_item.color_temperature is None:
continue
current_temp = state.value
if isinstance(current_temp, str) and current_temp.endswith("K"):
current_temp = current_temp[:-1]
new_val = int(current_temp)
if cur_item.color_temperature.temperature != new_val:
cur_item.color_temperature.temperature = new_val
updated_keys.add("color_temperature")
elif state.functionClass == "brightness":
apply_brightness_state_update(
afero_device, cur_item, state, updated_keys
)
elif state.functionClass == "color-sequence":
color_seq_states[state.functionInstance] = state
elif state.functionClass == "color-rgb":
color_red = state.value["color-rgb"].get("r", 0)
color_green = state.value["color-rgb"].get("g", 0)
color_blue = state.value["color-rgb"].get("b", 0)
if (
cur_item.color.red != color_red
or cur_item.color.green != color_green
or cur_item.color.blue != color_blue
):
cur_item.color.red = color_red
cur_item.color.green = color_green
cur_item.color.blue = color_blue
updated_keys.add("color")
elif state.functionClass == "color-mode":
if cur_item.color_mode.mode != state.value:
cur_item.color_mode.mode = state.value
updated_keys.add("color_mode")
elif state.functionClass == "available":
if cur_item.available != state.value:
cur_item.available = state.value
updated_keys.add("available")
elif (update_key := await self.update_number(state, cur_item)) or (
update_key := await self.update_select(state, cur_item)
):
updated_keys.add(update_key)
# Several states hold the effect, but its always derived from the preset functionInstance
return updated_keys.union(
await self.update_elem_color(cur_item, color_seq_states)
)
[docs]
async def update_elem_color(self, cur_item: Light, color_seq_states: dict) -> set:
"""Perform the update for effects."""
updated_keys = set()
if color_seq_states and "preset" in color_seq_states:
preset_val = color_seq_states["preset"].value
if cur_item.effect.is_preset(preset_val):
if cur_item.effect.effect != preset_val:
cur_item.effect.effect = preset_val
updated_keys.add("effect")
else:
new_val = color_seq_states[color_seq_states["preset"].value].value
if cur_item.effect.effect != new_val:
cur_item.effect.effect = color_seq_states[
color_seq_states["preset"].value
].value
updated_keys.add("effect")
return updated_keys
[docs]
async def set_state(
self,
device_id: str,
on: bool | None = None,
temperature: int | None = None,
brightness: int | None = None,
color_mode: str | None = None,
color: tuple[int, int, int] | None = None,
effect: str | None = None,
force_white_mode: int | None = None,
numbers: dict[tuple[str, str], float] | None = None,
) -> None:
"""Update light state in the cloud.
Args:
device_id: Device ID from this controller.
on: Power state.
temperature: Color temperature in kelvin.
brightness: Brightness percentage.
color_mode: API color mode (``white``, ``color``, ``sequence``, etc.).
color: RGB tuple ``(red, green, blue)``.
effect: Named color sequence effect.
force_white_mode: Brightness to apply after forcing white mode.
numbers: Number features keyed by ``(functionClass, functionInstance)``.
"""
update_obj = LightPut()
try:
cur_item = self.get_device(device_id)
except errors.DeviceNotFound:
self._logger.info("Unable to find device %s", device_id)
return
if on is not None:
update_obj.on = features.OnFeature(
on=on,
func_class=cur_item.on.func_class,
func_instance=cur_item.on.func_instance,
)
send_duplicate_states = False
if force_white_mode is not None:
send_duplicate_states = True
update_obj.color_mode = features.ColorModeFeature(mode="white")
update_obj.dimming = features.DimmingFeature(
brightness=force_white_mode,
supported=cur_item.dimming.supported,
func_instance=cur_item.dimming.func_instance,
)
else:
if numbers:
for key, val in numbers.items():
if key not in cur_item.numbers:
continue
update_obj.numbers[key] = features.NumbersFeature(
value=val,
min=cur_item.numbers[key].min,
max=cur_item.numbers[key].max,
step=cur_item.numbers[key].step,
name=cur_item.numbers[key].name,
unit=cur_item.numbers[key].unit,
)
if temperature is not None and cur_item.color_temperature is not None:
adjusted_temp = min(
cur_item.color_temperature.supported,
key=lambda x: abs(x - temperature),
)
update_obj.color_temperature = features.ColorTemperatureFeature(
temperature=adjusted_temp,
supported=cur_item.color_temperature.supported,
prefix=cur_item.color_temperature.prefix,
)
if color_mode is None:
color_mode = "white"
elif temperature is not None and cur_item.supports_color_white:
if color_mode is None:
color_mode = "white"
if brightness is not None and cur_item.dimming is not None:
update_obj.dimming = features.DimmingFeature(
brightness=brightness,
supported=cur_item.dimming.supported,
func_instance=resolve_brightness_instance(
cur_item,
color_mode=color_mode,
color=color,
temperature=temperature,
effect=effect,
),
)
if color is not None and cur_item.color is not None:
update_obj.color = features.ColorFeature(
red=color[0], green=color[1], blue=color[2]
)
if color_mode is not None and cur_item.color_mode is not None:
update_obj.color_mode = features.ColorModeFeature(mode=color_mode)
# No-CCT zones that support white (e.g. accent trim with RGB) must always
# PUT color-mode white when requested. Skipping unchanged fields leaves the
# device in RGB when cache is stale or the user re-selects white in HA.
if (
color_mode == "white"
and cur_item.color_temperature is None
and cur_item.supports_color_white
):
send_duplicate_states = True
if effect is not None and cur_item.effect is not None:
update_obj.effect = features.EffectFeature(
effect=effect, effects=cur_item.effect.effects
)
await self.update(
device_id, obj_in=update_obj, send_duplicate_states=send_duplicate_states
)
[docs]
def process_color_temps(color_temps: dict) -> list[int]:
"""Determine the supported color temps.
:param color_temps: Result from functions["values"]
"""
supported_temps = []
for temp in color_temps:
color_temp = temp["name"]
if isinstance(color_temp, str) and color_temp.endswith("K"):
color_temp = color_temp[0:-1]
supported_temps.append(int(color_temp))
return sorted(supported_temps)
[docs]
def process_effects(functions: list[dict]) -> dict[str, set]:
"""Determine the supported effects."""
supported_effects = {}
for function in functions:
if function["functionClass"] == "color-sequence":
supported_effects[function["functionInstance"]] = set(
process_names(function["values"])
)
# custom shouldn't be a value in preset
with suppress(KeyError):
supported_effects["preset"].remove("custom")
return supported_effects