From 623dd65aef264be2d0cfb57e785419ec82897cc5 Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Sun, 6 Sep 2026 05:04:18 -0600 Subject: [PATCH] Register service actions in async_setup for Bronze tier compliance (#1403) * Register service actions in async_setup for Bronze tier compliance - Move 'apply' and 'set_manual_control' service registration from async_setup_entry to async_setup. - Move service handlers to module-level functions in switch.py. - Update apply_service_schema to support dynamic defaults for transition duration. - Clean up related unused imports and fix Python 3.10 syntax compatibility. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Clean up and add tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix lint errors * Automated update of generated docs * Re-add types * Make transition not required again * fix: validate global service targets * fix: document optional apply transition * fix: derive service docs from schema markers * docs: clarify service target options * fix: preserve entity service target handling --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt --- README.md | 6 +- .../adaptive_lighting/__init__.py | 43 ++- .../adaptive_lighting/_docs_helpers.py | 17 +- custom_components/adaptive_lighting/const.py | 20 +- .../adaptive_lighting/services.yaml | 10 +- .../adaptive_lighting/strings.json | 4 - custom_components/adaptive_lighting/switch.py | 270 +++++++++--------- .../adaptive_lighting/translations/en.json | 4 - docs/services.md | 6 +- tests/test_init.py | 134 ++++++++- tests/test_switch.py | 133 ++++++++- 11 files changed, 483 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index be49d7ad..2ed03505 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ adaptive_lighting: #### `adaptive_lighting.apply` `adaptive_lighting.apply` applies Adaptive Lighting settings to lights on demand. +Provide a switch in `entity_id`, a list of `lights`, or both. @@ -212,7 +213,7 @@ adaptive_lighting: | Service data attribute | Description | Required | Type | |:-------------------------|:--------------------------------------------------------------------------------------|:-----------|:---------------------| -| `entity_id` | The `entity_id` of the switch with the settings to apply. 📝 | ✅ | list of `entity_id`s | +| `entity_id` | The `entity_id` of the switch with the settings to apply. 📝 | ❌ | list of `entity_id`s | | `lights` | A light (or list of lights) to apply the settings to. 💡 | ❌ | list of `entity_id`s | | `transition` | Duration of transition when lights change, in seconds. 🕑 | ❌ | `float` 0-6553 | | `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool | @@ -224,6 +225,7 @@ adaptive_lighting: #### `adaptive_lighting.set_manual_control` `adaptive_lighting.set_manual_control` can mark (or unmark) whether a light is "manually controlled", meaning that when a light has `manual_control`, the light is not adapted. +Provide a switch in `entity_id`, a list of `lights`, or both. @@ -234,7 +236,7 @@ adaptive_lighting: | Service data attribute | Description | Required | Type | |:-------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|:-----------------------------------------| -| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | ✅ | list of `entity_id`s | +| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | ❌ | list of `entity_id`s | | `lights` | entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 | ❌ | list of `entity_id`s | | `manual_control` | Whether to add ("true") or remove ("false") all adapted attributes of the light from the "manual_control" list, or the name of an attribute for selective addition. 🔒 | ❌ | bool or one of `['brightness', 'color']` | diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index 8e61e93b..0ac9ef71 100644 --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -1,20 +1,33 @@ """Adaptive Lighting integration in Home-Assistant.""" import logging +from functools import partial from typing import Any import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_SOURCE +from homeassistant.const import CONF_SOURCE, Platform from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import service from .const import ( _DOMAIN_SCHEMA, # pyright: ignore[reportPrivateUsage] ATTR_ADAPTIVE_LIGHTING_MANAGER, CONF_NAME, DOMAIN, + SERVICE_APPLY, + SERVICE_CHANGE_SWITCH_SETTINGS, + SERVICE_SET_MANUAL_CONTROL, + SET_MANUAL_CONTROL_SCHEMA, UNDO_UPDATE_LISTENER, + apply_service_schema, + change_switch_settings_schema, +) +from .switch import ( + handle_apply_service, + handle_change_switch_settings, + handle_set_manual_control_service, ) _LOGGER = logging.getLogger(__name__) @@ -47,6 +60,34 @@ async def reload_configuration_yaml(event: Event) -> None: async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: """Import integration from config.""" + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_APPLY, + service_func=partial(handle_apply_service, hass), + schema=apply_service_schema(), + ) + + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_SET_MANUAL_CONTROL, + service_func=partial(handle_set_manual_control_service, hass), + schema=SET_MANUAL_CONTROL_SCHEMA, + ) + + if register_platform_service := getattr( + service, + "async_register_platform_entity_service", + None, + ): + register_platform_service( + hass, + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + entity_domain=Platform.SWITCH, + func=handle_change_switch_settings, + schema=change_switch_settings_schema(), + ) + if DOMAIN in config: for entry in config[DOMAIN]: hass.async_create_task( diff --git a/custom_components/adaptive_lighting/_docs_helpers.py b/custom_components/adaptive_lighting/_docs_helpers.py index 0c4ed45d..c90d0c87 100644 --- a/custom_components/adaptive_lighting/_docs_helpers.py +++ b/custom_components/adaptive_lighting/_docs_helpers.py @@ -74,22 +74,21 @@ def generate_config_markdown_table() -> str: return df.to_markdown(index=False) -def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[Any, Any]]: - result: dict[str, tuple[Any, Any]] = {} +def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[bool, Any]]: + result: dict[str, tuple[bool, Any]] = {} for key, value in schema.schema.items(): - if isinstance(key, vol.Optional): - default_value = key.default - result[key.schema] = (default_value, value) + if isinstance(key, vol.Required | vol.Optional): + required = isinstance(key, vol.Required) and key.default == vol.UNDEFINED + result[key.schema] = (required, value) return result def _generate_service_markdown_table( - schema: dict[str, tuple[Any, Any]] | vol.Schema, + schema: vol.Schema, alternative_docs: dict[str, str] | None = None, ) -> str: - schema_dict = _schema_to_dict(schema) if isinstance(schema, vol.Schema) else schema rows: list[dict[str, str]] = [] - for k, (default, type_) in schema_dict.items(): + for k, (required, type_) in _schema_to_dict(schema).items(): if alternative_docs is not None and k in alternative_docs: description = alternative_docs[k] else: @@ -97,7 +96,7 @@ def _generate_service_markdown_table( row = { "Service data attribute": f"`{k}`", "Description": description, - "Required": "✅" if default == vol.UNDEFINED else "❌", + "Required": "✅" if required else "❌", "Type": _type_to_str(type_), } rows.append(row) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 59d5959b..4e258587 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -473,16 +473,13 @@ _DOMAIN_SCHEMA = vol.Schema( ) -def apply_service_schema(initial_transition: int = 1) -> vol.Schema: +def apply_service_schema() -> vol.Schema: """Return the schema for the apply service.""" return vol.Schema( { vol.Optional(CONF_ENTITY_ID): cv.entity_ids, # type: ignore[arg-type] vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, # type: ignore[arg-type] - vol.Optional( - CONF_TRANSITION, - default=initial_transition, - ): VALID_TRANSITION, + vol.Optional(CONF_TRANSITION): 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, @@ -491,6 +488,19 @@ def apply_service_schema(initial_transition: int = 1) -> vol.Schema: ) +def change_switch_settings_schema() -> dict[vol.Marker, Any]: + """Return the schema for the change_switch_settings service.""" + args: dict[vol.Marker, Any] = { + vol.Optional(CONF_USE_DEFAULTS, default="current"): cv.string, + } + # Modifying these after init isn't possible + skip = (CONF_INTERVAL, CONF_NAME, CONF_LIGHTS) + for k, _, valid in VALIDATION_TUPLES: + if k not in skip: + args[vol.Optional(k)] = valid + return args + + SET_MANUAL_CONTROL_SCHEMA = vol.Schema( { vol.Optional(CONF_ENTITY_ID): cv.entity_ids, # type: ignore[arg-type] diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index 77ba0b7a..2471e83a 100644 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -64,13 +64,11 @@ set_manual_control: boolean: null change_switch_settings: description: Change any settings you'd like in the switch. All options here are the same as in the config flow. + target: + entity: + integration: adaptive_lighting + domain: switch fields: - entity_id: - description: Entity ID of the switch. 📝 - required: true - selector: - entity: - domain: switch use_defaults: description: 'Sets the default values not specified in this service call. Options: "current" (default, retains current values), "factory" (resets to documented defaults), or "configuration" (reverts to switch config defaults). ⚙️' example: current diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 070db7ec..cf47241e 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -156,10 +156,6 @@ "name": "change_switch_settings", "description": "Change any settings you'd like in the switch. All options here are the same as in the config flow.", "fields": { - "entity_id": { - "description": "Entity ID of the switch. 📝", - "name": "entity_id" - }, "use_defaults": { "description": "Sets the default values not specified in this service call. Options: \"current\" (default, retains current values), \"factory\" (resets to documented defaults), or \"configuration\" (reverts to switch config defaults). ⚙️", "name": "use_defaults" diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 782f01f1..0b49e8d9 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -14,7 +14,6 @@ from typing import TYPE_CHECKING, Any import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util import ulid_transform -import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -58,7 +57,8 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.helpers import entity_platform, entity_registry +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_platform, entity_registry, service from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.event import ( @@ -139,15 +139,12 @@ from .const import ( ICON_COLOR_TEMP, ICON_MAIN, ICON_SLEEP, - SERVICE_APPLY, SERVICE_CHANGE_SWITCH_SETTINGS, - SERVICE_SET_MANUAL_CONTROL, - SET_MANUAL_CONTROL_SCHEMA, SLEEP_MODE_SWITCH, TURNING_OFF_DELAY, VALIDATION_TUPLES, TakeOverControlMode, - apply_service_schema, + change_switch_settings_schema, replace_none_str, ) from .hass_utils import area_entities, setup_service_call_interceptor @@ -164,7 +161,7 @@ if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity_platform import AddEntitiesCallback - from homeassistant.helpers.typing import NoEventData, VolDictType + from homeassistant.helpers.typing import NoEventData try: from homeassistant.helpers.sun import get_astral_observer @@ -243,16 +240,22 @@ def _switches_with_lights( ) -> AdaptiveSwitches: """Get all switches that control at least one of the lights passed.""" config_entries = hass.config_entries.async_entries(DOMAIN) - data = hass.data[DOMAIN] - switches: AdaptiveSwitches = [] + data = hass.data.get(DOMAIN, {}) + loaded_switches: AdaptiveSwitches = [] + for config in config_entries: + entry = data.get(config.entry_id) + if not isinstance(entry, dict) or SWITCH_DOMAIN not in entry: + continue + loaded_switches.append(entry[SWITCH_DOMAIN]) + + if not loaded_switches: + return [] + all_check_lights = ( _expand_light_groups(hass, lights) if expand_light_groups else set(lights) ) - for config in config_entries: - entry = data.get(config.entry_id) - if entry is None: # entry might be disabled and therefore missing - continue - switch = data[config.entry_id][SWITCH_DOMAIN] + switches: AdaptiveSwitches = [] + for switch in loaded_switches: switch._expand_light_groups(hass=hass) # Check if any of the lights are in the switch's lights if set(switch.lights) & set(all_check_lights): @@ -299,7 +302,7 @@ def _switches_from_service_call( service_call: ServiceCall, ) -> AdaptiveSwitches: data = service_call.data - lights = data[CONF_LIGHTS] + lights = data.get(CONF_LIGHTS) switch_entity_ids: list[str] | None = data.get("entity_id") if not lights and not switch_entity_ids: @@ -310,7 +313,12 @@ def _switches_from_service_call( " use case. Currently, you must pass either an adaptive-lighting switch or" " the lights to an `adaptive_lighting` service call." ) - raise ValueError(msg) + raise ServiceValidationError(msg) + + domain_data = hass.data.get(DOMAIN) + if not domain_data: + msg = "adaptive-lighting: No Adaptive Lighting config entries are loaded." + raise ServiceValidationError(msg) if switch_entity_ids is not None: if len(switch_entity_ids) > 1 and lights: @@ -318,32 +326,59 @@ def _switches_from_service_call( "adaptive-lighting: Cannot pass multiple switches with lights argument." f" Invalid service data received: {service_call.data}" ) - raise ValueError(msg) + raise ServiceValidationError(msg) switches: AdaptiveSwitches = [] + config_ids: set[str] = set() ent_reg = entity_registry.async_get(hass) for entity_id in switch_entity_ids: ent_entry = ent_reg.async_get(entity_id) - assert ent_entry is not None + if ent_entry is None: + msg = f"adaptive-lighting: Entity '{entity_id}' not found in registry." + raise ServiceValidationError(msg) + if ent_entry.platform != DOMAIN: + msg = ( + f"adaptive-lighting: Entity '{entity_id}' is not registered by" + " Adaptive Lighting." + ) + raise ServiceValidationError(msg) config_id = ent_entry.config_entry_id - switches.append(hass.data[DOMAIN][config_id][SWITCH_DOMAIN]) + config_data = domain_data.get(config_id) if config_id else None + if ( + config_id is None + or not isinstance(config_data, dict) + or SWITCH_DOMAIN not in config_data + ): + msg = ( + f"adaptive-lighting: Adaptive Lighting entry for entity '{entity_id}'" + " is not loaded." + ) + raise ServiceValidationError(msg) + if config_id not in config_ids: + switches.append(config_data[SWITCH_DOMAIN]) + config_ids.add(config_id) return switches if lights: - switch = _switch_with_lights(hass, lights) + try: + switch = _switch_with_lights(hass, lights) + except NoSwitchFoundError as err: + raise ServiceValidationError(str(err)) from err return [switch] msg = ( "adaptive-lighting: Incorrect data provided in service call." f" Entities not found in the integration. Service data: {service_call.data}" ) - raise ValueError(msg) + raise ServiceValidationError(msg) async def handle_change_switch_settings( - switch: AdaptiveSwitch, + switch: AdaptiveSwitch | SimpleSwitch, service_call: ServiceCall, ) -> None: """Allows HASS to change config values via a service call.""" + if not isinstance(switch, AdaptiveSwitch): + return data = service_call.data which = data.get(CONF_USE_DEFAULTS, "current") if which == "current": # use whatever we're already using. @@ -376,7 +411,79 @@ async def handle_change_switch_settings( ) -async def async_setup_entry( # noqa: PLR0915 +async def handle_apply_service(hass: HomeAssistant, service_call: ServiceCall) -> None: + """Handle the entity service apply.""" + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.apply' service with '%s'", + data, + ) + switches = _switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for switch in switches: + all_lights = switch.lights if not lights else _expand_light_groups(hass, lights) + switch.manager.lights.update(all_lights) + for light in all_lights: + if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): + context = switch.create_context( + "service", + parent=service_call.context, + ) + transition = data.get(CONF_TRANSITION) + if transition is None: + transition = switch.initial_transition + await switch._adapt_light( # pylint: disable=protected-access + light, + context=context, + transition=transition, + adapt_brightness=data[ATTR_ADAPT_BRIGHTNESS], + adapt_color=data[ATTR_ADAPT_COLOR], + prefer_rgb_color=data[CONF_PREFER_RGB_COLOR], + force=True, + ) + + +async def handle_set_manual_control_service( + hass: HomeAssistant, + service_call: ServiceCall, +) -> None: + """Set or unset lights as manually controlled.""" + data = service_call.data + _LOGGER.debug( + "Called 'adaptive_lighting.set_manual_control' service with '%s'", + data, + ) + switches = _switches_from_service_call(hass, service_call) + lights = data[CONF_LIGHTS] + for switch in switches: + all_lights = switch.lights if not lights else _expand_light_groups(hass, lights) + manual_attributes = manual_control_event_attribute_to_flags( + data[CONF_MANUAL_CONTROL], + ) + + if manual_attributes: + for light in all_lights: + switch.manager.set_manual_control_attributes( + light, + manual_attributes, + ) + switch.fire_manual_control_event(light, service_call.context) + else: + switch.manager.reset(*all_lights) + if switch.is_on: + context = switch.create_context( + "service", + parent=service_call.context, + ) + await switch._update_attrs_and_maybe_adapt_lights( # pylint: disable=protected-access + context=context, + lights=all_lights, + transition=switch.initial_transition, + force=True, + ) + + +async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, @@ -446,112 +553,14 @@ async def async_setup_entry( # noqa: PLR0915 update_before_add=True, ) - @callback - async def handle_apply(service_call: ServiceCall) -> None: - """Handle the entity service apply.""" - data = service_call.data - _LOGGER.debug( - "Called 'adaptive_lighting.apply' service with '%s'", - data, + if not hasattr(service, "async_register_platform_entity_service"): + platform = entity_platform.current_platform.get() + assert platform is not None + platform.async_register_entity_service( + SERVICE_CHANGE_SWITCH_SETTINGS, + change_switch_settings_schema(), + handle_change_switch_settings, ) - switches = _switches_from_service_call(hass, service_call) - lights = data[CONF_LIGHTS] - for switch in switches: - if not lights: - all_lights = switch.lights - else: - all_lights = _expand_light_groups(hass, lights) - switch.manager.lights.update(all_lights) - for light in all_lights: - if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): - context = switch.create_context( - "service", - parent=service_call.context, - ) - await switch._adapt_light( # pylint: disable=protected-access - light, - context=context, - transition=data[CONF_TRANSITION], - adapt_brightness=data[ATTR_ADAPT_BRIGHTNESS], - adapt_color=data[ATTR_ADAPT_COLOR], - prefer_rgb_color=data[CONF_PREFER_RGB_COLOR], - force=True, - ) - - @callback - async def handle_set_manual_control(service_call: ServiceCall) -> None: - """Set or unset lights as 'manually controlled'.""" - data = service_call.data - _LOGGER.debug( - "Called 'adaptive_lighting.set_manual_control' service with '%s'", - data, - ) - switches = _switches_from_service_call(hass, service_call) - lights = data[CONF_LIGHTS] - for switch in switches: - if not lights: - all_lights = switch.lights - else: - all_lights = _expand_light_groups(hass, lights) - - manual_attributes = manual_control_event_attribute_to_flags( - service_call.data[CONF_MANUAL_CONTROL], - ) - - if manual_attributes: - for light in all_lights: - switch.manager.set_manual_control_attributes( - light, - manual_attributes, - ) - switch.fire_manual_control_event( - light, - service_call.context, - ) - else: - switch.manager.reset(*all_lights) - if switch.is_on: - context = switch.create_context( - "service", - parent=service_call.context, - ) - # pylint: disable=protected-access - await switch._update_attrs_and_maybe_adapt_lights( - context=context, - lights=all_lights, - transition=switch.initial_transition, - force=True, - ) - - # Register `apply` service - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_APPLY, - service_func=handle_apply, - schema=apply_service_schema(switch.initial_transition), - ) - - # Register `set_manual_control` service - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_SET_MANUAL_CONTROL, - service_func=handle_set_manual_control, - schema=SET_MANUAL_CONTROL_SCHEMA, - ) - - args: VolDictType = {vol.Optional(CONF_USE_DEFAULTS, default="current"): cv.string} - # Modifying these after init isn't possible - skip = (CONF_INTERVAL, CONF_NAME, CONF_LIGHTS) - for k, _, valid in VALIDATION_TUPLES: - if k not in skip: - args[vol.Optional(k)] = valid - platform = entity_platform.current_platform.get() - assert platform is not None - platform.async_register_entity_service( - SERVICE_CHANGE_SWITCH_SETTINGS, - args, - handle_change_switch_settings, - ) def validate( @@ -1846,9 +1855,12 @@ class AdaptiveLightingManager: ) def disable(self) -> None: - """Disable the listener by removing all subscribed handlers.""" + """Disable listeners and pending automatic manual-control resets.""" for remove in self.listener_removers: remove() + for timer in self.auto_reset_manual_control_timers.values(): + timer.cancel() + self.auto_reset_manual_control_timers.clear() def set_proactively_adapting(self, context_id: str, entity_id: str) -> None: """Declare the adaptation with context_id as proactively adapting, diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 4bf3fcb9..9d218f62 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -157,10 +157,6 @@ "name": "change_switch_settings", "description": "Change any settings you'd like in the switch. All options here are the same as in the config flow.", "fields": { - "entity_id": { - "description": "Entity ID of the switch. 📝", - "name": "entity_id" - }, "use_defaults": { "description": "Sets the default values not specified in this service call. Options: \"current\" (default, retains current values), \"factory\" (resets to documented defaults), or \"configuration\" (reverts to switch config defaults). ⚙️", "name": "use_defaults" diff --git a/docs/services.md b/docs/services.md index 306aa7c3..6258017a 100644 --- a/docs/services.md +++ b/docs/services.md @@ -9,6 +9,7 @@ Adaptive Lighting provides three services for programmatic control, allowing you ## adaptive_lighting.apply Applies the current Adaptive Lighting settings to lights on demand. Useful for forcing an immediate update or applying settings to lights that aren't in the regular adaptation cycle. +Provide a switch in `entity_id`, a list of `lights`, or both. ### Parameters @@ -20,7 +21,7 @@ Applies the current Adaptive Lighting settings to lights on demand. Useful for f | Service data attribute | Description | Required | Type | |:-------------------------|:--------------------------------------------------------------------------------------|:-----------|:---------------------| -| `entity_id` | The `entity_id` of the switch with the settings to apply. 📝 | ✅ | list of `entity_id`s | +| `entity_id` | The `entity_id` of the switch with the settings to apply. 📝 | ❌ | list of `entity_id`s | | `lights` | A light (or list of lights) to apply the settings to. 💡 | ❌ | list of `entity_id`s | | `transition` | Duration of transition when lights change, in seconds. 🕑 | ❌ | `float` 0-6553 | | `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool | @@ -58,6 +59,7 @@ data: ## adaptive_lighting.set_manual_control Marks or unmarks a light as "manually controlled". When a light is marked as manually controlled, Adaptive Lighting will not adjust it until the manual control flag is cleared. +Provide a switch in `entity_id`, a list of `lights`, or both. ### Parameters @@ -69,7 +71,7 @@ Marks or unmarks a light as "manually controlled". When a light is marked as man | Service data attribute | Description | Required | Type | |:-------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|:-----------------------------------------| -| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | ✅ | list of `entity_id`s | +| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | ❌ | list of `entity_id`s | | `lights` | entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 | ❌ | list of `entity_id`s | | `manual_control` | Whether to add ("true") or remove ("false") all adapted attributes of the light from the "manual_control" list, or the name of an attribute for selective addition. 🔒 | ❌ | bool or one of `['brightness', 'color']` | diff --git a/tests/test_init.py b/tests/test_init.py index 6bbfd599..aaf23851 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,12 +1,21 @@ """Tests for Adaptive Lighting integration.""" +import pytest +import voluptuous.error from homeassistant.components import adaptive_lighting from homeassistant.components.adaptive_lighting.const import ( + CONF_LIGHTS, DEFAULT_NAME, + SERVICE_APPLY, + SERVICE_CHANGE_SWITCH_SETTINGS, + SERVICE_SET_MANUAL_CONTROL, UNDO_UPDATE_LISTENER, ) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_NAME +from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import service from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -53,3 +62,126 @@ async def test_unload_entry(hass): assert entry.state == ConfigEntryState.NOT_LOADED assert adaptive_lighting.DOMAIN not in hass.data + + +async def test_services_survive_entry_unload_and_reload(hass): + """Test integration services remain registered across entry lifecycle.""" + assert await async_setup_component(hass, adaptive_lighting.DOMAIN, {}) + service_names = ( + SERVICE_APPLY, + SERVICE_CHANGE_SWITCH_SETTINGS, + SERVICE_SET_MANUAL_CONTROL, + ) + services = hass.services.async_services()[adaptive_lighting.DOMAIN] + assert SERVICE_APPLY in services + assert SERVICE_SET_MANUAL_CONTROL in services + if hasattr(service, "async_register_platform_entity_service"): + assert SERVICE_CHANGE_SWITCH_SETTINGS in services + else: + assert SERVICE_CHANGE_SWITCH_SETTINGS not in services + + entry = MockConfigEntry( + domain=adaptive_lighting.DOMAIN, + data={CONF_NAME: DEFAULT_NAME}, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + registered = { + name: hass.services.async_services()[adaptive_lighting.DOMAIN][name] + for name in service_names + } + switch = hass.data[adaptive_lighting.DOMAIN][entry.entry_id][SWITCH_DOMAIN] + assert await hass.config_entries.async_unload(entry.entry_id) + + for name in service_names: + assert ( + hass.services.async_services()[adaptive_lighting.DOMAIN][name] + is registered[name] + ) + + with pytest.raises(ServiceValidationError, match="No Adaptive Lighting"): + await hass.services.async_call( + adaptive_lighting.DOMAIN, + SERVICE_APPLY, + {ATTR_ENTITY_ID: switch.entity_id}, + blocking=True, + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + for name in service_names: + assert ( + hass.services.async_services()[adaptive_lighting.DOMAIN][name] + is registered[name] + ) + + await hass.services.async_call( + adaptive_lighting.DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + {ATTR_ENTITY_ID: switch.entity_id}, + blocking=True, + ) + + +async def test_service_call_without_loaded_entry(hass): + """Test global services reject calls when no profile is loaded.""" + assert await async_setup_component(hass, adaptive_lighting.DOMAIN, {}) + + with pytest.raises(ServiceValidationError, match="No Adaptive Lighting"): + await hass.services.async_call( + adaptive_lighting.DOMAIN, + SERVICE_APPLY, + {CONF_LIGHTS: ["light.test"]}, + blocking=True, + ) + + pending_entry = MockConfigEntry( + domain=adaptive_lighting.DOMAIN, + data={CONF_NAME: "pending"}, + ) + pending_entry.add_to_hass(hass) + hass.data[adaptive_lighting.DOMAIN] = {pending_entry.entry_id: {}} + with pytest.raises(ServiceValidationError, match="not found in any switch"): + await hass.services.async_call( + adaptive_lighting.DOMAIN, + SERVICE_APPLY, + {CONF_LIGHTS: ["light.test"]}, + blocking=True, + ) + + +async def test_apply_rejects_unknown_light(hass): + """Test the apply service rejects an unknown light target.""" + entry = MockConfigEntry( + domain=adaptive_lighting.DOMAIN, + data={CONF_NAME: DEFAULT_NAME}, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + with pytest.raises(ServiceValidationError, match="not found in any switch"): + await hass.services.async_call( + adaptive_lighting.DOMAIN, + SERVICE_APPLY, + {CONF_LIGHTS: ["light.does_not_exist"]}, + blocking=True, + ) + + +async def test_change_switch_settings_requires_entity_target(hass): + """Test change_switch_settings rejects a missing entity target.""" + entry = MockConfigEntry( + domain=adaptive_lighting.DOMAIN, + data={CONF_NAME: DEFAULT_NAME}, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + with pytest.raises( + voluptuous.error.MultipleInvalid, + match=r"must contain at least one of entity_id.*area_id", + ): + await hass.services.async_call( + adaptive_lighting.DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + {}, + blocking=True, + ) diff --git a/tests/test_switch.py b/tests/test_switch.py index 6d2e169a..421a430e 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -95,6 +95,7 @@ from homeassistant.components.template.light import StateLightEntity as LightTem from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntryState from homeassistant.const import ( ATTR_AREA_ID, + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_LIGHTS, @@ -108,6 +109,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import Context, Event, HomeAssistant, State +from homeassistant.exceptions import Unauthorized from homeassistant.helpers import area_registry as ar from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import async_get_platforms @@ -1488,6 +1490,54 @@ async def test_apply_service(hass): assert old_state[ATTR_COLOR_TEMP_KELVIN] == new_state[ATTR_COLOR_TEMP_KELVIN] +async def test_apply_service_uses_each_switch_transition(hass): + """Test global apply resolves omitted transition for each profile.""" + await setup_lights(hass) + _, switch_1 = await setup_switch( + hass, + { + CONF_NAME: "switch 1", + CONF_LIGHTS: [ENTITY_LIGHT_1], + CONF_INITIAL_TRANSITION: 3, + }, + ) + _, switch_2 = await setup_switch( + hass, + { + CONF_NAME: "switch 2", + CONF_LIGHTS: [ENTITY_LIGHT_2], + CONF_INITIAL_TRANSITION: 7, + }, + ) + + with ( + patch.object(switch_1, "_adapt_light", new=AsyncMock()) as adapt_1, + patch.object(switch_2, "_adapt_light", new=AsyncMock()) as adapt_2, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY, + {ATTR_ENTITY_ID: [switch_1.entity_id, switch_2.entity_id]}, + blocking=True, + ) + assert adapt_1.await_args.kwargs["transition"] == 3 + assert adapt_2.await_args.kwargs["transition"] == 7 + + adapt_1.reset_mock() + adapt_2.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY, + { + ATTR_ENTITY_ID: [switch_1.entity_id, switch_2.entity_id], + CONF_TRANSITION: 0, + }, + blocking=True, + ) + assert adapt_1.await_args.kwargs["transition"] == 0 + assert adapt_2.await_args.kwargs["transition"] == 0 + + async def test_switch_off_on_off(hass): """Test switch rapid off_on_off.""" @@ -1837,10 +1887,16 @@ def test_is_our_context(): async def test_unload_switch(hass): """Test removing Adaptive Lighting.""" - entry, _ = await setup_switch(hass, {}) + entry, switch = await setup_switch(hass, {}) + switch.manager.set_auto_reset_manual_control_times([ENTITY_LIGHT_1], 60) + switch.manager.set_manual_control_attributes(ENTITY_LIGHT_1) + timer = switch.manager.auto_reset_manual_control_timers[ENTITY_LIGHT_1] + assert timer.is_running() + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert DOMAIN not in hass.data + assert not timer.is_running() @pytest.mark.parametrize("state", [STATE_ON, STATE_OFF, None]) @@ -2152,6 +2208,81 @@ async def test_change_switch_settings_service(hass): assert switch._sun_light_settings.min_color_temp == 2500 +@pytest.mark.parametrize("target", ["entity", "area", "device", "all"]) +async def test_change_switch_settings_entity_targets(hass, device_registry, target): + """Test settings changes through Home Assistant entity targets.""" + _, switch = await setup_switch(hass, {}) + mock_area_registry(hass) + registry_entry = entity_registry.async_get(hass).async_get(switch.entity_id) + assert registry_entry is not None + assert registry_entry.device_id is not None + device_registry.async_update_device( + registry_entry.device_id, + area_id="test-area", + ) + service_data = { + "entity": {ATTR_ENTITY_ID: switch.entity_id}, + "area": {ATTR_AREA_ID: "test-area"}, + "device": {ATTR_DEVICE_ID: registry_entry.device_id}, + "all": {ATTR_ENTITY_ID: "all"}, + }[target] + + with patch.object( + switch, + "_set_changeable_settings", + wraps=switch._set_changeable_settings, + ) as set_settings: + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + {**service_data, CONF_MAX_BRIGHTNESS: 50}, + blocking=True, + ) + + set_settings.assert_called_once() + assert switch._sun_light_settings.max_brightness == 50 + + +async def test_change_switch_settings_ignores_unknown_entity(hass): + """Test an unknown entity target does not change a loaded profile.""" + _, switch = await setup_switch(hass, {}) + + with patch.object(switch, "_set_changeable_settings") as set_settings: + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + { + ATTR_ENTITY_ID: "switch.does_not_exist", + CONF_MAX_BRIGHTNESS: 50, + }, + blocking=True, + ) + + set_settings.assert_not_called() + + +async def test_change_switch_settings_checks_entity_permissions( + hass, + hass_read_only_user, +): + """Test settings changes require permission to control the target entity.""" + _, switch = await setup_switch(hass, {}) + + with pytest.raises(Unauthorized): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + { + ATTR_ENTITY_ID: switch.entity_id, + CONF_MAX_BRIGHTNESS: 50, + }, + blocking=True, + context=Context(user_id=hass_read_only_user.id), + ) + + assert switch._sun_light_settings.max_brightness == DEFAULT_MAX_BRIGHTNESS + + async def test_cancellable_service_calls_task(hass): """Test the creation and execution of the task that wraps adaptation service calls.""" light, *_ = await setup_lights(hass)