From b00ff0bb11e0199630a5bb5199b9c358ea47ea01 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 1 Aug 2026 23:10:31 +0200 Subject: [PATCH] Cleanup broken switch entities for Hue automations (#177671) Co-authored-by: Jan Bouwhuis --- homeassistant/components/hue/switch.py | 39 ++++++++++- tests/components/hue/const.py | 24 +++++++ .../components/hue/fixtures/v2_resources.json | 1 + tests/components/hue/test_switch.py | 69 ++++++++++++++++++- 4 files changed, 130 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index c6cd386f59ef..cd27d66cf0dc 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -1,5 +1,6 @@ """Support for switch platform for Hue resources (V2 only).""" +from collections.abc import Callable from typing import Any, override from aiohue.v2 import HueBridgeV2 @@ -11,17 +12,20 @@ from aiohue.v2.controllers.sensors import ( Motion, MotionController, ) +from aiohue.v2.models.behavior_script import BehaviorScriptCategory from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import HueConfigEntry +from .const import DOMAIN from .v2.entity import HueBaseEntity @@ -48,12 +52,15 @@ async def async_setup_entry( | HueLightSensorEnabledEntity | HueMotionSensorEnabledEntity ], + resource_filter: Callable[[Any], bool] | None = None, ): @callback def async_add_entity( event_type: EventType, resource: BehaviorInstance | LightLevel | Motion ) -> None: """Add entity from Hue resource.""" + if resource_filter is not None and not resource_filter(resource): + return async_add_entities([switch_class(bridge, controller, resource)]) # add all current items in controller @@ -67,10 +74,38 @@ async def async_setup_entry( ) ) + @callback + def is_user_automation(resource: BehaviorInstance) -> bool: + """Return if the behavior instance is an automation from the Hue app. + + Anything else is device configuration, which the bridge keeps running + even after it accepts switching it off. Categories we do not recognise + are skipped too, better no switch than one that does nothing. + """ + script = api.config.behavior_script.get(resource.script_id) + return ( + script is not None + and script.metadata.category is BehaviorScriptCategory.AUTOMATION + ) + + # clean up entities previously created for internal behavior instances + entity_registry = er.async_get(hass) + for resource in api.config.behavior_instance: + if is_user_automation(resource): + continue + if entity_id := entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, resource.id + ): + entity_registry.async_remove(entity_id) + # setup for each switch-type hue resource register_items(api.sensors.motion, HueMotionSensorEnabledEntity) register_items(api.sensors.light_level, HueLightSensorEnabledEntity) - register_items(api.config.behavior_instance, HueBehaviorInstanceEnabledEntity) + register_items( + api.config.behavior_instance, + HueBehaviorInstanceEnabledEntity, + is_user_automation, + ) class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity): diff --git a/tests/components/hue/const.py b/tests/components/hue/const.py index 57a590ab1af4..12cae5db14e8 100644 --- a/tests/components/hue/const.py +++ b/tests/components/hue/const.py @@ -138,3 +138,27 @@ FAKE_ROTARY = { }, "type": "relative_rotary", } + +FAKE_BEHAVIOR_SCRIPT = { + "configuration_schema": {}, + "description": "Generic switches script", + "id": "fake_behavior_script_id_1", + "metadata": {"category": "accessory", "name": "Hue Accessories"}, + "state_schema": {}, + "supported_features": [], + "trigger_schema": {}, + "type": "behavior_script", + "version": "0.0.1", +} + +FAKE_BEHAVIOR_INSTANCE = { + "configuration": {}, + "dependees": [], # codespell:ignore dependees + "enabled": True, + "id": "fake_behavior_instance_id_1", + "last_error": "", + "metadata": {"name": "Wall switch Hallway"}, + "script_id": "fake_behavior_script_id_1", + "status": "running", + "type": "behavior_instance", +} diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index d567360634e3..831a499bd593 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2235,6 +2235,7 @@ "description": "Countdown Timer", "id": "e73bc72d-96b1-46f8-aa57-729861f80c78", "metadata": { + "category": "automation", "name": "Timers" }, "state_schema": { diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index 0b951010f586..2e02f5d2b858 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -2,12 +2,22 @@ from unittest.mock import Mock +import pytest + +from homeassistant.components.hue.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util.json import JsonArrayType from .conftest import setup_platform -from .const import FAKE_BINARY_SENSOR, FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY +from .const import ( + FAKE_BEHAVIOR_INSTANCE, + FAKE_BEHAVIOR_SCRIPT, + FAKE_BINARY_SENSOR, + FAKE_DEVICE, + FAKE_ZIGBEE_CONNECTIVITY, +) async def test_switch( @@ -131,3 +141,60 @@ async def test_switch_added(hass: HomeAssistant, mock_bridge_v2: Mock) -> None: test_entity = hass.states.get(test_entity_id) assert test_entity is not None assert test_entity.state == "off" + + +@pytest.mark.parametrize( + "metadata", + [ + pytest.param( + {"name": "Hue Accessories", "category": "accessory"}, id="accessory" + ), + pytest.param( + {"name": "Light state after streaming", "category": "entertainment"}, + id="entertainment", + ), + pytest.param({"name": "Old bridge script"}, id="no_category"), + ], +) +async def test_internal_behavior_instance_not_added( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + metadata: dict, +) -> None: + """Test internal behavior instances are not exposed as switches. + + The bridge accepts a change to `enabled` on these but keeps running them, + so a switch for them would silently do nothing. Bridges that do not report + a category at all are skipped for the same reason. + """ + internal_script = {**FAKE_BEHAVIOR_SCRIPT, "metadata": metadata} + await mock_bridge_v2.api.load_test_data( + [*v2_resources_test_data, internal_script, FAKE_BEHAVIOR_INSTANCE] + ) + + await setup_platform(hass, mock_bridge_v2, Platform.SWITCH) + + assert hass.states.get("switch.philips_hue_automation_wall_switch_hallway") is None + assert hass.states.get("switch.philips_hue_automation_timer_test") is not None + assert len(hass.states.async_all()) == 4 + + +async def test_internal_behavior_instance_entity_removed( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + entity_registry: er.EntityRegistry, +) -> None: + """Test a previously created entity for an internal instance is removed.""" + # Simulate an entity created with a previous version of the integration + stale_entity = entity_registry.async_get_or_create( + Platform.SWITCH, DOMAIN, FAKE_BEHAVIOR_INSTANCE["id"] + ) + await mock_bridge_v2.api.load_test_data( + [*v2_resources_test_data, FAKE_BEHAVIOR_SCRIPT, FAKE_BEHAVIOR_INSTANCE] + ) + + await setup_platform(hass, mock_bridge_v2, Platform.SWITCH) + + assert entity_registry.async_get(stale_entity.entity_id) is None