mirror of
https://github.com/home-assistant/core.git
synced 2026-09-17 05:58:41 +01:00
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:
co-authored by
Claude Opus 4.6
Cursor
Erik Montnemery
parent
afbd203c00
commit
2de2d6ef0c
@@ -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
|
||||
)
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"active_scene": {
|
||||
"default": "mdi:palette"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user