From 2de2d6ef0cd4fa0a8c62880ee39dffffb010b0ba Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 15 Sep 2026 11:48:14 +0200 Subject: [PATCH] Expose active Hue scene applied to grouped lights (#151883) Co-authored-by: Claude Opus 4.6 Co-authored-by: Cursor Co-authored-by: Erik Montnemery --- homeassistant/components/hue/bridge.py | 6 + homeassistant/components/hue/icons.json | 5 + homeassistant/components/hue/scene.py | 19 +- homeassistant/components/hue/select.py | 22 + homeassistant/components/hue/strings.json | 5 + homeassistant/components/hue/v2/entity.py | 6 +- homeassistant/components/hue/v2/select.py | 195 ++++++ tests/components/hue/conftest.py | 3 + .../components/hue/fixtures/v2_resources.json | 10 +- tests/components/hue/test_bridge.py | 1 + tests/components/hue/test_scene.py | 116 +++- tests/components/hue/test_select.py | 625 ++++++++++++++++++ 12 files changed, 996 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/hue/select.py create mode 100644 homeassistant/components/hue/v2/select.py create mode 100644 tests/components/hue/test_select.py diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index c1bf684236a7..b5c18385a603 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -9,6 +9,7 @@ import aiohttp from aiohttp import client_exceptions from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized from aiohue.errors import AiohueException, BridgeBusy +from aiohue.v2.scene_activity import SceneActivityTracker from homeassistant import core from homeassistant.components import persistent_notification @@ -31,6 +32,7 @@ PLATFORMS_v2 = [ Platform.EVENT, Platform.LIGHT, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -49,6 +51,7 @@ class HueBridge: # Jobs to be executed when API is reset. self.reset_jobs: list[core.CALLBACK_TYPE] = [] self.sensor_manager: SensorManager | None = None + self.scene_activity_tracker: SceneActivityTracker | None = None self.logger = logging.getLogger(__name__) # store actual api connection to bridge as api app_key: str = self.config_entry.data[CONF_API_KEY] @@ -121,6 +124,9 @@ class HueBridge: else: await async_setup_devices(self) await async_setup_hue_events(self) + self.scene_activity_tracker = SceneActivityTracker(self.api.scenes) + self.scene_activity_tracker.start() + self.reset_jobs.append(self.scene_activity_tracker.stop) await self.hass.config_entries.async_forward_entry_setups( self.config_entry, PLATFORMS_v2 ) diff --git a/homeassistant/components/hue/icons.json b/homeassistant/components/hue/icons.json index 92079afce589..459c4529452d 100644 --- a/homeassistant/components/hue/icons.json +++ b/homeassistant/components/hue/icons.json @@ -27,6 +27,11 @@ } } } + }, + "select": { + "active_scene": { + "default": "mdi:palette" + } } }, "services": { diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 00681795359e..034bc8902979 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -6,8 +6,10 @@ from typing import Any, override from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.scenes import ScenesController +from aiohue.v2.models.room import Room from aiohue.v2.models.scene import Scene as HueScene, ScenePut as HueScenePut from aiohue.v2.models.smart_scene import SmartScene as HueSmartScene, SmartSceneState +from aiohue.v2.models.zone import Zone import probatio from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity @@ -50,13 +52,21 @@ async def async_setup_entry( event_type: EventType, resource: HueScene | HueSmartScene ) -> None: """Add entity from Hue resource.""" + if (group := api.scenes.get_group(resource.id)) is None: + LOGGER.warning( + "Skipping Hue scene %s: group %s could not be resolved", + resource.id, + resource.group.rid, + ) + return + # Catch creation errors to continue adding other scenes even if one fails try: entity: HueSceneEntityBase if isinstance(resource, HueSmartScene): - entity = HueSmartSceneEntity(bridge, api.scenes, resource) + entity = HueSmartSceneEntity(bridge, api.scenes, resource, group) else: - entity = HueSceneEntity(bridge, api.scenes, resource) + entity = HueSceneEntity(bridge, api.scenes, resource, group) except KeyError, StopIteration: LOGGER.exception("Unable to create Hue scene entity for %s", resource.id) return @@ -102,14 +112,13 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity): bridge: HueBridge, controller: ScenesController, resource: HueScene | HueSmartScene, + group: Room | Zone, ) -> None: """Initialize the entity.""" super().__init__(bridge, controller, resource) self.resource = resource self.controller = controller - if (hue_group := self.controller.get_group(self.resource.id)) is None: - raise KeyError(self.resource.group.rid) - self.hue_group = hue_group + self.hue_group = group # we create a virtual service/device for Hue zones/rooms # so we have a parent for grouped lights and scenes self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/hue/select.py b/homeassistant/components/hue/select.py new file mode 100644 index 000000000000..ce05bcf9711f --- /dev/null +++ b/homeassistant/components/hue/select.py @@ -0,0 +1,22 @@ +"""Support for select platform for Hue scenes (V2 only).""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .bridge import HueConfigEntry +from .v2.select import async_setup_entry as setup_entry_v2 + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hue select entities.""" + bridge = config_entry.runtime_data + if bridge.api_version == 1: + # should not happen, but just in case + raise NotImplementedError("Select support is only available for V2 bridges") + await setup_entry_v2(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index bbc8384ed3a0..9d5f82352c5a 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -130,6 +130,11 @@ } } }, + "select": { + "active_scene": { + "name": "Scene" + } + }, "sensor": { "zigbee_connectivity": { "name": "Zigbee connectivity", diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 38616e2c4f32..c14a604bbcc2 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -21,8 +21,12 @@ if TYPE_CHECKING: from aiohue.v2.models.light import Light from aiohue.v2.models.light_level import LightLevel from aiohue.v2.models.motion import Motion + from aiohue.v2.models.room import Room + from aiohue.v2.models.zone import Zone - type HueResource = Light | DevicePower | GroupedLight | LightLevel | Motion + type HueResource = ( + Light | DevicePower | GroupedLight | LightLevel | Motion | Room | Zone + ) RESOURCE_TYPE_NAMES = { diff --git a/homeassistant/components/hue/v2/select.py b/homeassistant/components/hue/v2/select.py new file mode 100644 index 000000000000..291a926e4f86 --- /dev/null +++ b/homeassistant/components/hue/v2/select.py @@ -0,0 +1,195 @@ +"""Select entities for Hue scene selection per group.""" + +from typing import override + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.groups import RoomController, ZoneController +from aiohue.v2.models.room import Room +from aiohue.v2.models.scene import Scene as HueScene +from aiohue.v2.models.smart_scene import SmartScene as HueSmartScene +from aiohue.v2.models.zone import Zone +from aiohue.v2.scene_activity import SceneActivityTracker + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from ..bridge import HueBridge, HueConfigEntry +from ..const import DOMAIN +from .entity import HueBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hue scene select entities from a config entry.""" + bridge = config_entry.runtime_data + api: HueBridgeV2 = bridge.api + tracker = bridge.scene_activity_tracker + assert tracker is not None + + # Prepare initial options before entity registration reads capabilities. + scenes_by_group: dict[str, list[HueScene | HueSmartScene]] = {} + for scene in api.scenes: + scenes_by_group.setdefault(scene.group.rid, []).append(scene) + + @callback + def _on_group_added(_: EventType, group: Room | Zone) -> None: + controller = api.groups.room if isinstance(group, Room) else api.groups.zone + async_add_entities([HueSceneSelectEntity(bridge, tracker, controller, group)]) + + for group_controller in (api.groups.room, api.groups.zone): + async_add_entities( + HueSceneSelectEntity( + bridge, + tracker, + group_controller, + group, + scenes_by_group.get(group.id, []), + ) + for group in group_controller + ) + config_entry.async_on_unload( + group_controller.subscribe( + _on_group_added, event_filter=EventType.RESOURCE_ADDED + ) + ) + + +def _build_scene_option_maps( + scenes: list[HueScene | HueSmartScene], +) -> tuple[dict[str, str], dict[str, str]]: + """Build bidirectional option maps for a scene collection.""" + # Sort for a stable option order across restarts and updates. + scenes = sorted(scenes, key=lambda s: (s.metadata.name, s.id)) + option_to_scene_id: dict[str, str] = {} + scene_id_to_option: dict[str, str] = {} + + for scene in scenes: + # Hue allows duplicate scene names within a group; number the repeats. + option = scene.metadata.name + repeat = 1 + while option in option_to_scene_id: + repeat += 1 + option = f"{scene.metadata.name} ({repeat})" + option_to_scene_id[option] = scene.id + scene_id_to_option[scene.id] = option + + return option_to_scene_id, scene_id_to_option + + +# pylint: disable-next=home-assistant-enforce-class-module +class HueSceneSelectEntity(HueBaseEntity, SelectEntity): + """Select entity showing and controlling the active scene of a Hue group.""" + + _attr_has_entity_name = True + _attr_translation_key = "active_scene" + _option_to_scene_id: dict[str, str] + _scene_id_to_option: dict[str, str] + _scene_id_to_name: dict[str, str] + + def __init__( + self, + bridge: HueBridge, + tracker: SceneActivityTracker, + controller: RoomController | ZoneController, + group: Room | Zone, + initial_scenes: list[HueScene | HueSmartScene] | None = None, + ) -> None: + """Initialize the scene select entity.""" + super().__init__(bridge, controller, group) + self._tracker = tracker + self._group_id = group.id + self._group_state = tracker.get_group_state(self._group_id) + # Attach to the virtual Hue group device (same as grouped lights and scenes). + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.resource.id)}, + ) + self._attr_unique_id = f"{self._group_id}_scene_select" + self.refresh_options(initial_scenes) + + @override + async def async_added_to_hass(self) -> None: + """Register listeners when added to Home Assistant.""" + await super().async_added_to_hass() + + @callback + def _on_tracker_update(_: str) -> None: + self._group_state = self._tracker.get_group_state(self._group_id) + self.async_write_ha_state() + + self.async_on_remove( + self._tracker.subscribe(self._group_id, _on_tracker_update) + ) + self.async_on_remove( + self.bridge.api.scenes.subscribe( + self._handle_scene_event, + event_filter=( + EventType.RESOURCE_ADDED, + EventType.RESOURCE_UPDATED, + EventType.RESOURCE_DELETED, + ), + ) + ) + self.refresh_options() + + @callback + def _handle_scene_event( + self, event_type: EventType, scene: HueScene | HueSmartScene + ) -> None: + """Refresh options when this group's scenes change.""" + if scene.group.rid != self._group_id: + return + # Skip rebuild on status updates where the name hasn't changed. + if event_type == EventType.RESOURCE_UPDATED and self._scene_option_matches_name( + scene.id, scene.metadata.name + ): + return + self.refresh_options() + self.async_write_ha_state() + + def _scene_option_matches_name(self, scene_id: str, name: str) -> bool: + """Return if the current option label still matches an unchanged scene name.""" + return self._scene_id_to_name.get(scene_id) == name + + def refresh_options( + self, scenes: list[HueScene | HueSmartScene] | None = None + ) -> None: + """Rebuild the name-to-ID map of scenes available for this group.""" + if scenes is None: + scenes = [ + scene + for scene in self.bridge.api.scenes + if scene.group.rid == self._group_id + ] + self._scene_id_to_name = {scene.id: scene.metadata.name for scene in scenes} + self._option_to_scene_id, self._scene_id_to_option = _build_scene_option_maps( + scenes + ) + + @property + @override + def options(self) -> list[str]: + """Return the available scene names for this group.""" + return list(self._option_to_scene_id) + + @property + @override + def current_option(self) -> str | None: + """Return the name of the currently active scene.""" + if not (scene_id := self._group_state.scene_id): + return None + return self._scene_id_to_option.get(scene_id) + + @override + async def async_select_option(self, option: str) -> None: + """Activate the scene with the given name.""" + scene_id = self._option_to_scene_id[option] + await self.bridge.async_request_call( + self.bridge.api.scenes.recall, + scene_id, + ) diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 2c951c60ae27..26a379a5ac97 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, Mock, patch import aiohue.v1 as aiohue_v1 import aiohue.v2 as aiohue_v2 from aiohue.v2.controllers.events import EventType +from aiohue.v2.scene_activity import SceneActivityTracker import pytest from homeassistant.components import hue @@ -62,6 +63,8 @@ def create_mock_bridge(hass: HomeAssistant, api_version: int = 1) -> Mock: bridge.config_entry.runtime_data = bridge if bridge.api_version == 2: await async_setup_devices(bridge) + bridge.scene_activity_tracker = SceneActivityTracker(bridge.api.scenes) + bridge.scene_activity_tracker.start() return True bridge.async_initialize_bridge = async_initialize_bridge diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index b52ab82b5be8..09dc7fa35ce6 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -169,6 +169,10 @@ }, "speed": 0.6269841194152832, "auto_dynamic": false, + "status": { + "active": "dynamic_palette", + "last_recall": "2025-09-12T11:41:46.318Z" + }, "type": "scene" }, { @@ -222,10 +226,14 @@ }, "speed": 0.5, "auto_dynamic": false, + "status": { + "active": "static", + "last_recall": "2025-09-12T11:41:46.318Z" + }, "type": "scene" }, { - "id": "redacted-8abe5a3e-94c8-4058-908f-56241818509a", + "id": "8abe5a3e-94c8-4058-908f-56241818509a", "type": "smart_scene", "metadata": { "name": "Smart Test Scene", diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index be283a4e7dc4..1310d3354423 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -170,6 +170,7 @@ async def test_bridge_setup_v2(hass: HomeAssistant, mock_api_v2: Mock) -> None: "light", "binary_sensor", "event", + "select", "sensor", "switch", "scene", diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index fb0b845072d9..35f0d4a3b3c5 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -1,5 +1,7 @@ """Philips Hue scene platform tests for V2 bridge/api.""" +from copy import deepcopy +import logging from unittest.mock import Mock import pytest @@ -205,25 +207,119 @@ async def test_scene_updates( assert test_entity is None +@pytest.mark.parametrize( + "resource_type", + [pytest.param("scene", id="regular"), pytest.param("smart_scene", id="smart")], +) async def test_scene_with_orphaned_group( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType, caplog: pytest.LogCaptureFixture, + resource_type: str, ) -> None: """Test that a scene referencing a non-existent group is skipped and logged.""" - orphaned_scene = { - **FAKE_SCENE, - "id": "orphaned_scene_id", - "group": {"rid": "non-existent-group-id", "rtype": "room"}, - } + caplog.set_level(logging.WARNING) + orphaned_scene = deepcopy( + next( + resource + for resource in v2_resources_test_data + if resource["type"] == resource_type + ) + ) + orphaned_scene["id"] = "orphaned_scene_id" + orphaned_scene["group"] = {"rid": "non-existent-group-id", "rtype": "room"} await mock_bridge_v2.api.load_test_data([*v2_resources_test_data, orphaned_scene]) await setup_platform(hass, mock_bridge_v2, Platform.SCENE) - # the orphaned scene should not be created as an entity - assert hass.states.get("scene.test_room_mocked_scene_orphaned") is None - # the valid scenes should still be created + assert ( + entity_registry.async_get_entity_id( + Platform.SCENE, DOMAIN, orphaned_scene["id"] + ) + is None + ) assert len(hass.states.async_all()) == 3 - # an error should be logged for the orphaned scene - assert "Unable to create Hue scene entity for orphaned_scene_id" in caplog.text + record = next( + record + for record in caplog.records + if record.name == "homeassistant.components.hue.scene" + ) + assert record.levelno == logging.WARNING + assert record.getMessage() == ( + "Skipping Hue scene orphaned_scene_id: group non-existent-group-id " + "could not be resolved" + ) + assert record.exc_info is None + + +@pytest.mark.parametrize( + "resource_type", + [pytest.param("scene", id="regular"), pytest.param("smart_scene", id="smart")], +) +async def test_scene_added_after_group_deleted( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + caplog: pytest.LogCaptureFixture, + resource_type: str, +) -> None: + """Test a late scene for a deleted group is skipped without blocking valid scenes.""" + caplog.set_level(logging.WARNING) + room = next( + resource for resource in v2_resources_test_data if resource["type"] == "room" + ) + zone = next( + resource for resource in v2_resources_test_data if resource["type"] == "zone" + ) + late_scene = deepcopy( + next( + resource + for resource in v2_resources_test_data + if resource["type"] == resource_type + and resource["group"]["rid"] == room["id"] + ) + ) + late_scene["id"] = "late_scene_id" + late_scene["metadata"]["name"] = "Late scene" + + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, Platform.SCENE) + + mock_bridge_v2.api.emit_event("delete", {"type": "room", "id": room["id"]}) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert room["id"] not in mock_bridge_v2.api.groups + + caplog.clear() + mock_bridge_v2.api.emit_event("add", late_scene) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id(Platform.SCENE, DOMAIN, late_scene["id"]) + is None + ) + record = next( + record + for record in caplog.records + if record.name == "homeassistant.components.hue.scene" + ) + assert record.levelno == logging.WARNING + assert record.getMessage() == ( + f"Skipping Hue scene late_scene_id: group {room['id']} could not be resolved" + ) + assert record.exc_info is None + + valid_scene = deepcopy(late_scene) + valid_scene["id"] = "valid_scene_id" + valid_scene["group"] = {"rid": zone["id"], "rtype": "zone"} + mock_bridge_v2.api.emit_event("add", valid_scene) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + Platform.SCENE, DOMAIN, valid_scene["id"] + ) + assert entity_id is not None + assert hass.states.get(entity_id) is not None diff --git a/tests/components/hue/test_select.py b/tests/components/hue/test_select.py new file mode 100644 index 000000000000..1bdfa677d74d --- /dev/null +++ b/tests/components/hue/test_select.py @@ -0,0 +1,625 @@ +"""Tests for Hue scene select entities.""" + +from __future__ import annotations + +import asyncio +from copy import deepcopy +from unittest.mock import Mock, patch + +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.scene import Scene as HueScene +import pytest + +from homeassistant.components.hue.v2.select import HueSceneSelectEntity +from homeassistant.const import STATE_UNKNOWN, 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 + +TEST_ROOM_ID = "6ddc9066-7e7d-4a03-a773-c73937968296" +TEST_ZONE_ID = "7cee478d-6455-483a-9e32-9f9fdcbcc4f6" +TEST_ROOM_SCENE_ENTITY = "select.test_room_test_room_scene" +DUPLICATE_SCENE_ID = "22222222-3333-4444-8555-666666666666" +LITERAL_SUFFIX_SCENE_ID = "33333333-4444-4555-8666-777777777777" + + +async def test_scene_select_initial_state( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test that scene select entities are created with correct initial state.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + # A smart scene and its effective regular scene can both be active. The smart + # scene is the top-level selection shown by the Hue app. + state = hass.states.get("select.test_room_test_room_scene") + assert state is not None + assert state.state == "Smart Test Scene" + assert state.attributes["options"] == [ + "Regular Test Scene", + "Smart Test Scene", + ] + assert hass.states.get("select.test_room_test_room_smart_scene") is None + + # Test Zone has "Dynamic Test Scene" active (dynamic_palette) from fixture + state = hass.states.get("select.test_zone_scene") + assert state is not None + assert state.state == "Dynamic Test Scene" + assert state.attributes["options"] == ["Dynamic Test Scene"] + assert hass.states.get("select.test_zone_smart_scene") is None + + +async def test_scene_select_becomes_inactive( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test that the select entity reflects unknown state when no scene is active.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + # The active smart scene takes precedence over its effective regular scene. + assert ( + hass.states.get("select.test_room_test_room_scene").state == "Smart Test Scene" + ) + + smart_scene_id = "8abe5a3e-94c8-4058-908f-56241818509a" + regular_scene_id = "cdbf3740-7977-4a11-8275-8c78636ad4bd" + + # When the smart scene stops, fall back to the still-active regular scene. + mock_bridge_v2.api.emit_event( + "update", + {"id": smart_scene_id, "type": "smart_scene", "state": "inactive"}, + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("select.test_room_test_room_scene").state + == "Regular Test Scene" + ) + + # Once both scenes are inactive, the select has no active option. + mock_bridge_v2.api.emit_event( + "update", + { + "id": regular_scene_id, + "type": "scene", + "status": {"active": "inactive"}, + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("select.test_room_test_room_scene").state == STATE_UNKNOWN + + # Reactivate the scene + mock_bridge_v2.api.emit_event( + "update", + { + "id": regular_scene_id, + "type": "scene", + "status": { + "active": "static", + "last_recall": "2025-12-31T23:59:59.999Z", + }, + }, + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("select.test_room_test_room_scene").state + == "Regular Test Scene" + ) + + +async def test_scene_select_activate_option( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test that selecting an option calls the bridge scene recall API.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + # Select an option by calling the select_option service + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.test_room_test_room_scene", + "option": "Regular Test Scene", + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Bridge API should have been called with the correct scene id + regular_scene_id = "cdbf3740-7977-4a11-8275-8c78636ad4bd" + assert len(mock_bridge_v2.mock_requests) == 1 + path = mock_bridge_v2.mock_requests[0]["path"] + assert "/scene/" in path + assert regular_scene_id in path + + +async def test_scene_select_disambiguates_duplicate_names( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test duplicate regular scene names are exposed and recalled distinctly.""" + test_data = deepcopy(v2_resources_test_data) + duplicate_scene = deepcopy( + next( + resource + for resource in test_data + if resource["type"] == "scene" + and resource["metadata"]["name"] == "Regular Test Scene" + ) + ) + duplicate_scene["id"] = DUPLICATE_SCENE_ID + duplicate_scene["status"] = { + "active": "inactive", + "last_recall": "2025-09-12T11:41:46.318Z", + } + test_data.append(duplicate_scene) + + await mock_bridge_v2.api.load_test_data(test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + state = hass.states.get("select.test_room_test_room_scene") + assert state is not None + assert state.state == "Smart Test Scene" + # The duplicate sorts before the original on scene id, so it keeps the bare name. + assert state.attributes["options"] == [ + "Regular Test Scene", + "Regular Test Scene (2)", + "Smart Test Scene", + ] + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.test_room_test_room_scene", + "option": "Regular Test Scene", + }, + blocking=True, + ) + await hass.async_block_till_done() + + last_request = mock_bridge_v2.mock_requests[-1] + assert "/scene/" in last_request["path"] + assert DUPLICATE_SCENE_ID in last_request["path"] + + +@pytest.mark.parametrize( + ("option", "expected_scene_id"), + [ + pytest.param( + "Regular Test Scene", + DUPLICATE_SCENE_ID, + id="duplicate_keeps_bare_name", + ), + pytest.param( + "Regular Test Scene (2) (2)", + LITERAL_SUFFIX_SCENE_ID, + id="literal_name_is_disambiguated", + ), + ], +) +async def test_scene_select_disambiguated_label_does_not_shadow_scene_name( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + option: str, + expected_scene_id: str, +) -> None: + """Test a generated duplicate label cannot shadow a literal scene name.""" + test_data = deepcopy(v2_resources_test_data) + regular_scene = next( + resource + for resource in test_data + if resource["type"] == "scene" + and resource["metadata"]["name"] == "Regular Test Scene" + ) + + duplicate_scene = deepcopy(regular_scene) + duplicate_scene["id"] = DUPLICATE_SCENE_ID + duplicate_scene["status"]["active"] = "inactive" + test_data.append(duplicate_scene) + + literal_suffix_scene = deepcopy(regular_scene) + literal_suffix_scene["id"] = LITERAL_SUFFIX_SCENE_ID + literal_suffix_scene["metadata"]["name"] = "Regular Test Scene (2)" + literal_suffix_scene["status"]["active"] = "inactive" + test_data.append(literal_suffix_scene) + + await mock_bridge_v2.api.load_test_data(test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + state = hass.states.get(TEST_ROOM_SCENE_ENTITY) + assert state is not None + assert state.attributes["options"] == [ + "Regular Test Scene", + "Regular Test Scene (2)", + "Regular Test Scene (2) (2)", + "Smart Test Scene", + ] + + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "select", + "select_option", + {"entity_id": TEST_ROOM_SCENE_ENTITY, "option": option}, + blocking=True, + ) + assert expected_scene_id in mock_bridge_v2.mock_requests[0]["path"] + + +async def test_scene_select_refreshes_options_for_scene_events( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test add, rename, and delete events refresh the unified scene options.""" + test_data = deepcopy(v2_resources_test_data) + regular_scene = next( + resource + for resource in test_data + if resource["type"] == "scene" + and resource["metadata"]["name"] == "Regular Test Scene" + ) + smart_scene = next( + resource for resource in test_data if resource["type"] == "smart_scene" + ) + + await mock_bridge_v2.api.load_test_data(test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + added_scene = deepcopy(regular_scene) + added_scene["id"] = "22222222-3333-4444-8555-666666666666" + added_scene["metadata"]["name"] = "Added scene" + added_scene["status"]["active"] = "inactive" + mock_bridge_v2.api.emit_event("add", added_scene) + await hass.async_block_till_done() + + state = hass.states.get("select.test_room_test_room_scene") + assert state is not None + assert state.attributes["options"] == [ + "Added scene", + "Regular Test Scene", + "Smart Test Scene", + ] + + renamed_scene = deepcopy(added_scene) + renamed_scene["metadata"]["name"] = "Renamed scene" + mock_bridge_v2.api.emit_event("update", renamed_scene) + await hass.async_block_till_done() + + state = hass.states.get("select.test_room_test_room_scene") + assert state.attributes["options"] == [ + "Regular Test Scene", + "Renamed scene", + "Smart Test Scene", + ] + + # Deleting the active smart scene removes its option and exposes its effective + # regular scene as the current selection. + mock_bridge_v2.api.emit_event("delete", smart_scene) + await hass.async_block_till_done() + + state = hass.states.get("select.test_room_test_room_scene") + assert state.state == "Regular Test Scene" + assert state.attributes["options"] == [ + "Regular Test Scene", + "Renamed scene", + ] + + +async def test_scene_select_prefers_active_smart_scene( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test smart scene state transitions in the unified scene select.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + # Smart scene starts active + assert ( + hass.states.get("select.test_room_test_room_scene").state == "Smart Test Scene" + ) + + smart_scene_id = "8abe5a3e-94c8-4058-908f-56241818509a" + + # Deactivate smart scene + mock_bridge_v2.api.emit_event( + "update", + {"id": smart_scene_id, "type": "smart_scene", "state": "inactive"}, + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("select.test_room_test_room_scene").state + == "Regular Test Scene" + ) + + # Reactivate smart scene + mock_bridge_v2.api.emit_event( + "update", + {"id": smart_scene_id, "type": "smart_scene", "state": "active"}, + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("select.test_room_test_room_scene").state == "Smart Test Scene" + ) + + +async def test_scene_select_activate_smart_scene_option( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test selecting a smart scene uses the smart scene recall API.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "select", + "select_option", + {"entity_id": "select.test_room_test_room_scene", "option": "Smart Test Scene"}, + blocking=True, + ) + await hass.async_block_till_done() + + smart_scene_id = "8abe5a3e-94c8-4058-908f-56241818509a" + assert len(mock_bridge_v2.mock_requests) == 1 + path = mock_bridge_v2.mock_requests[0]["path"] + assert "/smart_scene/" in path + assert smart_scene_id in path + + +async def test_scene_select_disambiguates_duplicate_smart_scene_names( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test duplicate smart scene names are exposed and recalled distinctly.""" + test_data = deepcopy(v2_resources_test_data) + duplicate_smart_scene = deepcopy( + next(resource for resource in test_data if resource["type"] == "smart_scene") + ) + duplicate_smart_scene_id = "11111111-2222-4333-8444-555555555555" + duplicate_smart_scene["id"] = duplicate_smart_scene_id + duplicate_smart_scene["state"] = "inactive" + test_data.append(duplicate_smart_scene) + + await mock_bridge_v2.api.load_test_data(test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + state = hass.states.get("select.test_room_test_room_scene") + assert state is not None + # The duplicate sorts before the original on scene id, so it keeps the bare name. + assert state.state == "Smart Test Scene (2)" + assert state.attributes["options"] == [ + "Regular Test Scene", + "Smart Test Scene", + "Smart Test Scene (2)", + ] + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.test_room_test_room_scene", + "option": "Smart Test Scene", + }, + blocking=True, + ) + await hass.async_block_till_done() + + last_request = mock_bridge_v2.mock_requests[-1] + assert "/smart_scene/" in last_request["path"] + assert duplicate_smart_scene_id in last_request["path"] + + +async def test_scene_select_disambiguates_names_across_scene_types( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test identical regular and smart scene names remain independently selectable.""" + test_data = deepcopy(v2_resources_test_data) + smart_scene = next( + resource for resource in test_data if resource["type"] == "smart_scene" + ) + smart_scene["metadata"]["name"] = "Regular Test Scene" + + await mock_bridge_v2.api.load_test_data(test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + state = hass.states.get("select.test_room_test_room_scene") + assert state is not None + # The smart scene sorts before the regular scene on scene id. + assert state.state == "Regular Test Scene" + assert state.attributes["options"] == [ + "Regular Test Scene", + "Regular Test Scene (2)", + ] + + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.test_room_test_room_scene", + "option": "Regular Test Scene (2)", + }, + blocking=True, + ) + assert "/scene/" in mock_bridge_v2.mock_requests[0]["path"] + + mock_bridge_v2.mock_requests.clear() + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.test_room_test_room_scene", + "option": "Regular Test Scene", + }, + blocking=True, + ) + assert "/smart_scene/" in mock_bridge_v2.mock_requests[0]["path"] + + +@pytest.mark.parametrize( + ("entity_id", "expected_options"), + [ + ( + "select.test_room_test_room_scene", + ["Regular Test Scene", "Smart Test Scene"], + ), + ("select.test_zone_scene", ["Dynamic Test Scene"]), + ], +) +async def test_scene_select_options( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + entity_id: str, + expected_options: list[str], +) -> None: + """Test that each select entity exposes the correct scene options for its group.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["options"] == expected_options + + +async def test_scene_select_removed_when_group_deleted( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, +) -> None: + """Test that deleting a Hue group removes its scene select entity.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + assert hass.states.get(TEST_ROOM_SCENE_ENTITY) is not None + assert entity_registry.async_get(TEST_ROOM_SCENE_ENTITY) is not None + + mock_bridge_v2.api.emit_event( + "delete", + {"type": "room", "id": TEST_ROOM_ID}, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass.states.get(TEST_ROOM_SCENE_ENTITY) is None + assert entity_registry.async_get(TEST_ROOM_SCENE_ENTITY) is None + + +@pytest.mark.parametrize( + ("source_id", "new_id", "new_name", "entity_id"), + [ + pytest.param( + TEST_ROOM_ID, + "aaaaaaaa-bbbb-4ccc-8ddd-111111111111", + "New Room", + "select.new_room_new_room_scene", + id="room", + ), + pytest.param( + TEST_ZONE_ID, + "aaaaaaaa-bbbb-4ccc-8ddd-222222222222", + "New Zone", + "select.new_zone_scene", + id="zone", + ), + ], +) +async def test_scene_select_created_when_group_added( + hass: HomeAssistant, + mock_bridge_v2: Mock, + v2_resources_test_data: JsonArrayType, + source_id: str, + new_id: str, + new_name: str, + entity_id: str, +) -> None: + """Test that adding a Hue group at runtime creates its scene select entity.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, [Platform.SCENE, Platform.SELECT]) + + assert hass.states.get(entity_id) is None + + new_group = deepcopy( + next( + resource + for resource in v2_resources_test_data + if resource["id"] == source_id + ) + ) + new_group["id"] = new_id + new_group["metadata"]["name"] = new_name + mock_bridge_v2.api.emit_event("add", new_group) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["options"] == [] + + +async def test_scene_select_refreshes_options_missed_before_subscribe( + hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType +) -> None: + """Test options include a scene added after init and before subscribe.""" + test_data = [ + deepcopy(resource) + for resource in v2_resources_test_data + if resource["id"] != TEST_ZONE_ID + ] + regular_scene = next( + resource + for resource in test_data + if resource["type"] == "scene" + and resource["metadata"]["name"] == "Regular Test Scene" + ) + late_scene = deepcopy(regular_scene) + late_scene["id"] = "44444444-5555-4666-8777-888888888888" + late_scene["metadata"]["name"] = "Late scene" + late_scene["status"]["active"] = "inactive" + + original_added_to_hass = HueSceneSelectEntity.async_added_to_hass + + async def async_added_to_hass_with_late_scene( + self: HueSceneSelectEntity, + ) -> None: + assert self.unique_id == f"{TEST_ROOM_ID}_scene_select" + scene_added = asyncio.Event() + + def on_scene_added(_: EventType, scene: HueScene) -> None: + scene_added.set() + + unsubscribe = mock_bridge_v2.api.scenes.subscribe( + on_scene_added, + late_scene["id"], + EventType.RESOURCE_ADDED, + ) + try: + mock_bridge_v2.api.emit_event("add", late_scene) + async with asyncio.timeout(5): + await scene_added.wait() + finally: + unsubscribe() + + assert late_scene["id"] in mock_bridge_v2.api.scenes + assert "Late scene" not in self.options + await original_added_to_hass(self) + + await mock_bridge_v2.api.load_test_data(test_data) + with patch.object( + HueSceneSelectEntity, + "async_added_to_hass", + async_added_to_hass_with_late_scene, + ): + await setup_platform(hass, mock_bridge_v2, Platform.SELECT) + + state = hass.states.get(TEST_ROOM_SCENE_ENTITY) + assert state is not None + assert state.attributes["options"] == [ + "Late scene", + "Regular Test Scene", + "Smart Test Scene", + ]