from enum import IntEnum from .feature import Feature from typing import TYPE_CHECKING, Optional, Dict, Any, Union, Sequence if TYPE_CHECKING: from .box import Box # V3 onwards used while device is operating on 5 channels, refactor if new versions occurs ctx2_v3 = {"cct1": lambda x: f"{x}------", "cct2": lambda x: f"----{x}--"} ctx2 = {"cct1": lambda x: f"{x}----", "cct2": lambda x: f"----{x}"} mono = { "mono1": lambda x: f"{x}------", "mono2": lambda x: f"--{x}----", "mono3": lambda x: f"----{x}--", "mono4": lambda x: f"------{x}", } class BleboxColorMode(IntEnum): RGBW = 1 # RGB color-space with color brightness, white brightness RGB = 2 # RGB color-space with color brightness MONO = 3 RGBorW = 4 # RGBW entity, where white color is prioritised CT = 5 # color-temperature, brightness, effect CTx2 = 6 # color-temperature, brightness, effect, two instances RGBWW = 7 # RGB with two color-temperature sliders(warm, cold) @classmethod def invert(cls): return {item.value: item.name for item in cls} BLEBOX_COLOR_MODES = BleboxColorMode.invert() class Light(Feature): # TODO: better defaults? CURRENT_CONF = dict() CONFIG = { "wLightBox": { "default": "FFFFFFFF", "off": "00000000", "white?": True, "color?": True, "to_value": lambda int_value: f"{int_value:02x}", "validator": lambda product, alias, raw: product.expect_rgbw(alias, raw), }, "wLightBoxS": { "default": "FF", "off": "00", "white?": False, "color?": False, "to_value": lambda int_value: f"{int_value:02x}", "validator": lambda product, alias, raw: product.expect_hex_str( alias, raw, 255, 0 ), }, "dimmerBox": { "default": 0xFF, "off": 0x0, "white?": False, "color?": False, "to_value": lambda int_value: int_value, "validator": lambda product, alias, raw: product.expect_int( alias, raw, 255, 0 ), }, } COLOR_MODE_CONFIG = { "CT": { "default": "FFFFFFFF", "off": "0000", "white?": False, "color?": False, "to_value": lambda int_value: f"{int_value:02x}", "validator": lambda product, alias, raw: product.expect_hex_str( alias, raw, 255, 0 ), }, "CTx2": { "default": "FFFFFFFF", "off": "0000", "white?": False, "color?": False, "to_value": lambda int_value: f"{int_value:02x}", "validator": lambda product, alias, raw: product.expect_hex_str( alias, raw, 255, 0 ), }, "RGBWW": { "default": "FFFFFFFFFF", "off": "0000000000", "white?": True, "color?": True, "to_value": lambda int_value: f"{int_value:02x}", "validator": lambda product, alias, raw: product.expect_hex_str( alias, raw, 255, 0 ), }, } def __init__( self, product: "Box", alias: str, methods: dict, extended_state: Optional[Dict], mask: Any, desired_color, color_mode, effect_list, current_effect, index: Optional[int] = None, ) -> None: super().__init__(product, alias, methods) self._index = index config = self.CONFIG[product.type] self.mask = mask self.desired_color = desired_color self._color_mode = color_mode self._effect_list = effect_list if self._effect_list is not None: self._effect = current_effect if extended_state not in [None, {}]: self.extended_state = extended_state self.device_colorMode = color_mode if self.device_colorMode in [6, 7]: config = self.COLOR_MODE_CONFIG[ BLEBOX_COLOR_MODES[self.device_colorMode] ] else: if product.type == "dimmerBox": self.device_colorMode = BleboxColorMode.MONO self.CURRENT_CONF = config self._off_value = self.evaluate_off_value(config, desired_color) self._last_on_state = self._default_on_value = config["default"] @classmethod def many_from_config( cls, product, box_type_config, extended_state ) -> list["Light"]: if isinstance(extended_state, dict) and extended_state is not None: desired_color = extended_state.get("rgbw", {}).get("desiredColor") color_mode = extended_state.get("rgbw", {}).get("colorMode") current_effect = extended_state.get("rgbw", {}).get("effectID") effect_list = extended_state.get("rgbw", {}).get("effectsNames") else: desired_color = None color_mode = None current_effect = None effect_list = None alias, methods = box_type_config[0] const_kwargs = dict( methods=methods, extended_state=extended_state, desired_color=desired_color, color_mode=color_mode, current_effect=current_effect, effect_list=effect_list, ) if extended_state is not None and color_mode is not None: if BleboxColorMode(color_mode).name == "RGBW": if len(desired_color) == 10: def generate_mask(x): return f"{x}--" mask = generate_mask else: mask = None return [cls(product, alias=alias + "_RGBW", mask=mask, **const_kwargs)] if BleboxColorMode(color_mode).name == "RGB": return [cls(product, alias=alias + "_RGB", mask=None, **const_kwargs)] if BleboxColorMode(color_mode).name == "MONO": if len(desired_color) % 2 == 0: object_list = [] mono_item = list(mono.items()) for i in range(0, int(len(desired_color) / 2)): indicator, mask = mono_item[i] object_list.append( cls( product, alias=alias + "_" + indicator, mask=mask, index=i, **const_kwargs, ) ) return object_list if BleboxColorMode(color_mode).name == "RGBorW": return [ cls(product, alias=alias + "_RGBorW", mask=None, **const_kwargs) ] if BleboxColorMode(color_mode).name == "CT": mask = ctx2["cct1"] if len(desired_color) > 8: mask = ctx2_v3["cct1"] return [cls(product, alias=alias + "_cct", mask=mask, **const_kwargs)] if BleboxColorMode(color_mode).name == "CTx2": object_list = [] ct = ctx2 if len(desired_color) > 8: ct = ctx2_v3 for i, (indicator, mask) in enumerate(ct.items()): object_list.append( cls( product, alias=alias + "_" + indicator, mask=mask, index=i, **const_kwargs, ) ) return object_list if BleboxColorMode(color_mode).name == "RGBWW": return [ cls(product, alias=alias + "_RGBCCT", mask=None, **const_kwargs) ] if len(box_type_config) > 0: del const_kwargs["methods"] if "Brightness" in box_type_config[0][1].get("desired"): const_kwargs["color_mode"] = BleboxColorMode.MONO return [ cls(product, *args, mask=None, **const_kwargs) for args in box_type_config ] else: return [] @property def brightness(self) -> Optional[int]: if self.color_mode in [6, 5]: _, bgt = self.color_temp_brightness_int_from_hex(self._desired) return bgt elif self.color_mode is not None and ( rgb_list := self.rgb_hex_to_rgb_list(self.rgb_hex) ): return self.evaluate_brightness_from_rgb(rgb_list) else: return None @property def index(self) -> Optional[int]: return self._index @property def effect_list(self): if isinstance(self._effect_list, dict): return list(self._effect_list.values()) else: return [] @property def color_temp(self): ct, _ = self.color_temp_brightness_int_from_hex(self._desired) return ct @staticmethod def evaluate_brightness_from_rgb(iterable: Sequence[int]) -> int: "return brightness from 0 to 255 evaluated basing rgb" if max(iterable) > 255: raise ValueError( f"evaluate_brightness_from_rgb values out of range, max is {max(iterable)}." ) elif min(iterable) < 0: raise ValueError( f"evaluate_brightness_from_rgb values out of range, min is {min(iterable)}." ) return int(max(iterable)) @staticmethod def swap_ww_cw(value: list) -> list: """Swap WW and CW channels in RGBWW mode for API compatibility.""" if len(value) == 5: value = list(value) value[3], value[4] = value[4], value[3] return value @staticmethod def scale_rgbww_brightness(value: list, brightness: int) -> list: """Scale RGBWW value preserving WW:CW ratio.""" current_max = max(value) if current_max > 0: return [round(x * brightness / current_max) for x in value] else: return [0, 0, 0, 0, brightness] def apply_brightness(self, value: int, brightness: int) -> Any: """Return list of values with applied brightness.""" if not isinstance(brightness, int): raise ValueError( f"adjust_brightness called with bad parameter ({brightness} is {type(brightness)} instead of int)" ) if brightness > 255: raise ValueError( f"adjust_brightness called with bad parameter ({brightness} is greater than 255)" ) if self.product.type == "dimmerBox" or self.color_mode == BleboxColorMode.MONO: return [brightness] if brightness == 0: return [value] if self.color_mode in ( BleboxColorMode.RGB, BleboxColorMode.RGBW, BleboxColorMode.RGBorW, ): value = self.normalise_elements_of_rgb(list(value)) res = list(map(lambda x: round(x * (brightness / 255)), value)) elif self.color_mode == BleboxColorMode.RGBWW: res = self.scale_rgbww_brightness(value, brightness) else: res = list(map(lambda x: round(x * (brightness / 255)), value)) return res def evaluate_off_value(self, config: dict, raw_hex: str) -> str: """ Return hex representing off state value without mask formatting for necessary channels if mask is applied. If no mask applied than return default from config :param config: :param raw_hex: :return: str """ if self.mask: placeholder = self._get_placeholder_for_color_mode() mask_result = self.mask(placeholder) x_count = mask_result.count("x") return "0" * x_count elif raw_hex is not None: if len(raw_hex) < len(config["off"]): return config["off"][: len(raw_hex)] return config["off"] @property def supports_white(self) -> Any: return self.CURRENT_CONF["white?"] @property def white_value(self) -> Optional[int]: return self._white_value def apply_white(self, value: str, white: int) -> Union[int, str]: if white is None: return value if not self.supports_white: return value rgbhex = value[0:6] white_raw = f"{white:02x}" return f"{rgbhex}{white_raw}" @property def supports_color(self) -> Any: return self.CURRENT_CONF["color?"] @property def color_mode(self) -> int: return self._color_mode def apply_color(self, value: str, rgb_hex: str) -> Union[int, str]: if rgb_hex is None: return value if not self.supports_color: return value white_hex = value[6:8] return f"{rgb_hex}{white_hex}" def _get_placeholder_for_color_mode(self) -> str: if self._color_mode == BleboxColorMode.MONO: return "xx" elif self._color_mode in (BleboxColorMode.CT, BleboxColorMode.CTx2): return "xxxx" elif self._color_mode == BleboxColorMode.RGB: return "xxxxxx" elif self._color_mode in (BleboxColorMode.RGBW, BleboxColorMode.RGBorW): return "xxxxxxxx" else: return "x" def return_color_temp_with_brightness( self, value, brightness: Any ) -> Optional[str]: """Method returns value which will be send to""" if value < 128: warm = min(255, value * 2) cold = 255 else: warm = 255 cold = max(0, min(255, (255 - value) * 2)) cold = cold * brightness / 255 warm = warm * brightness / 255 cold = f"{int(round(cold)):02x}" warm = f"{int(round(warm)):02x}" return self.rgb_hex_to_rgb_list(warm + cold) def value_for_selected_channels_from_given_val(self, value: str): placeholder = self._get_placeholder_for_color_mode() lambda_result = self.mask(placeholder) first_index = lambda_result.index("x") last_index = lambda_result.rindex("x") return value[first_index : last_index + 1] @staticmethod def color_temp_brightness_int_from_hex(val) -> (int, int): """Assuming that hex is 2channels, 4characters. Return values for front end""" cold = int(val[2:], 16) warm = int(val[0:2], 16) if cold > warm: if warm == 0: return 0, cold else: return round(int((128 * (warm * 255 / cold) / 255)), 2), cold if cold < warm: if cold == 0: return 255, warm else: return round(int(255 - 128 * (cold * (255 / warm) / 255)), 2), warm else: return 128, max(cold, warm) @staticmethod def normalise_elements_of_rgb(elements): max_val = max(elements) min_val = min(elements) if 0 > max_val or max_val > 255: raise ValueError(f"Max value in normalisation was outside range {max_val}.") elif min_val < 0: raise ValueError(f"Min value in normalisation was outside range {min_val}.") elif max_val == 0: return [255] * len(elements) return list(map(lambda x: round(x * 255 / max_val), elements)) @property def is_on(self) -> Optional[bool]: return self._is_on @property def effect(self) -> Optional[str]: if isinstance(self._effect_list, dict): return self._effect_list.get(str(self._effect)) return self._effect def after_update(self) -> None: alias = self._alias product = self._product if product.last_data is None: self._desired_raw = None self._desired = None self._is_on = None self._effect = None if self.mask is None: self._white_value = None return self._effect = self.raw_value("currentEffect") raw = self._return_desired_value(alias, product) self._set_last_on_value(alias, product, raw) self._set_is_on() def _set_last_on_value(self, alias, product, raw): if raw == self._off_value: last_color = self.raw_value("last_color") if last_color is not None: raw = product.expect_rgbw(alias, last_color) if self.mask is not None: raw = self.value_for_selected_channels_from_given_val(raw) if raw == self._off_value: raw = self.value_for_selected_channels_from_given_val( "ffffffffff" ) else: if raw == self._off_value: raw = "f" * len(raw) else: raw = self._default_on_value if raw in (self._off_value, None): raise ValueError(raw) # TODO: store as custom value permanently (exposed by API consumer) self._last_on_state = raw def _set_is_on(self): self._is_on = (self._off_value != self._desired) or ( self._effect != 0 and self._effect is not None ) if isinstance(self._desired, str): if int(self._desired, 16) == 0: self._is_on = False def _return_desired_value(self, alias, product) -> str: """ Return value representing desired device state, set desired fields :param alias: :param product: :return desired value including mask: """ response_desired_val = self.raw_value("desired") if self.mask is not None: raw = self.value_for_selected_channels_from_given_val(response_desired_val) self._desired = self.CONFIG[self._product.type]["validator"]( product, alias, raw ) if self.color_mode in [1, 4]: self._white_value = int(raw[6:8], 16) else: raw = response_desired_val self._desired_raw = raw self._desired = self.CONFIG[self._product.type]["validator"]( product, alias, raw ) # type: ignore if self.color_mode in [1, 4]: self._white_value = int(raw[6:8], 16) return raw @property def sensible_on_value(self) -> Any: """Return sensible on value in hass format.""" if self.mask is not None: if int(self._last_on_state, 16) == 0: if self.color_mode in (BleboxColorMode.RGBW, BleboxColorMode.RGBorW): return 255, 255, 255, 255 if self.color_mode == BleboxColorMode.MONO: return [255] if self.color_mode in (BleboxColorMode.CT, BleboxColorMode.CTx2): return 255, 255 else: if self.color_mode in ( BleboxColorMode.MONO, BleboxColorMode.CT, BleboxColorMode.CTx2, ): return self.rgb_hex_to_rgb_list(self._last_on_state) return self.normalise_elements_of_rgb( self.rgb_hex_to_rgb_list(self._last_on_state) ) else: if isinstance(self._last_on_state, str): if int(self._last_on_state, 16) == 0: return [255] * len(self.rgb_hex_to_rgb_list(self._last_on_state)) else: if self.color_mode == BleboxColorMode.RGB: return self.rgb_hex_to_rgb_list(self._last_on_state[:6]) elif self.color_mode == BleboxColorMode.MONO: return self._last_on_state elif self.color_mode == BleboxColorMode.RGBWW: result = self.rgb_hex_to_rgb_list(self._last_on_state) return self.swap_ww_cw(result) else: return self.rgb_hex_to_rgb_list(self._last_on_state) else: return self._last_on_state @property def rgb_hex(self) -> Any: """Return hex str representing rgb.""" if isinstance(self._desired, int): return f"{self._desired:02x}" else: return self._desired @property def rgbw_hex(self) -> Any: return self._desired @property def rgbww_hex(self) -> Any: if len(self._desired) < 10: return None else: hex_str = self._desired hex_str_warm_cold = hex_str[6:] output_str = hex_str[0:6] + "".join( [ hex_str_warm_cold[i - 2 : i] for i in range(len(hex_str_warm_cold), 0, -2) ] ) return output_str @staticmethod def rgb_hex_to_rgb_list(hex_str) -> list[int]: """Return an RGB color value list from a hex color string.""" if hex_str is not None: return [int(hex_str[i : i + 2], 16) for i in range(0, len(hex_str), 2)] return [] @staticmethod def rgb_list_to_rgb_hex_list(rgb_list) -> hex: return [f"{i:02x}" for i in rgb_list] async def async_on(self, value: Any) -> None: if isinstance(value, (list, tuple)): if self.color_mode == BleboxColorMode.RGBWW: value.insert(3, value.pop()) value = "".join(self.rgb_list_to_rgb_hex_list(value)) if self.product.type == "dimmerBox": if not isinstance(value, int): value = int(value, 16) if not isinstance(value, type(self._off_value)): raise ValueError( f"turn_on called with bad parameter ({value} is {type(value)}, compared to {self._off_value} which is " f"{type(self._off_value)})" ) if value == self._off_value: raise ValueError(f"turn_on called with invalid value ({value})") if self.mask is not None: value = self.mask(value) await self.async_api_command("set", value) async def async_off(self) -> None: if self.raw_value("colorMode") in [5, 6]: await self.async_api_command("set", self.mask("0000")) elif self.raw_value("colorMode") == 3 and self.product.type != "dimmerBox": await self.async_api_command("set", self.mask("00")) else: await self.async_api_command("set", self._off_value)