diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index 33881c75..dc928a6b 100755 --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_setup_reload_service import voluptuous as vol from .const import ( @@ -37,6 +38,8 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: dict[str, Any]): """Import integration from config.""" + # This will reload any changes the user made to any YAML configurations. + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) if DOMAIN in config: for entry in config[DOMAIN]: diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index e9d6f01c..e1f13499 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -5,7 +5,10 @@ from homeassistant.helpers import selector import homeassistant.helpers.config_validation as cv import voluptuous as vol -ICON = "mdi:theme-light-dark" +ICON_MAIN = "mdi:theme-light-dark" +ICON_BRIGHTNESS = "mdi:brightness-4" +ICON_COLOR_TEMP = "mdi:sun-thermometer" +ICON_SLEEP = "mdi:sleep" DOMAIN = "adaptive_lighting" SUN_EVENT_NOON = "solar_noon" @@ -115,7 +118,7 @@ VALIDATION_TUPLES = [ (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), (CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)), - (CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, int_between(0, 10000)), + (CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, cv.positive_float), ] diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index 8f449a77..05e706e9 100755 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -2,35 +2,66 @@ apply: description: Applies the current Adaptive Lighting settings to lights. fields: entity_id: - description: entity_id of the Adaptive Lighting switch. + description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used. example: switch.adaptive_lighting_default + selector: + entity: + integration: adaptive_lighting + domain: switch + multiple: false lights: - description: "entity_id(s) of lights, default: lights of the switch" + description: entity_id(s) of lights, if not specified, all lights in the switch are selected. example: light.bedroom_ceiling + selector: + entity: + domain: light + multiple: true transition: description: Transition of the lights. example: 10 + selector: + text: adapt_brightness: description: "Adapt the 'brightness', default: true" example: true + selector: + boolean: adapt_color: description: "Adapt the color_temp/color_rgb, default: true" example: true + selector: + boolean: prefer_rgb_color: description: "Prefer to use color_rgb over color_temp if possible, default: false" example: false + selector: + boolean: turn_on_lights: description: "Turn on the lights that are off, default: false" example: false + selector: + boolean: set_manual_control: description: Mark whether a light is 'manually controlled'. fields: entity_id: - description: entity_id of the Adaptive Lighting switch. + description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used. example: switch.adaptive_lighting_default - manual_control: - description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true" - example: true + selector: + entity: + integration: adaptive_lighting + domain: switch + multiple: false lights: description: entity_id(s) of lights, if not specified, all lights in the switch are selected. example: light.bedroom_ceiling + selector: + entity: + domain: light + multiple: true + manual_control: + description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true" + example: true + default: true + selector: + boolean: diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 5c228ade..0b1d00de 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -21,10 +21,8 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS_STEP_PCT, ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, @@ -74,7 +72,7 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.helpers import entity_platform +from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( async_track_state_change_event, @@ -129,7 +127,10 @@ from .const import ( CONF_TURN_ON_LIGHTS, DOMAIN, EXTRA_VALIDATION, - ICON, + ICON_BRIGHTNESS, + ICON_COLOR_TEMP, + ICON_MAIN, + ICON_SLEEP, SERVICE_APPLY, SERVICE_SET_MANUAL_CONTROL, SLEEP_MODE_SWITCH, @@ -161,10 +162,8 @@ RGB_REDMEAN_CHANGE = 80 # ≈10% of total range COLOR_ATTRS = { # Should ATTR_PROFILE be in here? ATTR_COLOR_NAME, - ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_XY_COLOR, } @@ -238,57 +237,116 @@ def _split_service_data(service_data, adapt_brightness, adapt_color): return service_datas -async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall): - """Handle the entity service apply.""" - hass = switch.hass - data = service_call.data - all_lights = data[CONF_LIGHTS] - if not all_lights: - all_lights = switch._lights - all_lights = _expand_light_groups(hass, all_lights) - switch.turn_on_off_listener.lights.update(all_lights) - _LOGGER.debug( - "Called 'adaptive_lighting.apply' service with '%s'", - data, - ) - for light in all_lights: - if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): - await switch._adapt_light( # pylint: disable=protected-access - light, - data[CONF_TRANSITION], - data[ATTR_ADAPT_BRIGHTNESS], - data[ATTR_ADAPT_COLOR], - data[CONF_PREFER_RGB_COLOR], - force=True, - context=switch.create_context("service", parent=service_call.context), - ) +def _find_switch_with_any_of_lights( + hass: HomeAssistant, + lights: list[str], + service_call: ServiceCall, +) -> AdaptiveSwitch: + """Find the switch that controls the lights in 'lights'.""" + config_entries = hass.config_entries.async_entries(DOMAIN) + data = hass.data[DOMAIN] + switches = {} + for config in config_entries: + # this check is necessary as there seems to always be an extra config + # entry that doesn't contain any data. I believe this happens when the + # integration exists, but is disabled by the user in HASS. + if config.entry_id in data: + switch = data[config.entry_id]["instance"] + all_check_lights = _expand_light_groups(hass, lights) + switch._expand_light_groups() + if set(switch._lights) & set(all_check_lights): + switches[config.entry_id] = switch + if len(switches) == 1: + return next(iter(switches.values())) -async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: ServiceCall): - """Set or unset lights as 'manually controlled'.""" - lights = service_call.data[CONF_LIGHTS] - if not lights: - all_lights = switch._lights # pylint: disable=protected-access + if len(switches) > 1: + _LOGGER.error( + "Invalid service data: Light(s) %s found in multiple switch configs (%s)." + " You must pass a switch under 'entity_id'. See the README for" + " details. Got %s", + lights, + list(switches.keys()), + service_call.data, + ) + raise ValueError( + "adaptive-lighting: Light(s) %s found in multiple switch configs.", + lights, + ) else: - all_lights = _expand_light_groups(switch.hass, lights) + _LOGGER.error( + "Invalid service data: Light was not found in any of your switch's configs." + " You must either include the light(s) that is/are in the integration config, or" + " pass a switch under 'entity_id'. See the README for details. Got %s", + service_call.data, + ) + raise ValueError( + "adaptive-lighting: Light(s) %s not found in any switch's configuration.", + lights, + ) + + +# For documentation on this function, see integration_entities() from HomeAssistant Core: +# https://github.com/home-assistant/core/blob/dev/homeassistant/helpers/template.py#L1109 +def _get_switches_from_service_call( + hass: HomeAssistant, service_call: ServiceCall +) -> list[AdaptiveSwitch]: _LOGGER.debug( - "Called 'adaptive_lighting.set_manual_control' service with '%s'", + "Function '_get_switches_from_service_call' called with service data:\n'%s'", service_call.data, ) - if service_call.data[CONF_MANUAL_CONTROL]: - for light in all_lights: - switch.turn_on_off_listener.manual_control[light] = True - _fire_manual_control_event(switch, light, service_call.context) - else: - switch.turn_on_off_listener.reset(*all_lights) - # pylint: disable=protected-access - if switch.is_on: - await switch._update_attrs_and_maybe_adapt_lights( - all_lights, - transition=switch._initial_transition, - force=True, - context=switch.create_context("service", parent=service_call.context), + data = service_call.data + lights = data[CONF_LIGHTS] + switch_entity_ids: list[str] | None = data.get("entity_id") + if not lights and not switch_entity_ids: + _LOGGER.debug( + "If you intended to adapt every single light on every single switch, please inform the" + " developers at https://github.com/basnijholt/adaptive-lighting of your use case." + " Currently, you must pass either an adaptive-lighting switch or the lights to" + " an `adaptive_lighting` service call." + ) + _LOGGER.error( + "Invalid service data passed to adaptive-lighting service call -" + " you must pass either a switch or a light's entity ID. Service data:\n%s", + service_call.data, + ) + raise ValueError( + "adaptive-lighting: No switch or light was passed to service call." + ) + + if switch_entity_ids is not None: + if len(switch_entity_ids) > 1 and lights: + _LOGGER.error( + "Invalid service data: cannot pass multiple switch entities while also passing" + " lights. Service data received: %s", + service_call.data, ) + raise ValueError( + "adaptive-lighting: Multiple switches were passed with lights argument" + ) + switches = [] + ent_reg = entity_registry.async_get(hass) + for entity_id in switch_entity_ids: + ent_entry = ent_reg.async_get(entity_id) + config_id = ent_entry.config_entry_id + switches.append(hass.data[DOMAIN][config_id]["instance"]) + return switches + + if lights: + switch = _find_switch_with_any_of_lights(hass, lights, service_call) + _LOGGER.debug( + "Switch '%s' found for lights '%s'", + switch.entity_id, + lights, + ) + return [switch] + + _LOGGER.error( + "Invalid service data passed to adaptive-lighting service call -" + " entities were not found in the integration. Service data:\n%s", + service_call.data, + ) + raise ValueError("adaptive-lighting: User sent incorrect data to service call") @callback @@ -320,10 +378,15 @@ async def async_setup_entry( if ATTR_TURN_ON_OFF_LISTENER not in data: data[ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass) turn_on_off_listener = data[ATTR_TURN_ON_OFF_LISTENER] - - 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) + sleep_mode_switch = SimpleSwitch( + "Sleep Mode", False, hass, config_entry, ICON_SLEEP + ) + adapt_color_switch = SimpleSwitch( + "Adapt Color", True, hass, config_entry, ICON_COLOR_TEMP + ) + adapt_brightness_switch = SimpleSwitch( + "Adapt Brightness", True, hass, config_entry, ICON_BRIGHTNESS + ) switch = AdaptiveSwitch( hass, config_entry, @@ -333,6 +396,9 @@ async def async_setup_entry( adapt_brightness_switch, ) + # save our switch instance, allows us to make switch's entity_id optional in service calls. + hass.data[DOMAIN][config_entry.entry_id]["instance"] = 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 @@ -343,33 +409,101 @@ async def async_setup_entry( update_before_add=True, ) + @callback + async def handle_apply(service_call: ServiceCall): + """Handle the entity service apply.""" + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.apply' service with '%s'", + data, + ) + these_switches = _get_switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for this_switch in these_switches: + if not lights: + all_lights = this_switch._lights # pylint: disable=protected-access + else: + all_lights = _expand_light_groups(this_switch.hass, lights) + this_switch.turn_on_off_listener.lights.update(all_lights) + for light in all_lights: + if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): + await this_switch._adapt_light( # pylint: disable=protected-access + light, + data[CONF_TRANSITION], + data[ATTR_ADAPT_BRIGHTNESS], + data[ATTR_ADAPT_COLOR], + data[CONF_PREFER_RGB_COLOR], + force=True, + context=this_switch.create_context( + "service", parent=service_call.context + ), + ) + + @callback + async def handle_set_manual_control(service_call: ServiceCall): + """Set or unset lights as 'manually controlled'.""" + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.set_manual_control' service with '%s'", + data, + ) + these_switches = _get_switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for this_switch in these_switches: + if not lights: + all_lights = this_switch._lights # pylint: disable=protected-access + else: + all_lights = _expand_light_groups(this_switch.hass, lights) + if service_call.data[CONF_MANUAL_CONTROL]: + for light in all_lights: + this_switch.turn_on_off_listener.manual_control[light] = True + _fire_manual_control_event(this_switch, light, service_call.context) + else: + this_switch.turn_on_off_listener.reset(*all_lights) + # pylint: disable=protected-access + if this_switch.is_on: + await this_switch._update_attrs_and_maybe_adapt_lights( + all_lights, + transition=this_switch._initial_transition, + force=True, + context=this_switch.create_context( + "service", parent=service_call.context + ), + ) + # Register `apply` service - platform = entity_platform.current_platform.get() - platform.async_register_entity_service( - SERVICE_APPLY, - { - vol.Optional( - CONF_LIGHTS, default=[] - ): cv.entity_ids, # pylint: disable=protected-access - vol.Optional( - CONF_TRANSITION, - default=switch._initial_transition, # pylint: disable=protected-access - ): VALID_TRANSITION, - vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, - vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, - vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, - vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, - }, - handle_apply, + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_APPLY, + service_func=handle_apply, + schema=vol.Schema( + { + vol.Optional("entity_id"): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional( + CONF_TRANSITION, + default=switch._initial_transition, # pylint: disable=protected-access + ): VALID_TRANSITION, + vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, + vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, + vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, + vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, + } + ), ) - platform.async_register_entity_service( - SERVICE_SET_MANUAL_CONTROL, - { - vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, - vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, - }, - handle_set_manual_control, + # Register `set_manual_control` service + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_SET_MANUAL_CONTROL, + service_func=handle_set_manual_control, + schema=vol.Schema( + { + vol.Optional("entity_id"): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, + } + ), ) @@ -610,7 +744,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) # Set other attributes - self._icon = ICON + self._icon = ICON_MAIN self._state = None # Tracks 'off' → 'on' state changes @@ -1046,12 +1180,17 @@ class SimpleSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" def __init__( - self, which: str, initial_state: bool, hass: HomeAssistant, config_entry + self, + which: str, + initial_state: bool, + hass: HomeAssistant, + config_entry: ConfigEntry, + icon: str, ): """Initialize the Adaptive Lighting switch.""" self.hass = hass data = validate(config_entry) - self._icon = ICON + self._icon = icon self._state = None self._which = which name = data[CONF_NAME]