Skip to content

Commit 95eb45a

Browse files
authored
cleanup registered callbacks before removing velux config entry (home-assistant#156525)
1 parent 84f8e57 commit 95eb45a

File tree

3 files changed

+46
-14
lines changed

3 files changed

+46
-14
lines changed

homeassistant/components/velux/entity.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Support for VELUX KLF 200 devices."""
22

3+
from collections.abc import Awaitable, Callable
4+
35
from pyvlx import Node
46

5-
from homeassistant.core import callback
67
from homeassistant.helpers.device_registry import DeviceInfo
78
from homeassistant.helpers.entity import Entity
89

@@ -14,6 +15,7 @@ class VeluxEntity(Entity):
1415

1516
_attr_should_poll = False
1617
_attr_has_entity_name = True
18+
update_callback: Callable[["Node"], Awaitable[None]] | None = None
1719

1820
def __init__(self, node: Node, config_entry_id: str) -> None:
1921
"""Initialize the Velux device."""
@@ -24,6 +26,7 @@ def __init__(self, node: Node, config_entry_id: str) -> None:
2426
else f"{config_entry_id}_{node.node_id}"
2527
)
2628
self._attr_unique_id = unique_id
29+
self.unsubscribe = None
2730

2831
self._attr_device_info = DeviceInfo(
2932
identifiers={
@@ -37,16 +40,18 @@ def __init__(self, node: Node, config_entry_id: str) -> None:
3740
via_device=(DOMAIN, f"gateway_{config_entry_id}"),
3841
)
3942

40-
@callback
41-
def async_register_callbacks(self):
42-
"""Register callbacks to update hass after device was changed."""
43+
async def after_update_callback(self, node) -> None:
44+
"""Call after device was updated."""
45+
self.async_write_ha_state()
4346

44-
async def after_update_callback(device):
45-
"""Call after device was updated."""
46-
self.async_write_ha_state()
47+
async def async_added_to_hass(self) -> None:
48+
"""Register callback and store reference for cleanup."""
4749

48-
self.node.register_device_updated_cb(after_update_callback)
50+
self.update_callback = self.after_update_callback
51+
self.node.register_device_updated_cb(self.update_callback)
4952

50-
async def async_added_to_hass(self) -> None:
51-
"""Store register state change callback."""
52-
self.async_register_callbacks()
53+
async def async_will_remove_from_hass(self) -> None:
54+
"""Clean up registered callbacks."""
55+
if self.update_callback:
56+
self.node.unregister_device_updated_cb(self.update_callback)
57+
self.update_callback = None

homeassistant/components/velux/quality_scale.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ rules:
1515
docs-high-level-description: done
1616
docs-installation-instructions: done
1717
docs-removal-instructions: done
18-
entity-event-setup:
19-
status: todo
20-
comment: subscribe is ok, unsubscribe needs to be added
18+
entity-event-setup: done
2119
entity-unique-id: done
2220
has-entity-name:
2321
status: todo

tests/components/velux/test_light.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from homeassistant.core import HomeAssistant
99
from homeassistant.helpers import device_registry as dr, entity_registry as er
1010

11+
from tests.common import MockConfigEntry
12+
1113

1214
@pytest.fixture
1315
def platform() -> Platform:
@@ -41,3 +43,30 @@ async def test_light_setup(
4143
# Verify device has correct identifiers + name
4244
assert ("velux", mock_light.serial_number) in device_entry.identifiers
4345
assert device_entry.name == mock_light.name
46+
47+
48+
# This test is not light specific, it just uses the light platform to test the base entity class.
49+
@pytest.mark.usefixtures("setup_integration")
50+
async def test_entity_callbacks(
51+
hass: HomeAssistant,
52+
mock_config_entry: MockConfigEntry,
53+
mock_light: AsyncMock,
54+
) -> None:
55+
"""Ensure the entity unregisters its device-updated callback when unloaded."""
56+
# Entity is created by setup_integration; callback should be registered
57+
test_entity_id = f"light.{mock_light.name.lower().replace(' ', '_')}"
58+
state = hass.states.get(test_entity_id)
59+
assert state is not None
60+
61+
# Callback is registered exactly once with a callable
62+
assert mock_light.register_device_updated_cb.call_count == 1
63+
cb = mock_light.register_device_updated_cb.call_args[0][0]
64+
assert callable(cb)
65+
66+
# Unload the config entry to trigger async_will_remove_from_hass
67+
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
68+
await hass.async_block_till_done()
69+
70+
# Callback must be unregistered with the same callable
71+
assert mock_light.unregister_device_updated_cb.call_count == 1
72+
assert mock_light.unregister_device_updated_cb.call_args[0][0] is cb

0 commit comments

Comments
 (0)