From 08ff04141e791ac5061c569658a80672c69b3374 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 25 Mar 2023 22:22:05 -0500 Subject: [PATCH] Feature requests: Switch now optional for service calls | New default icons | Reload `.yaml` w/o restart | `adapt_delay` ms (#459) * added #274 * attempt getSwitchFromLightId() * Update switch.py * changed from register_entity_service to hass.services.async_register * Update switch.py * Update switch.py * Update switch.py * Update switch.py * Update switch.py * Update switch.py * Update services.yaml * Update switch.py * test * test builds ready * target selector may not be possible with current syntax priority is backwards-compatibility as I know most people will smash that update button * expanded light groups * test builds ready * add feature #104 * Update custom_components/adaptive_lighting/switch.py Co-authored-by: Chris * Might as well type both. No reason not to. Co-authored-by: Chris * Might as well type both. No reason not to. Co-authored-by: Chris * Reformatted debug messages. Reimported ServiceCall as suggested * Multiple switches allowed again in services. Apparently this was possible before. With this change, the `lights` argument must not be passed with multiple switches, or the integration has no way of knowing what the user wants to do. Integration did not make this check in prior versions. Also reformatted the debug messages. Removed `automerge.yaml` (my apologies) * Reload config without restart. You can now reload any changes to the yaml file without restarting your home assistant. Should show a 'reload' button in your integrations page or you can call the homeassistant reload integration service call. See https://community.home-assistant.io/t/how-to-allow-custom-compontent-for-yaml-configuration-reloading/391190/4 * Use snake_case for function name 'parseServiceArgs' * Slight rephrase * Small style changes * Rephrased debug messages. Removed `integration_entities` as we rewrote the code from that function already in parse_service_args. Reference function now above parse_service_args. * removed: `these_switches = data = None` * Factor out _find_switch_with_lights * Rename function and add log statement * Remove pylint marker * Handle multiple switches found * Small changes * Rename _parse_service_args to _get_switches_from_service_call * Rephrase log messages * Add type hint --------- Co-authored-by: Chris Co-authored-by: Bas Nijholt --- .../adaptive_lighting/__init__.py | 3 + custom_components/adaptive_lighting/const.py | 7 +- .../adaptive_lighting/services.yaml | 43 ++- custom_components/adaptive_lighting/switch.py | 301 +++++++++++++----- 4 files changed, 265 insertions(+), 89 deletions(-) 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]