diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index b182ed82..2f53377d 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -37,20 +37,34 @@ CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 CONF_SUNSET_TIME = "sunset_time" CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45 +CONF_ACCENT_COLOR, DEFAULT_ACCENT_COLOR = "accent_color", None +CONF_ACCENT_COLOR_RGB, DEFAULT_ACCENT_COLOR_RGB = "accent_color_rgb", None +CONF_ACCENT_COLOR_XY, DEFAULT_ACCENT_COLOR_XY = "accent_color_xy", None +CONF_ACCENT_COLOR_HS, DEFAULT_ACCENT_COLOR_HS = "accent_color_hs", None +CONF_ACCENT_COLOR_MIX_MIN, DEFAULT_ACCENT_COLOR_MIX_MIN = "accent_color_mix_min", -50 +CONF_ACCENT_COLOR_MIX_MAX, DEFAULT_ACCENT_COLOR_MIX_MAX = "accent_color_mix_max", 100 + +ATTR_SWITCH_ACCENT_COLOR = "accent_color" SLEEP_MODE_SWITCH = "sleep_mode_switch" ADAPT_COLOR_SWITCH = "adapt_color_switch" ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch" +ENABLE_ACCENT_COLOR_SWITCH = "enable_accent_color_switch" ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener" UNDO_UPDATE_LISTENER = "undo_update_listener" NONE_STR = "None" ATTR_ADAPT_COLOR = "adapt_color" ATTR_ADAPT_BRIGHTNESS = "adapt_brightness" +ATTR_USE_ACCENT_COLOR = "use_accent_color" SERVICE_SET_MANUAL_CONTROL = "set_manual_control" CONF_MANUAL_CONTROL = "manual_control" SERVICE_APPLY = "apply" CONF_TURN_ON_LIGHTS = "turn_on_lights" +SERVICE_SET_ACCENT_COLOR = "set_accent_color" +CONF_ACCENT_RGB = "rgb" +CONF_ACCENT_HS = "hs" +CONF_ACCENT_XY = "xy" TURNING_OFF_DELAY = 5 @@ -71,6 +85,11 @@ VALIDATION_TUPLES = [ (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), (CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP, int_between(1000, 10000)), (CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP, int_between(1000, 10000)), + (CONF_ACCENT_COLOR_RGB, DEFAULT_ACCENT_COLOR_RGB, vol.Any(None,[int])), + (CONF_ACCENT_COLOR_XY, DEFAULT_ACCENT_COLOR_XY, vol.Any(None,[float])), + (CONF_ACCENT_COLOR_HS, DEFAULT_ACCENT_COLOR_HS, vol.Any(None,[float])), + (CONF_ACCENT_COLOR_MIX_MIN, DEFAULT_ACCENT_COLOR_MIX_MIN, int_between(-100, 100)), + (CONF_ACCENT_COLOR_MIX_MAX, DEFAULT_ACCENT_COLOR_MIX_MAX, int_between(-100, 100)), (CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS, int_between(1, 100)), (CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP, int_between(1000, 10000)), (CONF_SUNRISE_TIME, NONE_STR, str), diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 7e023134..3822803b 100755 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -84,10 +84,14 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.sun import get_astral_location from homeassistant.util import slugify from homeassistant.util.color import ( + color_hs_to_RGB, color_RGB_to_xy, + color_RGB_to_xy_brightness, color_temperature_kelvin_to_mired, color_temperature_to_rgb, + color_xy_brightness_to_RGB, color_xy_to_hs, + color_xy_to_RGB, ) import homeassistant.util.dt as dt_util @@ -97,6 +101,17 @@ from .const import ( ATTR_ADAPT_BRIGHTNESS, ATTR_ADAPT_COLOR, ATTR_TURN_ON_OFF_LISTENER, + ATTR_USE_ACCENT_COLOR, + ATTR_SWITCH_ACCENT_COLOR, + CONF_ACCENT_COLOR, + CONF_ACCENT_COLOR_HS, + CONF_ACCENT_COLOR_MIX_MAX, + CONF_ACCENT_COLOR_MIX_MIN, + CONF_ACCENT_COLOR_RGB, + CONF_ACCENT_COLOR_XY, + CONF_ACCENT_HS, + CONF_ACCENT_RGB, + CONF_ACCENT_XY, CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, CONF_SLEEP_TRANSITION, @@ -121,8 +136,10 @@ from .const import ( CONF_TURN_ON_LIGHTS, DOMAIN, EXTRA_VALIDATION, + ENABLE_ACCENT_COLOR_SWITCH, ICON, SERVICE_APPLY, + SERVICE_SET_ACCENT_COLOR, SERVICE_SET_MANUAL_CONTROL, SLEEP_MODE_SWITCH, SUN_EVENT_MIDNIGHT, @@ -238,11 +255,30 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): data[CONF_TRANSITION], data[ATTR_ADAPT_BRIGHTNESS], data[ATTR_ADAPT_COLOR], + data[ATTR_USE_ACCENT_COLOR], data[CONF_PREFER_RGB_COLOR], force=True, context=switch.create_context("service", parent=service_call.context), ) +async def handle_set_accent_color(switch: AdaptiveSwitch, service_call: ServiceCall): + _LOGGER.debug( + "Called 'adaptive_lighting.set_accent_color' service with '%s'", + service_call.data, + ) + data = service_call.data + switch._sun_light_settings.set_accent_color( + get_rgb_from_any_form( + data[CONF_ACCENT_RGB] if CONF_ACCENT_RGB in data else None, + data[CONF_ACCENT_HS] if CONF_ACCENT_HS in data else None, + data[CONF_ACCENT_XY] if CONF_ACCENT_XY in data else None + ) + ) + if switch.is_on: + await switch._update_attrs_and_maybe_adapt_lights( + force=True, + context=switch.create_context("accent"), + ) async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: ServiceCall): """Set or unset lights as 'manually controlled'.""" @@ -304,6 +340,7 @@ async def async_setup_entry( sleep_mode_switch = SimpleSwitch("Sleep Mode", False, hass, config_entry) adapt_color_switch = SimpleSwitch("Adapt Color", True, hass, config_entry) adapt_brightness_switch = SimpleSwitch("Adapt Brightness", True, hass, config_entry) + accent_color_switch = SimpleSwitch("Enable Accent Color", False, hass, config_entry) switch = AdaptiveSwitch( hass, config_entry, @@ -311,15 +348,17 @@ async def async_setup_entry( sleep_mode_switch, adapt_color_switch, adapt_brightness_switch, + accent_color_switch, ) data[config_entry.entry_id][SLEEP_MODE_SWITCH] = sleep_mode_switch data[config_entry.entry_id][ADAPT_COLOR_SWITCH] = adapt_color_switch data[config_entry.entry_id][ADAPT_BRIGHTNESS_SWITCH] = adapt_brightness_switch + data[config_entry.entry_id][ENABLE_ACCENT_COLOR_SWITCH] = accent_color_switch data[config_entry.entry_id][SWITCH_DOMAIN] = switch async_add_entities( - [switch, sleep_mode_switch, adapt_color_switch, adapt_brightness_switch], + [switch, sleep_mode_switch, adapt_color_switch, adapt_brightness_switch, accent_color_switch], update_before_add=True, ) @@ -341,6 +380,16 @@ async def async_setup_entry( handle_apply, ) + platform.async_register_entity_service( + SERVICE_SET_ACCENT_COLOR, + { + vol.Optional(CONF_ACCENT_RGB, default=None): vol.Any(None, [int]), + vol.Optional(CONF_ACCENT_HS, default=None): vol.Any(None, [float]), + vol.Optional(CONF_ACCENT_XY, default=None): vol.Any(None, [float]), + }, + handle_set_accent_color, + ) + platform.async_register_entity_service( SERVICE_SET_MANUAL_CONTROL, { @@ -362,8 +411,23 @@ def validate(config_entry: ConfigEntry): value = data.get(key) if value is not None: data[key] = validate_value(value) # Fix the types of the inputs + data[CONF_ACCENT_COLOR] = get_rgb_from_any_form( + config_entry.data[CONF_ACCENT_COLOR_RGB] if CONF_ACCENT_COLOR_RGB in data else None, + config_entry.data[CONF_ACCENT_COLOR_HS] if CONF_ACCENT_COLOR_HS in data else None, + config_entry.data[CONF_ACCENT_COLOR_XY] if CONF_ACCENT_COLOR_XY in data else None + ) return data +def get_rgb_from_any_form( + rgb: Tuple[float, float, float] = None, hs: Tuple[float, float] = None, xy: Tuple[float, float] = None +) -> Union[Tuple[float, float, float], None]: + if rgb is not None: + return rgb + if hs is not None: + return color_hs_to_RGB(hs[0], hs[1]) + if xy is not None: + return color_xy_to_RGB(xy[0], xy[1]) + return None def match_switch_state_event(event: Event, from_or_to_state: List[str]): """Match state event when either 'from_state' or 'to_state' matches.""" @@ -442,6 +506,14 @@ def color_difference_redmean( blue_term = (2 + (255 - r_hat) / 256) * delta_b ** 2 return math.sqrt(red_term + green_term + blue_term) +def interpolate_colors_xyb( + xyb1: Tuple[float, float, float], xyb: Tuple[float, float, float], weight: float +) -> Tuple[float, float, float]: + """Interpolate between two HSV colors.""" + x = xyb1[0] + (xyb[0] - xyb1[0]) * weight + y = xyb1[1] + (xyb[1] - xyb1[1]) * weight + b = xyb1[2] + (xyb[2] - xyb1[2]) * weight + return (x, y, b) def _attributes_have_changed( light: str, @@ -551,6 +623,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): sleep_mode_switch: SimpleSwitch, adapt_color_switch: SimpleSwitch, adapt_brightness_switch: SimpleSwitch, + accent_color_switch: SimpleSwitch, ): """Initialize the Adaptive Lighting switch.""" self.hass = hass @@ -558,6 +631,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self.sleep_mode_switch = sleep_mode_switch self.adapt_color_switch = adapt_color_switch self.adapt_brightness_switch = adapt_brightness_switch + self.accent_color_switch = accent_color_switch data = validate(config_entry) self._name = data[CONF_NAME] @@ -594,8 +668,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): sunset_offset=data[CONF_SUNSET_OFFSET], sunset_time=data[CONF_SUNSET_TIME], time_zone=self.hass.config.time_zone, + accent_color=data[CONF_ACCENT_COLOR], + accent_color_lower_bound=float(data[CONF_ACCENT_COLOR_MIX_MIN])/100.0, + accent_color_upper_bound=float(data[CONF_ACCENT_COLOR_MIX_MAX])/100.0, transition=data[CONF_TRANSITION], ) + self._sun_light_settings.set_accent_color(data[CONF_ACCENT_COLOR]) # Set other attributes self._icon = ICON @@ -651,6 +729,20 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) last_state = await self.async_get_last_state() is_new_entry = last_state is None # newly added to HA + if (not is_new_entry + and self._sun_light_settings.accent_color is None + and ATTR_SWITCH_ACCENT_COLOR in last_state.attributes + and last_state.attributes[ATTR_SWITCH_ACCENT_COLOR] is not None + and len(last_state.attributes[ATTR_SWITCH_ACCENT_COLOR]) == 3 + ): + # restore previous accent_color as the entry is available + # and the color is not set by the configuration + last_accent_color = last_state.attributes[ATTR_SWITCH_ACCENT_COLOR] + self._sun_light_settings.accent_color = ( + float(last_accent_color[0]), + float(last_accent_color[1]), + float(last_accent_color[2]), + ) if is_new_entry or last_state.state == STATE_ON: await self.async_turn_on(adapt_lights=not self._only_once) else: @@ -682,8 +774,13 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self.sleep_mode_switch.entity_id, self._sleep_mode_switch_state_event, ) + remove_accent_color = async_track_state_change_event( + self.hass, + self.accent_color_switch.entity_id, + self._accent_color_switch_state_event, + ) - self.remove_listeners.extend([remove_interval, remove_sleep]) + self.remove_listeners.extend([remove_interval, remove_sleep, remove_accent_color]) if self._lights: self._expand_light_groups() @@ -768,6 +865,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): transition: Optional[int] = None, adapt_brightness: Optional[bool] = None, adapt_color: Optional[bool] = None, + use_accent_color: Optional[bool] = None, prefer_rgb_color: Optional[bool] = None, force: bool = False, context: Optional[Context] = None, @@ -785,6 +883,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): adapt_brightness = self.adapt_brightness_switch.is_on if adapt_color is None: adapt_color = self.adapt_color_switch.is_on + if use_accent_color is None: + use_accent_color = self.accent_color_switch.is_on if prefer_rgb_color is None: prefer_rgb_color = self._prefer_rgb_color @@ -802,7 +902,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if ( "color_temp" in features and adapt_color - and not (prefer_rgb_color and "color" in features) + and not ((prefer_rgb_color or use_accent_color) and "color" in features) ): attributes = self.hass.states.get(light).attributes min_mireds, max_mireds = attributes["min_mireds"], attributes["max_mireds"] @@ -872,7 +972,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) assert self.is_on self._settings = self._sun_light_settings.get_settings( - self.sleep_mode_switch.is_on, transition + self.sleep_mode_switch.is_on, self.accent_color_switch.is_on, transition ) self.async_write_ha_state() if lights is None: @@ -933,6 +1033,20 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context=self.create_context("sleep", parent=event.context), ) + async def _accent_color_switch_state_event(self, event: Event) -> None: + if not match_switch_state_event(event, (STATE_ON, STATE_OFF)): + return + _LOGGER.debug( + "%s: _accent_color_switch_state_event, event: '%s'", self._name, event + ) + # Reset the manually controlled status when the "sleep mode" changes + self.turn_on_off_listener.reset(*self._lights) + await self._update_attrs_and_maybe_adapt_lights( + transition=self._initial_transition, + force=True, + context=self.create_context("accent"), + ) + async def _light_event(self, event: Event) -> None: old_state = event.data.get("old_state") new_state = event.data.get("new_state") @@ -1041,7 +1155,7 @@ class SimpleSwitch(SwitchEntity, RestoreEntity): self._state = False -@dataclass(frozen=True) +@dataclass class SunLightSettings: """Track the state of the sun and associated light settings.""" @@ -1059,6 +1173,25 @@ class SunLightSettings: sunset_time: Optional[datetime.time] time_zone: datetime.tzinfo transition: int + accent_color: Optional[Tuple[float, float, float]] + accent_color_lower_bound: Optional[float] + accent_color_upper_bound: Optional[float] + + _accent_color_xyb: Tuple[float, float, float] = None + _bezier_point_xyb: Tuple[float, float, float] = None + + def set_accent_color(self, accent_color: Tuple[float, float, float]): + self.accent_color = accent_color + if accent_color is not None: + self._accent_color_xyb = color_RGB_to_xy_brightness(*accent_color) + color_upper_temp: float = self.calc_color_temp_kelvin(self.accent_color_upper_bound, False) + color_upper_rgb: Tuple[float, float, float] = color_temperature_to_rgb( + color_upper_temp + ) + self._bezier_point_xyb = color_RGB_to_xy_brightness(*color_upper_rgb) + else: + self._accent_color_xyb = None + self._bezier_point_xyb = None def get_sun_events(self, date: datetime.datetime) -> Dict[str, float]: """Get the four sun event's timestamps at 'date'.""" @@ -1176,8 +1309,42 @@ class SunLightSettings: return (delta * percent) + self.min_color_temp return self.min_color_temp + def calc_accented_color_interpolation( + self, rgb_color: Tuple[float, float, float], accent_percent: float + ) -> Tuple[float, float, float]: + if self.accent_color is None: + return None + if self._bezier_point_xyb is None or self._accent_color_xyb is None: + self.set_accent_color(self.accent_color) + control_point1 = color_RGB_to_xy_brightness(*rgb_color) + control_point2 = interpolate_colors_xyb( + self._bezier_point_xyb, + self._accent_color_xyb, + accent_percent + ) + xyb_color = interpolate_colors_xyb( + control_point1, + control_point2, + accent_percent + ) + return color_xy_brightness_to_RGB(*xyb_color) + + def calc_accented_color( + self, percent: float, rgb_color: Tuple[float, float, float] + ) -> Tuple[float, float, float]: + if self.accent_color is None: + return rgb_color + if percent <= self.accent_color_lower_bound: + return rgb_color + elif percent < self.accent_color_upper_bound: + accent_percent = (percent - self.accent_color_lower_bound) \ + / (self.accent_color_upper_bound - self.accent_color_lower_bound) + return self.calc_accented_color_interpolation(rgb_color, accent_percent) + else: + return self.accent_color + def get_settings( - self, is_sleep, transition + self, is_sleep, use_accent, transition ) -> Dict[str, Union[float, Tuple[float, float], Tuple[float, float, float]]]: """Get all light settings. @@ -1190,6 +1357,8 @@ class SunLightSettings: rgb_color: Tuple[float, float, float] = color_temperature_to_rgb( color_temp_kelvin ) + if use_accent and self.accent_color is not None and not is_sleep: + rgb_color = self.calc_accented_color(percent, rgb_color) xy_color: Tuple[float, float] = color_RGB_to_xy(*rgb_color) hs_color: Tuple[float, float] = color_xy_to_hs(*xy_color) return { @@ -1200,6 +1369,7 @@ class SunLightSettings: "xy_color": xy_color, "hs_color": hs_color, "sun_position": percent, + ATTR_SWITCH_ACCENT_COLOR: self.accent_color }