diff --git a/README.md b/README.md index 36ad135c..61c0bf34 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ adaptive_lighting: |:-------------------------|:--------------------------------------------------------------------------------------|:-----------|:---------------------| | `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 | +| `transition` | Duration of transition when lights change, in seconds. 🕑 | ✅ | `float` 0-6553 | | `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool | | `adapt_color` | Whether to adapt the color on supporting lights. 🌈 | ❌ | bool | | `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | ❌ | bool | diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index 8e61e93b..44b41d1b 100644 --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -1,6 +1,7 @@ """Adaptive Lighting integration in Home-Assistant.""" import logging +from functools import partial from typing import Any import homeassistant.helpers.config_validation as cv @@ -14,7 +15,18 @@ from .const import ( 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 +59,27 @@ 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, + ) + + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_CHANGE_SWITCH_SETTINGS, + service_func=partial(handle_change_switch_settings, hass), + 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/const.py b/custom_components/adaptive_lighting/const.py index 502318f0..e6ae3443 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -459,16 +459,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, @@ -477,6 +474,20 @@ def apply_service_schema(initial_transition: int = 1) -> vol.Schema: ) +def change_switch_settings_schema() -> vol.Schema: + """Return the schema for the change_switch_settings service.""" + args: dict[vol.Marker, Any] = { + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + 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 vol.Schema(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/hass_utils.py b/custom_components/adaptive_lighting/hass_utils.py index 550ae350..84961d27 100644 --- a/custom_components/adaptive_lighting/hass_utils.py +++ b/custom_components/adaptive_lighting/hass_utils.py @@ -1,5 +1,6 @@ """Utility functions for HA core.""" +import asyncio import logging from collections.abc import Awaitable, Callable @@ -80,9 +81,8 @@ def setup_service_call_interceptor( "Error for call '%s' in service_func_proxy", call.data, ) - # Call original service handler with processed data - import asyncio + # Call original service handler with processed data target = existing_service.job.target if asyncio.iscoroutinefunction(target): await target(call) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 3923e056..94964988 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -13,7 +13,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, @@ -55,9 +54,9 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, State, - callback, ) -from homeassistant.helpers import entity_platform, entity_registry +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.event import ( @@ -137,15 +136,10 @@ 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, replace_none_str, ) from .hass_utils import area_entities, setup_service_call_interceptor @@ -162,9 +156,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 _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) @@ -284,7 +276,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: @@ -295,7 +287,7 @@ 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) if switch_entity_ids is not None: if len(switch_entity_ids) > 1 and lights: @@ -303,13 +295,18 @@ 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 = [] 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) config_id = ent_entry.config_entry_id + if config_id not in hass.data[DOMAIN]: + msg = f"adaptive-lighting: Entity '{entity_id}' does not belong to this integration or is not loaded." + raise ServiceValidationError(msg) switches.append(hass.data[DOMAIN][config_id][SWITCH_DOMAIN]) return switches @@ -321,47 +318,128 @@ def _switches_from_service_call( "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, + hass: HomeAssistant, service_call: ServiceCall, ) -> None: """Allows HASS to change config values via a service call.""" data = service_call.data - which = data.get(CONF_USE_DEFAULTS, "current") - if which == "current": # use whatever we're already using. - defaults = switch._current_settings # pylint: disable=protected-access - elif which == "factory": # use actual defaults listed in the documentation - defaults = None - elif which == "configuration": - # use whatever's in the config flow or configuration.yaml - defaults = switch._config_backup - else: - defaults = None + switches = _switches_from_service_call(hass, service_call) + for switch in switches: + which = data.get(CONF_USE_DEFAULTS, "current") + if which == "current": # use whatever we're already using. + defaults = switch._current_settings # pylint: disable=protected-access + elif which == "factory": # use actual defaults listed in the documentation + defaults = None + elif which == "configuration": + # use whatever's in the config flow or configuration.yaml + defaults = switch._config_backup + else: + defaults = None - # deep copy the defaults so we don't modify the original dicts - switch._set_changeable_settings(data=data, defaults=deepcopy(defaults)) - if switch.is_on: - switch._update_time_interval_listener() + # deep copy the defaults so we don't modify the original dicts + switch._set_changeable_settings(data=data, defaults=deepcopy(defaults)) + if switch.is_on: + switch._update_time_interval_listener() - _LOGGER.debug( - "Called 'adaptive_lighting.change_switch_settings' service with '%s'", - data, - ) - - switch.manager.reset(*switch.lights, reset_manual_control=False) - if switch.is_on: - await switch._update_attrs_and_maybe_adapt_lights( # pylint: disable=protected-access - context=switch.create_context("service", parent=service_call.context), - lights=switch.lights, - transition=switch.initial_transition, - force=True, + _LOGGER.debug( + "Called 'adaptive_lighting.change_switch_settings' service with '%s'", + data, ) + switch.manager.reset(*switch.lights, reset_manual_control=False) + if switch.is_on: + await switch._update_attrs_and_maybe_adapt_lights( # pylint: disable=protected-access + context=switch.create_context("service", parent=service_call.context), + lights=switch.lights, + transition=switch.initial_transition, + force=True, + ) -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, + ) + # Handle optional transition + 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( + 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, + ) + + +async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, @@ -431,113 +509,6 @@ 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, - ) - 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( config_entry: ConfigEntry | None, @@ -1681,8 +1652,8 @@ class SimpleSwitch(SwitchEntity, RestoreEntity): self._state = False -type AdaptiveSwitches = list[AdaptiveSwitch] -type AdaptiveSwitchMap = dict[AdaptiveSwitch, list[str]] +AdaptiveSwitches = list[AdaptiveSwitch] +AdaptiveSwitchMap = dict[AdaptiveSwitch, list[str]] class AdaptiveLightingManager: diff --git a/docs/services.md b/docs/services.md index 5b0ccf58..cc541653 100644 --- a/docs/services.md +++ b/docs/services.md @@ -22,7 +22,7 @@ Applies the current Adaptive Lighting settings to lights on demand. Useful for f |:-------------------------|:--------------------------------------------------------------------------------------|:-----------|:---------------------| | `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 | +| `transition` | Duration of transition when lights change, in seconds. 🕑 | ✅ | `float` 0-6553 | | `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool | | `adapt_color` | Whether to adapt the color on supporting lights. 🌈 | ❌ | bool | | `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | ❌ | bool | diff --git a/tests/test_switch.py b/tests/test_switch.py index 5ba3cb60..fd4d1ec7 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -87,6 +87,9 @@ from homeassistant.components.light import ( ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.helpers.normalized_name_base_registry import ( + NormalizedNameBaseRegistryItems, +) try: # HA >= 2025.8 @@ -113,6 +116,8 @@ from homeassistant.const import ( ) from homeassistant.const import __version__ as ha_version from homeassistant.core import Context, Event, HomeAssistant, State +from homeassistant.core_config import async_process_ha_core_config +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import area_registry as ar from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import async_get_platforms @@ -375,19 +380,6 @@ async def test_adaptive_lighting_switches(hass): assert len(data.keys()) == 5 -def async_process_ha_core_config(hass, config): - """Set up the Home Assistant configuration.""" - try: - # ha >= "2023.11.0" - from homeassistant.core_config import async_process_ha_core_config - - return async_process_ha_core_config(hass, config) - except ModuleNotFoundError: - import homeassistant.config as config_util - - return config_util.async_process_ha_core_config(hass, config) - - @pytest.mark.parametrize(("lat", "long", "timezone"), LAT_LONG_TZS) async def test_adaptive_lighting_time_zones_with_default_settings( hass, @@ -890,7 +882,7 @@ async def test_auto_reset_manual_control(hass): async def test_adaptation_attribute_selection(hass): """Test the 'manual control' tracking.""" - switch, (light, *_) = await setup_lights_and_switch(hass) + switch, (_, *_) = await setup_lights_and_switch(hass) # Assert default settings assert switch._take_over_control @@ -1480,7 +1472,7 @@ async def test_offset_too_large(hass): which makes the adaptive lighting algorithm fail with a ValueError. """ _, switch = await setup_switch(hass, {CONF_SUNRISE_OFFSET: 3600 * 12}) - with pytest.raises(ValueError, match="sun events.*not in the expected order"): + with pytest.raises(ValueError, match=r"sun events.*not in the expected order"): await switch._update_attrs_and_maybe_adapt_lights( context=switch.create_context("test"), ) @@ -1584,10 +1576,6 @@ def mock_area_registry( # https://github.com/home-assistant/core/pull/114777 registry.areas = ar.AreaRegistryItems() elif dt == datetime.date(2024, 4, 1): - from homeassistant.helpers.normalized_name_base_registry import ( - NormalizedNameBaseRegistryItems, - ) - registry.areas = NormalizedNameBaseRegistryItems() else: registry.areas = OrderedDict() @@ -2005,7 +1993,7 @@ async def test_proactive_multiple_lights_all_at_once(hass): async def test_proactive_multiple_lights_turn_on_non_managed_light(hass): """Create switch and demo lights.""" - lights, switch1, switch2 = await setup_proactive_multiple_lights_two_switches(hass) + lights, _, _ = await setup_proactive_multiple_lights_two_switches(hass) turn_ons = await _turn_on_and_track_event_contexts(hass, "test1", lights) assert len(turn_ons) == 3, turn_ons await hass.async_block_till_done() @@ -2027,7 +2015,7 @@ async def test_proactive_multiple_lights_turn_on_non_managed_light(hass): async def test_proactive_multiple_lights_turn_on_managed_lights_only(hass): """Create switch and demo lights.""" - lights, switch1, switch2 = await setup_proactive_multiple_lights_two_switches(hass) + lights, _, _ = await setup_proactive_multiple_lights_two_switches(hass) _LOGGER.debug("Start test_proactive_multiple_lights_all_at_once") # Setup demo lights and turn on events = await _turn_on_and_track_event_contexts( @@ -2154,7 +2142,7 @@ async def test_adapt_until_sleep_and_rgb_colors(hass): hass, {"latitude": lat, "longitude": long, "time_zone": timezone, "country": "US"}, ) - switch, lights = await setup_lights_and_switch( + switch, _ = await setup_lights_and_switch( hass, { CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), @@ -2716,7 +2704,7 @@ async def test_skipped_lights_context_not_from_arbitrary_switch(hass): See: https://github.com/basnijholt/adaptive-lighting/pull/1348 """ # Setup two switches with different lights - lights, switch1, switch2 = await setup_proactive_multiple_lights_two_switches(hass) + lights, _, _ = await setup_proactive_multiple_lights_two_switches(hass) # Turn on all three lights at once: # - ENTITY_LIGHT_1 is in switch1 @@ -2914,3 +2902,146 @@ async def test_adapt_only_on_bare_turn_on_respects_pause_changed_mode(hass, inte f"With take_over_control_mode=PAUSE_CHANGED and only brightness marked " f"as manually controlled, color_temp should still be adapted." ) + + +async def test_service_validation_error_invalid_entity(hass): + """Test that ServiceValidationError is raised for invalid entities.""" + await setup_lights_and_switch(hass) + + # Test change_switch_settings with non-existent entity + with pytest.raises(ServiceValidationError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + {ATTR_ENTITY_ID: "switch.non_existent"}, + blocking=True, + ) + + +async def test_service_validation_error_missing_input(hass): + """Test that ServiceValidationError is raised for missing input.""" + await setup_lights_and_switch(hass) + + # Test change_switch_settings with no entity and no lights + with pytest.raises( + ServiceValidationError, + match="Neither a switch nor a light was provided", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + {}, + blocking=True, + ) + + +async def test_change_switch_settings_multiple_entities(hass): + """Test change_switch_settings with multiple entities.""" + # Setup two switches + await setup_lights(hass) + _, switch1 = await setup_switch( + hass, + {CONF_LIGHTS: [ENTITY_LIGHT_1], CONF_NAME: "switch1"}, + ) + _, switch2 = await setup_switch( + hass, + {CONF_LIGHTS: [ENTITY_LIGHT_2], CONF_NAME: "switch2"}, + ) + + assert switch1._sun_light_settings.min_color_temp != 3000 + assert switch2._sun_light_settings.min_color_temp != 3000 + + # Call service for both switches + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + { + ATTR_ENTITY_ID: [ + "switch.adaptive_lighting_switch1", + "switch.adaptive_lighting_switch2", + ], + "min_color_temp": 3000, + "use_defaults": "configuration", # Required to set new value + }, + blocking=True, + ) + + assert switch1._sun_light_settings.min_color_temp == 3000 + assert switch2._sun_light_settings.min_color_temp == 3000 + + +async def test_apply_service_validation(hass): + """Test validation for apply service.""" + await setup_lights_and_switch(hass) + + with pytest.raises(ServiceValidationError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY, + {ATTR_ENTITY_ID: "switch.non_existent"}, + blocking=True, + ) + + +async def test_set_manual_control_validation(hass): + """Test validation for set_manual_control service.""" + await setup_lights_and_switch(hass) + + with pytest.raises(ServiceValidationError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MANUAL_CONTROL, + {ATTR_ENTITY_ID: "switch.non_existent"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "use_target", + [False, True], + ids=["entity_id_in_data", "entity_id_in_target"], +) +async def test_change_switch_settings_backward_compatibility(hass, use_target): + """Test change_switch_settings works with both calling conventions. + + This is a regression test to ensure backward compatibility when + change_switch_settings was converted from an entity service to a domain service. + + Previously (entity service): entity_id was passed via `target` parameter + Now (domain service): entity_id is passed via `data` parameter + + Both conventions should work to avoid breaking existing automations. + """ + switch, _ = await setup_lights_and_switch(hass) + + # Verify initial state + original_min_color_temp = switch._sun_light_settings.min_color_temp + new_min_color_temp = 3000 + assert original_min_color_temp != new_min_color_temp + + if use_target: + # Old convention: entity_id in target (entity service style) + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + {"min_color_temp": new_min_color_temp}, + target={"entity_id": ENTITY_SWITCH}, + blocking=True, + ) + else: + # New convention: entity_id in data (domain service style) + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + { + ATTR_ENTITY_ID: ENTITY_SWITCH, + "min_color_temp": new_min_color_temp, + }, + blocking=True, + ) + + # Both conventions should result in the setting being changed + assert switch._sun_light_settings.min_color_temp == new_min_color_temp, ( + f"change_switch_settings failed with {'target' if use_target else 'data'} convention. " + f"Expected min_color_temp={new_min_color_temp}, got {switch._sun_light_settings.min_color_temp}" + )