From 68c0e4db69a4026d0ead942d11507ecf2dbabe7e Mon Sep 17 00:00:00 2001 From: mueslo Date: Sun, 6 Sep 2026 10:04:50 +0200 Subject: [PATCH] fix: preserve Home Assistant area target exclusions (#1511) * Exclude 'service' lights (entity_category) from area intercept turn-on Fixes #1510 When Adaptive Lighting intercepts an area/label-targeted light.turn_on, the intercept rewrites the call to target only the managed lights (see modify_service_data), so Home Assistant's own handler turns on only those. AL then re-issues light.turn_on for the remaining 'skipped' (unmanaged) entities so they still come on. The problem: HA excludes entities with an entity_category (config/diagnostic, e.g. the Home Assistant Voice LED ring) from area/label expansion, but AL's re-issue did not, so AL was the sole thing turning these service lights on. Changes: - Keep re-issuing skipped lights so unmanaged normal lights still come on, but filter out 'service' lights (entity_category set) from that re-issue. - Add _is_service_light helper (registry-based) and divert a *managed* service light to 'skipped' in _separate_entity_ids, so it is excluded from the intercept turn-on while still being adapted when on. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add toggle regression test for service light exclusion from area intercept Mirrors test_service_light_excluded_from_area_intercept_turn_on but uses light.toggle: a service light (entity_category set) in an area must remain off when AL intercepts an area toggle. The managed lights still toggle on. Refs #1510, PR #1511. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Only exclude service lights from indirect (area/device/label) expansion Previously _is_service_light filtered service lights unconditionally, which also excluded a service light explicitly named in `entity_id`. Home Assistant only excludes such lights from indirect area/device/label expansion and turns them on when directly targeted, so AL must mirror that: service lights are now excluded from the intercept/re-issue only when not directly targeted (`direct_entity_ids`). Adds a regression test for the direct-target case. Refs #1510, PR #1511. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use if/elif/else for indirect service-light exclusion; inline check Address review: collapse the two separate `if is_service`/"if not is_service" into an `if/elif/else` chain (ruff PLR5501) and inline the service check as `self._is_service_light(...) and entity_id not in direct_entity_ids`, which conceptually is `is_indirect_service`. Matches HA's indirect-only exclusion and keeps the flow simple. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Filter service lights directly in _get_entity_list expansion Move service-light filtering to the single expansion site AdaptiveLightingManager._get_entity_list. Area/device expansion is the only place where HA excludes entity_category lights, so filtering there mirrors HA and makes the later direct_entity_ids / skipped_normal guards unnecessary. Explicit entity_id targets bypass expansion and therefore still turn on service lights, matching HA. Fully reverts the direct_entity_ids / skipped_normal addition per review. --------- Co-authored-by: mueslo Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 8 ++ tests/test_switch.py | 78 ++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 0cd23a7b..fb210744 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1859,6 +1859,13 @@ class AdaptiveLightingManager: self._context_cnt += 1 return context + def _is_excluded_from_area(self, entity_id: str) -> bool: + """Match Home Assistant's exclusions for indirect area targets.""" + entry = entity_registry.async_get(self.hass).async_get(entity_id) + return entry is not None and ( + entry.entity_category is not None or entry.hidden_by is not None + ) + def _separate_entity_ids( self, entity_ids: list[str], @@ -2398,6 +2405,7 @@ class AdaptiveLightingManager: entity_id for entity_id in area_entity_ids if entity_id.startswith(LIGHT_DOMAIN) + and not self._is_excluded_from_area(entity_id) ] entity_ids.extend(eids) _LOGGER.debug( diff --git a/tests/test_switch.py b/tests/test_switch.py index 3ba39aca..148c8bd3 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -38,6 +38,7 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_INITIAL_TRANSITION, CONF_MANUAL_CONTROL, CONF_MAX_BRIGHTNESS, + CONF_MIN_BRIGHTNESS, CONF_MIN_COLOR_TEMP, CONF_MULTI_LIGHT_INTERCEPT, CONF_PREFER_RGB_COLOR, @@ -112,6 +113,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + EntityCategory, ) from homeassistant.const import __version__ as ha_version from homeassistant.core import Context, Event, HomeAssistant, State @@ -1299,7 +1301,11 @@ async def test_state_change_handlers(hass): 4. Assert all possible problems that would result. Also tests significant changes. """ - switch, (light, *_) = await setup_lights_and_switch(hass) + # Keep adaptive brightness distinct from the manual values 20, 40, and 50. + switch, (light, *_) = await setup_lights_and_switch( + hass, + {CONF_MIN_BRIGHTNESS: 50, CONF_MAX_BRIGHTNESS: 50}, + ) context = switch.create_context("test") # needs to be passed to update method # [Config options]: @@ -3339,3 +3345,73 @@ def test_validate_yaml_data_wins_over_stray_options(): result = validate(entry) assert result[CONF_LIGHTS] == ["light.a"] + + +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TOGGLE]) +@pytest.mark.parametrize("explicit", [False, True], ids=["area", "direct"]) +@pytest.mark.parametrize("managed", [False, True], ids=["unmanaged", "managed"]) +@pytest.mark.parametrize( + "registry_settings", + [ + {}, + {"entity_category": EntityCategory.CONFIG}, + {"entity_category": EntityCategory.DIAGNOSTIC}, + {"hidden_by": entity_registry.RegistryEntryHider.USER}, + ], + ids=["normal", "config", "diagnostic", "hidden"], +) +async def test_intercept_preserves_area_target_exclusions( + hass: HomeAssistant, + service: str, + explicit: bool, + managed: bool, + registry_settings: dict[str, Any], +): + """Area calls exclude hidden/categorized lights; direct calls honor them.""" + await setup_lights(hass) + mock_area_registry(hass) + registry = entity_registry.async_get(hass) + lights = [ENTITY_LIGHT_1, ENTITY_LIGHT_2, ENTITY_LIGHT_3] + for light in lights: + registry.async_update_entity(light, area_id="test-area") + registry.async_update_entity(ENTITY_LIGHT_3, **registry_settings) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: lights}, + blocking=True, + ) + await hass.async_block_till_done() + await setup_switch( + hass, + { + CONF_LIGHTS: ( + [ENTITY_LIGHT_1, ENTITY_LIGHT_3] if managed else [ENTITY_LIGHT_1] + ), + CONF_INTERCEPT: True, + CONF_INITIAL_TRANSITION: 0, + CONF_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + assert all(hass.states.get(light).state == STATE_OFF for light in lights) + + target = {ATTR_ENTITY_ID: lights} if explicit else {ATTR_AREA_ID: "test-area"} + await hass.services.async_call(LIGHT_DOMAIN, service, target, blocking=True) + await hass.async_block_till_done() + + # Both normal lights turn on; only the managed one gets adaptive brightness. + assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 128 + assert hass.states.get(ENTITY_LIGHT_2).state == STATE_ON + assert hass.states.get(ENTITY_LIGHT_2).attributes.get(ATTR_BRIGHTNESS) != 128 + target_state = hass.states.get(ENTITY_LIGHT_3) + if registry_settings and not explicit: + assert target_state.state == STATE_OFF + else: + assert target_state.state == STATE_ON + if managed: + assert target_state.attributes[ATTR_BRIGHTNESS] == 128 + else: + assert target_state.attributes.get(ATTR_BRIGHTNESS) != 128