Skip to content

Commit 70fe8ca

Browse files
authored
Fix velux scenes (naming and unique ids) (home-assistant#156436)
1 parent 95eb45a commit 70fe8ca

File tree

5 files changed

+156
-13
lines changed

5 files changed

+156
-13
lines changed

homeassistant/components/velux/quality_scale.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ rules:
1717
docs-removal-instructions: done
1818
entity-event-setup: done
1919
entity-unique-id: done
20-
has-entity-name:
21-
status: todo
22-
comment: scenes need fixing
20+
has-entity-name: done
2321
runtime-data: done
2422
test-before-configure: done
2523
test-before-setup:

homeassistant/components/velux/scene.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
from typing import Any
66

7+
from pyvlx import Scene as PyVLXScene
8+
79
from homeassistant.components.scene import Scene
810
from homeassistant.core import HomeAssistant
11+
from homeassistant.helpers.device_registry import DeviceInfo
912
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
1013

1114
from . import VeluxConfigEntry
15+
from .const import DOMAIN
1216

1317
PARALLEL_UPDATES = 1
1418

@@ -20,22 +24,32 @@ async def async_setup_entry(
2024
) -> None:
2125
"""Set up the scenes for Velux platform."""
2226
pyvlx = config_entry.runtime_data
23-
24-
entities = [VeluxScene(scene) for scene in pyvlx.scenes]
25-
async_add_entities(entities)
27+
async_add_entities(
28+
[VeluxScene(config_entry.entry_id, scene) for scene in pyvlx.scenes]
29+
)
2630

2731

2832
class VeluxScene(Scene):
2933
"""Representation of a Velux scene."""
3034

31-
def __init__(self, scene):
35+
_attr_has_entity_name = True
36+
37+
# Note: there's currently no code to update the scenes dynamically if changed in
38+
# the gateway. They're only loaded on integration setup (they're probably not
39+
# used heavily anyway since it's a pain to set them up in the gateway and so
40+
# much easier to use HA scenes).
41+
42+
def __init__(self, config_entry_id: str, scene: PyVLXScene) -> None:
3243
"""Init velux scene."""
3344
self.scene = scene
34-
35-
@property
36-
def name(self):
37-
"""Return the name of the scene."""
38-
return self.scene.name
45+
# Renaming scenes in gateway keeps scene_id stable, we can use it as unique_id
46+
self._attr_unique_id = f"{config_entry_id}_scene_{scene.scene_id}"
47+
self._attr_name = scene.name
48+
49+
# Associate scenes with the gateway device (where they are stored)
50+
self._attr_device_info = DeviceInfo(
51+
identifiers={(DOMAIN, f"gateway_{config_entry_id}")},
52+
)
3953

4054
async def async_activate(self, **kwargs: Any) -> None:
4155
"""Activate the scene."""

tests/components/velux/conftest.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from homeassistant.components.velux import DOMAIN
99
from homeassistant.components.velux.binary_sensor import Window
1010
from homeassistant.components.velux.light import LighteningDevice
11+
from homeassistant.components.velux.scene import PyVLXScene as Scene
1112
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform
1213
from homeassistant.core import HomeAssistant
1314

@@ -91,10 +92,23 @@ def mock_light() -> AsyncMock:
9192

9293

9394
@pytest.fixture
94-
def mock_pyvlx(mock_window: MagicMock, mock_light: MagicMock) -> Generator[MagicMock]:
95+
def mock_scene() -> AsyncMock:
96+
"""Create a mock Velux scene."""
97+
scene = AsyncMock(spec=Scene, autospec=True)
98+
scene.name = "Test Scene"
99+
scene.scene_id = "1234"
100+
scene.scene = AsyncMock()
101+
return scene
102+
103+
104+
@pytest.fixture
105+
def mock_pyvlx(
106+
mock_window: MagicMock, mock_light: MagicMock, mock_scene: AsyncMock
107+
) -> Generator[MagicMock]:
95108
"""Create the library mock and patch PyVLX."""
96109
pyvlx = MagicMock()
97110
pyvlx.nodes = [mock_window, mock_light]
111+
pyvlx.scenes = [mock_scene]
98112
pyvlx.load_scenes = AsyncMock()
99113
pyvlx.load_nodes = AsyncMock()
100114
pyvlx.disconnect = AsyncMock()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# serializer version: 1
2+
# name: test_scene_snapshot[scene.klf_200_gateway_test_scene-entry]
3+
EntityRegistryEntrySnapshot({
4+
'aliases': set({
5+
}),
6+
'area_id': None,
7+
'capabilities': None,
8+
'config_entry_id': <ANY>,
9+
'config_subentry_id': <ANY>,
10+
'device_class': None,
11+
'device_id': <ANY>,
12+
'disabled_by': None,
13+
'domain': 'scene',
14+
'entity_category': None,
15+
'entity_id': 'scene.klf_200_gateway_test_scene',
16+
'has_entity_name': True,
17+
'hidden_by': None,
18+
'icon': None,
19+
'id': <ANY>,
20+
'labels': set({
21+
}),
22+
'name': None,
23+
'options': dict({
24+
}),
25+
'original_device_class': None,
26+
'original_icon': None,
27+
'original_name': 'Test Scene',
28+
'platform': 'velux',
29+
'previous_unique_id': None,
30+
'suggested_object_id': None,
31+
'supported_features': 0,
32+
'translation_key': None,
33+
'unique_id': 'test_entry_id_scene_1234',
34+
'unit_of_measurement': None,
35+
})
36+
# ---
37+
# name: test_scene_snapshot[scene.klf_200_gateway_test_scene-state]
38+
StateSnapshot({
39+
'attributes': ReadOnlyDict({
40+
'friendly_name': 'KLF 200 Gateway Test Scene',
41+
}),
42+
'context': <ANY>,
43+
'entity_id': 'scene.klf_200_gateway_test_scene',
44+
'last_changed': <ANY>,
45+
'last_reported': <ANY>,
46+
'last_updated': <ANY>,
47+
'state': 'unknown',
48+
})
49+
# ---
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Test Velux scene entities."""
2+
3+
import pytest
4+
from syrupy.assertion import SnapshotAssertion
5+
6+
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON
7+
from homeassistant.components.velux import DOMAIN
8+
from homeassistant.const import ATTR_ENTITY_ID, Platform
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers import device_registry as dr, entity_registry as er
11+
12+
from tests.common import AsyncMock, MockConfigEntry, snapshot_platform
13+
14+
15+
@pytest.fixture
16+
def platform() -> Platform:
17+
"""Fixture to specify platform to test."""
18+
return Platform.SCENE
19+
20+
21+
@pytest.mark.usefixtures("setup_integration")
22+
async def test_scene_snapshot(
23+
hass: HomeAssistant,
24+
mock_config_entry: MockConfigEntry,
25+
entity_registry: er.EntityRegistry,
26+
device_registry: dr.DeviceRegistry,
27+
snapshot: SnapshotAssertion,
28+
) -> None:
29+
"""Snapshot the scene entity (registry + state)."""
30+
await snapshot_platform(
31+
hass,
32+
entity_registry,
33+
snapshot,
34+
mock_config_entry.entry_id,
35+
)
36+
37+
# Get the scene entity setup and test device association
38+
entity_entries = er.async_entries_for_config_entry(
39+
entity_registry, mock_config_entry.entry_id
40+
)
41+
assert len(entity_entries) == 1
42+
entry = entity_entries[0]
43+
44+
assert entry.device_id is not None
45+
device_entry = device_registry.async_get(entry.device_id)
46+
assert device_entry is not None
47+
# Scenes are associated with the gateway device
48+
assert (DOMAIN, f"gateway_{mock_config_entry.entry_id}") in device_entry.identifiers
49+
assert device_entry.via_device_id is None
50+
51+
52+
@pytest.mark.usefixtures("setup_integration")
53+
async def test_scene_activation(
54+
hass: HomeAssistant,
55+
mock_scene: AsyncMock,
56+
) -> None:
57+
"""Test successful scene activation."""
58+
59+
# activate the scene via service call
60+
await hass.services.async_call(
61+
SCENE_DOMAIN,
62+
SERVICE_TURN_ON,
63+
{ATTR_ENTITY_ID: "scene.klf_200_gateway_test_scene"},
64+
blocking=True,
65+
)
66+
67+
# Verify the run method was called
68+
mock_scene.run.assert_awaited_once_with(wait_for_completion=False)

0 commit comments

Comments
 (0)