Cleanup broken switch entities for Hue automations (#177671)

Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
This commit is contained in:
Marcel van der Veldt
2026-08-01 23:10:31 +02:00
committed by GitHub
co-authored by Jan Bouwhuis
parent 92d5741fec
commit b00ff0bb11
4 changed files with 130 additions and 3 deletions
+37 -2
View File
@@ -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):
+24
View File
@@ -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",
}
@@ -2235,6 +2235,7 @@
"description": "Countdown Timer",
"id": "e73bc72d-96b1-46f8-aa57-729861f80c78",
"metadata": {
"category": "automation",
"name": "Timers"
},
"state_schema": {
+68 -1
View File
@@ -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