From 3952b4767aecf637de1673ed3214fdf8874f6c6c Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Sat, 17 Jan 2026 19:54:52 +0000 Subject: [PATCH 1/7] 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. --- .../adaptive_lighting/__init__.py | 20 ++ custom_components/adaptive_lighting/const.py | 7 +- custom_components/adaptive_lighting/switch.py | 188 ++++++++---------- 3 files changed, 110 insertions(+), 105 deletions(-) diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index 8e61e93b..65b4145b 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,8 +15,13 @@ from .const import ( ATTR_ADAPTIVE_LIGHTING_MANAGER, CONF_NAME, DOMAIN, + SERVICE_APPLY, + SERVICE_SET_MANUAL_CONTROL, + SET_MANUAL_CONTROL_SCHEMA, UNDO_UPDATE_LISTENER, + apply_service_schema, ) +from .switch import handle_apply_service, handle_set_manual_control_service _LOGGER = logging.getLogger(__name__) @@ -47,6 +53,20 @@ 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, + SERVICE_APPLY, + partial(handle_apply_service, hass), + schema=apply_service_schema(), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_MANUAL_CONTROL, + partial(handle_set_manual_control_service, hass), + schema=SET_MANUAL_CONTROL_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..650374a0 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, diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 3923e056..839359d1 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -137,15 +137,11 @@ 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 @@ -361,7 +357,92 @@ async def handle_change_switch_settings( ) -async def async_setup_entry( # noqa: PLR0915 +@callback +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, + ) + + +@callback +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,99 +512,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) @@ -1681,8 +1669,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: From ab50cc9e3f2613bbd8e8eae8f72135db4f48c0a2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:56:08 +0000 Subject: [PATCH 2/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- custom_components/adaptive_lighting/switch.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 839359d1..bd1f8e19 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -368,9 +368,7 @@ async def handle_apply_service(hass: HomeAssistant, service_call: ServiceCall) - 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) - ) + 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): @@ -408,9 +406,7 @@ async def handle_set_manual_control_service( 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) - ) + 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], From 282d7631c88fc80ba32fcc8c1fccf24690aa534f Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Sun, 18 Jan 2026 21:24:01 -0700 Subject: [PATCH 3/7] Clean up and add tests --- .../adaptive_lighting/__init__.py | 27 ++++-- custom_components/adaptive_lighting/const.py | 14 +++ custom_components/adaptive_lighting/switch.py | 94 +++++++++---------- tests/test_switch.py | 78 ++++++++++++++- 4 files changed, 156 insertions(+), 57 deletions(-) diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index 65b4145b..44b41d1b 100644 --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -16,12 +16,18 @@ from .const import ( 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, ) -from .switch import handle_apply_service, handle_set_manual_control_service _LOGGER = logging.getLogger(__name__) @@ -54,19 +60,26 @@ 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, - SERVICE_APPLY, - partial(handle_apply_service, hass), + domain=DOMAIN, + service=SERVICE_APPLY, + service_func=partial(handle_apply_service, hass), schema=apply_service_schema(), ) hass.services.async_register( - DOMAIN, - SERVICE_SET_MANUAL_CONTROL, - partial(handle_set_manual_control_service, hass), + 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 650374a0..e6ae3443 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -474,6 +474,20 @@ def apply_service_schema() -> 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/switch.py b/custom_components/adaptive_lighting/switch.py index bd1f8e19..65c234af 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -57,6 +57,7 @@ from homeassistant.core import ( State, callback, ) +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_platform, entity_registry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_component import async_update_entity @@ -158,7 +159,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__) @@ -280,7 +281,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: @@ -291,7 +292,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: @@ -299,13 +300,22 @@ 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 @@ -317,47 +327,48 @@ 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, + ) + -@callback async def handle_apply_service(hass: HomeAssistant, service_call: ServiceCall) -> None: """Handle the entity service apply.""" data = service_call.data @@ -392,7 +403,6 @@ async def handle_apply_service(hass: HomeAssistant, service_call: ServiceCall) - ) -@callback async def handle_set_manual_control_service( hass: HomeAssistant, service_call: ServiceCall, @@ -508,20 +518,6 @@ async def async_setup_entry( update_before_add=True, ) - 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, diff --git a/tests/test_switch.py b/tests/test_switch.py index 5ba3cb60..4b675388 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -112,7 +112,8 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.const import __version__ as ha_version -from homeassistant.core import Context, Event, HomeAssistant, State +from homeassistant.core import Context, Event, HomeAssistant, State, callback +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 @@ -2914,3 +2915,78 @@ 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, + ) From 114688613ea2680c631a65b4ff45e7ef0ea3fe20 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 04:25:12 +0000 Subject: [PATCH 4/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- custom_components/adaptive_lighting/switch.py | 14 ++----- tests/test_switch.py | 40 +++++++++++++------ 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 65c234af..3d4d4017 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,10 +54,9 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, State, - callback, ) from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_platform, entity_registry +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 ( @@ -138,7 +136,6 @@ from .const import ( ICON_COLOR_TEMP, ICON_MAIN, ICON_SLEEP, - SERVICE_CHANGE_SWITCH_SETTINGS, SLEEP_MODE_SWITCH, TURNING_OFF_DELAY, VALIDATION_TUPLES, @@ -161,7 +158,6 @@ if TYPE_CHECKING: from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import NoEventData - _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) @@ -306,15 +302,11 @@ def _switches_from_service_call( for entity_id in switch_entity_ids: ent_entry = ent_reg.async_get(entity_id) if ent_entry is None: - msg = ( - f"adaptive-lighting: Entity '{entity_id}' not found in registry." - ) + 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." - ) + 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 diff --git a/tests/test_switch.py b/tests/test_switch.py index 4b675388..1b3ef10a 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -112,7 +112,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.const import __version__ as ha_version -from homeassistant.core import Context, Event, HomeAssistant, State, callback +from homeassistant.core import Context, Event, HomeAssistant, State from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import area_registry as ar from homeassistant.helpers import entity_registry @@ -2916,10 +2916,11 @@ async def test_adapt_only_on_bare_turn_on_respects_pause_changed_mode(hass, inte 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( @@ -2929,12 +2930,15 @@ async def test_service_validation_error_invalid_entity(hass): 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"): + with pytest.raises( + ServiceValidationError, match="Neither a switch nor a light was provided" + ): await hass.services.async_call( DOMAIN, SERVICE_CHANGE_SWITCH_SETTINGS, @@ -2942,35 +2946,44 @@ async def test_service_validation_error_missing_input(hass): 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"}) - + _, 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"], + ATTR_ENTITY_ID: [ + "switch.adaptive_lighting_switch1", + "switch.adaptive_lighting_switch2", + ], "min_color_temp": 3000, - "use_defaults": "configuration" # Required to set new value + "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, @@ -2979,10 +2992,11 @@ async def test_apply_service_validation(hass): 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, From 154e2211c559d7f1c11b54776b0799776116a578 Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Mon, 19 Jan 2026 23:23:04 -0700 Subject: [PATCH 5/7] Fix lint errors --- .../adaptive_lighting/hass_utils.py | 4 +- custom_components/adaptive_lighting/switch.py | 3 +- tests/test_switch.py | 42 +++++++------------ 3 files changed, 19 insertions(+), 30 deletions(-) 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 3d4d4017..94964988 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -156,8 +156,7 @@ if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import NoEventData - + from homeassistant.helpers.typing import NoEventData _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) diff --git a/tests/test_switch.py b/tests/test_switch.py index 1b3ef10a..62266d9f 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,7 @@ 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 @@ -376,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, @@ -891,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 @@ -1481,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"), ) @@ -1585,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() @@ -2006,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() @@ -2028,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( @@ -2155,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), @@ -2717,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 @@ -2937,7 +2924,8 @@ async def test_service_validation_error_missing_input(hass): # Test change_switch_settings with no entity and no lights with pytest.raises( - ServiceValidationError, match="Neither a switch nor a light was provided" + ServiceValidationError, + match="Neither a switch nor a light was provided", ): await hass.services.async_call( DOMAIN, @@ -2952,10 +2940,12 @@ async def test_change_switch_settings_multiple_entities(hass): # Setup two switches await setup_lights(hass) _, switch1 = await setup_switch( - hass, {CONF_LIGHTS: [ENTITY_LIGHT_1], CONF_NAME: "switch1"} + hass, + {CONF_LIGHTS: [ENTITY_LIGHT_1], CONF_NAME: "switch1"}, ) _, switch2 = await setup_switch( - hass, {CONF_LIGHTS: [ENTITY_LIGHT_2], CONF_NAME: "switch2"} + hass, + {CONF_LIGHTS: [ENTITY_LIGHT_2], CONF_NAME: "switch2"}, ) assert switch1._sun_light_settings.min_color_temp != 3000 From 0020cdd241ff7a945e0a036df34272a28586c2ac Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Mon, 19 Jan 2026 23:41:46 -0700 Subject: [PATCH 6/7] Automated update of generated docs --- README.md | 2 +- docs/services.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 | From df4f6236399a9088ce9a7c5a6e2acf0d9fd19ff5 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 19 Jan 2026 23:49:37 -0800 Subject: [PATCH 7/7] test: add backward compatibility test for change_switch_settings Add a regression test to verify that change_switch_settings works with both calling conventions: - entity_id in data (new domain service style) - entity_id in target (old entity service style) Both conventions work because Home Assistant's core automatically merges the target parameter into service_data before calling the handler. This test serves as documentation and ensures future changes don't break either calling convention for existing automations. --- tests/test_switch.py | 51 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_switch.py b/tests/test_switch.py index 62266d9f..fd4d1ec7 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -2994,3 +2994,54 @@ async def test_set_manual_control_validation(hass): {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}" + )