Expose active Hue scene applied to grouped lights (#151883)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Steven
2026-09-15 11:48:14 +02:00
committed by GitHub
co-authored by Claude Opus 4.6 Cursor Erik Montnemery
parent afbd203c00
commit 2de2d6ef0c
12 changed files with 996 additions and 17 deletions
+6
View File
@@ -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
)
+5
View File
@@ -27,6 +27,11 @@
}
}
}
},
"select": {
"active_scene": {
"default": "mdi:palette"
}
}
},
"services": {
+14 -5
View File
@@ -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(
+22
View File
@@ -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)
@@ -130,6 +130,11 @@
}
}
},
"select": {
"active_scene": {
"name": "Scene"
}
},
"sensor": {
"zigbee_connectivity": {
"name": "Zigbee connectivity",
+5 -1
View File
@@ -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 = {
+195
View File
@@ -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,
)
+3
View File
@@ -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
@@ -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",
+1
View File
@@ -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",
+106 -10
View File
@@ -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
+625
View File
@@ -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",
]