Skip to content

Commit 3817e52

Browse files
authored
Merge pull request #982 from plugwise/coord-restore-state
Coordinator: add _async_setup() and collect set of existing devices
2 parents 8e8759b + 6367335 commit 3817e52

File tree

3 files changed

+168
-84
lines changed

3 files changed

+168
-84
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Versions from 0.40 and up
44

55
## Ongoing
66

7+
- Improve automatic deletion of device(s) removed from the backend output, via PR [$982](https://github.com/plugwise/plugwise-beta/pull/982)
78
- Extended feature: extent/improve Plugwise groups, via PR[#976](https://github.com/plugwise/plugwise-beta/pull/976) and plugwise [v1.11.0](https://github.com/plugwise/python-plugwise/releases/tag/v1.11.0)
89
- DeviceInfo: show configuration_url on gateway only, via PR [#975](https://github.com/plugwise/plugwise-beta/pull/975)
910

custom_components/plugwise/coordinator.py

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from plugwise.exceptions import (
77
ConnectionFailedError,
88
InvalidAuthentication,
9+
InvalidSetupError,
910
InvalidXMLError,
1011
PlugwiseError,
1112
ResponseError,
@@ -37,6 +38,9 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
3738
"""Class to manage fetching Plugwise data from single endpoint."""
3839

3940
_connected: bool = False
41+
_current_devices: set[str]
42+
_stored_devices: set[str]
43+
new_devices: set[str]
4044

4145
config_entry: PlugwiseConfigEntry
4246

@@ -73,31 +77,18 @@ def __init__(
7377
username=self.config_entry.data[CONF_USERNAME],
7478
websession=async_get_clientsession(hass, verify_ssl=False),
7579
)
76-
self._current_devices: set[str] = set()
77-
self.new_devices: set[str] = set()
80+
self._current_devices = set()
81+
self._stored_devices = set()
82+
self.new_devices = set()
7883
self.update_interval = update_interval
7984

8085
async def _connect(self) -> None:
81-
"""Connect to the Plugwise Smile."""
82-
version = await self.api.connect()
83-
self._connected = isinstance(version, Version)
84-
if self._connected:
85-
self.update_interval = DEFAULT_SCAN_INTERVAL.get(
86-
self.api.smile.type, timedelta(seconds=60)
87-
) # pw-beta options scan-interval
88-
if (custom_time := self.config_entry.options.get(CONF_SCAN_INTERVAL)) is not None:
89-
self.update_interval = timedelta(
90-
seconds=int(custom_time)
91-
) # pragma: no cover # pw-beta options
92-
93-
LOGGER.debug("DUC update interval: %s", self.update_interval) # pw-beta options
86+
"""Connect to the Plugwise Smile.
9487
95-
async def _async_update_data(self) -> dict[str, GwEntityData]:
96-
"""Fetch data from Plugwise."""
88+
A Version object is received when the connection succeeds.
89+
"""
9790
try:
98-
if not self._connected:
99-
await self._connect()
100-
data = await self.api.async_update()
91+
version = await self.api.connect()
10192
except ConnectionFailedError as err:
10293
raise UpdateFailed(
10394
translation_domain=DOMAIN,
@@ -108,35 +99,73 @@ async def _async_update_data(self) -> dict[str, GwEntityData]:
10899
translation_domain=DOMAIN,
109100
translation_key="authentication_failed",
110101
) from err
111-
except (InvalidXMLError, ResponseError) as err:
112-
# pwbeta TODO; we had {err} in the text, but not upstream, do we want this?
113-
raise UpdateFailed(
102+
except InvalidSetupError as err:
103+
raise ConfigEntryError(
114104
translation_domain=DOMAIN,
115-
translation_key="invalid_xml_data",
105+
translation_key="invalid_setup",
116106
) from err
117-
except PlugwiseError as err:
107+
except (InvalidXMLError, ResponseError) as err:
118108
raise UpdateFailed(
119109
translation_domain=DOMAIN,
120-
translation_key="data_incomplete_or_missing",
110+
translation_key="invalid_xml_data",
121111
) from err
122112
except UnsupportedDeviceError as err:
123113
raise ConfigEntryError(
124114
translation_domain=DOMAIN,
125115
translation_key="unsupported_firmware",
126116
) from err
127117

128-
LOGGER.debug(f"{self.api.smile.name} data: %s", data)
118+
self._connected = isinstance(version, Version)
119+
if self._connected:
120+
self.update_interval = DEFAULT_SCAN_INTERVAL.get(
121+
self.api.smile.type, timedelta(seconds=60)
122+
) # pw-beta options scan-interval
123+
if (custom_time := self.config_entry.options.get(CONF_SCAN_INTERVAL)) is not None:
124+
self.update_interval = timedelta(
125+
seconds=int(custom_time)
126+
) # pragma: no cover # pw-beta options
127+
128+
LOGGER.debug("DUC update interval: %s", self.update_interval) # pw-beta options
129+
130+
async def _async_setup(self) -> None:
131+
"""Initialize the update_data process."""
132+
device_reg = dr.async_get(self.hass)
133+
device_entries = dr.async_entries_for_config_entry(
134+
device_reg, self.config_entry.entry_id
135+
)
136+
self._stored_devices = {
137+
identifier[1]
138+
for device_entry in device_entries
139+
for identifier in device_entry.identifiers
140+
if identifier[0] == DOMAIN
141+
}
142+
143+
async def _async_update_data(self) -> dict[str, GwEntityData]:
144+
"""Fetch data from Plugwise."""
145+
if not self._connected:
146+
await self._connect()
147+
try:
148+
data = await self.api.async_update()
149+
except PlugwiseError as err:
150+
raise UpdateFailed(
151+
translation_domain=DOMAIN,
152+
translation_key="data_incomplete_or_missing",
153+
) from err
154+
129155
await self._async_add_remove_devices(data)
156+
LOGGER.debug("%s data: %s", self.api.smile.name, data)
130157
return data
131158

132159
async def _async_add_remove_devices(self, data: dict[str, GwEntityData]) -> None:
133160
"""Add new Plugwise devices, remove non-existing devices."""
134-
# Check for new or removed devices
135-
self.new_devices = set(data) - self._current_devices
136-
removed_devices = self._current_devices - set(data)
137-
self._current_devices = set(data)
138-
139-
if removed_devices:
161+
set_of_data = set(data)
162+
# Check for new or removed devices,
163+
# 'new_devices' contains all devices present in 'data' at init ('self._current_devices' is empty)
164+
# this is required for the proper initialization of all the present platform entities.
165+
self.new_devices = set_of_data - self._current_devices
166+
current_devices = self._stored_devices if not self._current_devices else self._current_devices
167+
self._current_devices = set_of_data
168+
if (current_devices - set_of_data): # device(s) to remove
140169
await self._async_remove_devices(data)
141170

142171
async def _async_remove_devices(self, data: dict[str, GwEntityData]) -> None:
@@ -148,9 +177,10 @@ async def _async_remove_devices(self, data: dict[str, GwEntityData]) -> None:
148177

149178
# First find the Plugwise via_device
150179
gateway_device = device_reg.async_get_device({(DOMAIN, self.api.gateway_id)})
151-
if gateway_device is not None:
152-
via_device_id = gateway_device.id
180+
if gateway_device is None:
181+
return # pragma: no cover
153182

183+
via_device_id = gateway_device.id
154184
# Then remove the connected orphaned device(s)
155185
for device_entry in device_list:
156186
for identifier in device_entry.identifiers:

tests/components/plugwise/test_init.py

Lines changed: 102 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
"""Tests for the Plugwise Climate integration."""
22
from datetime import timedelta
3-
import logging
43
from unittest.mock import MagicMock, patch
54

65
from plugwise.exceptions import (
76
ConnectionFailedError,
87
InvalidAuthentication,
8+
InvalidSetupError,
99
InvalidXMLError,
1010
PlugwiseError,
1111
ResponseError,
@@ -15,6 +15,7 @@
1515

1616
from freezegun.api import FrozenDateTimeFactory
1717
from homeassistant.components.plugwise.const import DOMAIN
18+
from homeassistant.components.plugwise.coordinator import PlugwiseDataUpdateCoordinator
1819
from homeassistant.config_entries import ConfigEntryState
1920
from homeassistant.const import (
2021
CONF_HOST,
@@ -26,13 +27,13 @@
2627
Platform,
2728
)
2829
from homeassistant.core import HomeAssistant
30+
from homeassistant.exceptions import ConfigEntryError
2931
from homeassistant.helpers import device_registry as dr, entity_registry as er
32+
from homeassistant.helpers.update_coordinator import UpdateFailed
3033
from homeassistant.setup import async_setup_component
3134

3235
from tests.common import MockConfigEntry, async_fire_time_changed
3336

34-
LOGGER = logging.getLogger(__package__)
35-
3637
HA_PLUGWISE_SMILE_ASYNC_UPDATE = (
3738
"homeassistant.components.plugwise.coordinator.Smile.async_update"
3839
)
@@ -96,23 +97,12 @@ async def test_load_unload_config_entry(
9697

9798
@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True)
9899
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
99-
@pytest.mark.parametrize(
100-
("side_effect", "entry_state"),
101-
[
102-
(ConnectionFailedError, ConfigEntryState.SETUP_RETRY),
103-
(InvalidAuthentication, ConfigEntryState.SETUP_ERROR),
104-
(InvalidXMLError, ConfigEntryState.SETUP_RETRY),
105-
(ResponseError, ConfigEntryState.SETUP_RETRY),
106-
(PlugwiseError, ConfigEntryState.SETUP_RETRY),
107-
(UnsupportedDeviceError, ConfigEntryState.SETUP_ERROR),
108-
],
109-
)
100+
@pytest.mark.parametrize("side_effect", [PlugwiseError])
110101
async def test_gateway_config_entry_not_ready(
111102
hass: HomeAssistant,
112103
mock_config_entry: MockConfigEntry,
113104
mock_smile_anna: MagicMock,
114105
side_effect: Exception,
115-
entry_state: ConfigEntryState,
116106
) -> None:
117107
"""Test the Plugwise configuration entry not ready."""
118108
mock_smile_anna.async_update.side_effect = side_effect
@@ -122,7 +112,38 @@ async def test_gateway_config_entry_not_ready(
122112
await hass.async_block_till_done()
123113

124114
assert len(mock_smile_anna.connect.mock_calls) == 1
125-
assert mock_config_entry.state is entry_state
115+
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
116+
117+
118+
@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True)
119+
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
120+
@pytest.mark.parametrize(
121+
("side_effect", "expected_raise"),
122+
[
123+
(ConnectionFailedError, UpdateFailed),
124+
(InvalidAuthentication, ConfigEntryError),
125+
(InvalidSetupError, ConfigEntryError),
126+
(InvalidXMLError, UpdateFailed),
127+
(ResponseError, UpdateFailed),
128+
(UnsupportedDeviceError, ConfigEntryError),
129+
],
130+
)
131+
async def test_coordinator_connect_exceptions(
132+
hass: HomeAssistant,
133+
mock_config_entry: MockConfigEntry,
134+
mock_smile_anna: MagicMock,
135+
side_effect: Exception,
136+
expected_raise: Exception,
137+
) -> None:
138+
"""Ensure _connect raises translated errors."""
139+
mock_smile_anna.connect.side_effect = side_effect
140+
coordinator = PlugwiseDataUpdateCoordinator(
141+
hass,
142+
cooldown=0,
143+
config_entry=mock_config_entry,
144+
)
145+
with pytest.raises(expected_raise):
146+
await coordinator._connect()
126147

127148

128149
@pytest.mark.parametrize("chosen_env", ["p1v4_442_single"], indirect=True)
@@ -160,7 +181,7 @@ async def check_migration(
160181
mock_config_entry.add_to_hass(hass)
161182

162183
entity_registry = er.async_get(hass)
163-
entity: entity_registry.RegistryEntry = entity_registry.async_get_or_create(
184+
entity: er.RegistryEntry = entity_registry.async_get_or_create(
164185
**entitydata,
165186
config_entry=mock_config_entry,
166187
)
@@ -245,38 +266,6 @@ async def test_migrate_unique_id_relay(
245266
hass, mock_config_entry, entitydata, old_unique_id, new_unique_id
246267
)
247268

248-
#### pw-beta only ####
249-
@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True)
250-
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
251-
async def test_entry_migration(
252-
hass: HomeAssistant, mock_smile_anna: MagicMock
253-
) -> None:
254-
"""Test config entry 1.2 -> 1.1 migration."""
255-
entry = MockConfigEntry(
256-
domain=DOMAIN,
257-
data={
258-
CONF_HOST: "127.0.0.1",
259-
CONF_MAC: "AA:BB:CC:DD:EE:FF",
260-
CONF_PASSWORD: "test-password",
261-
CONF_PORT: 80,
262-
CONF_TIMEOUT: 30,
263-
CONF_USERNAME: "smile",
264-
},
265-
minor_version=2,
266-
version=1,
267-
unique_id="smile98765",
268-
)
269-
270-
entry.runtime_data = MagicMock(api=mock_smile_anna)
271-
entry.add_to_hass(hass)
272-
await hass.config_entries.async_setup(entry.entry_id)
273-
await hass.async_block_till_done()
274-
275-
assert entry.version == 1
276-
assert entry.minor_version == 1
277-
assert entry.data.get(CONF_TIMEOUT) is None
278-
assert entry.state is ConfigEntryState.LOADED
279-
280269

281270
@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True)
282271
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
@@ -358,3 +347,67 @@ async def test_update_device(
358347
for device_entry in device_registry.devices.values():
359348
item_list.extend(x[1] for x in device_entry.identifiers)
360349
assert "1772a4ea304041adb83f357b751341ff" not in item_list
350+
351+
352+
@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True)
353+
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
354+
async def test_delete_removed_device(
355+
hass: HomeAssistant,
356+
mock_config_entry: MockConfigEntry,
357+
mock_smile_adam_heat_cool: MagicMock,
358+
device_registry: dr.DeviceRegistry,
359+
init_integration: MockConfigEntry,
360+
) -> None:
361+
"""Test device removal at integration init."""
362+
data = mock_smile_adam_heat_cool.async_update.return_value
363+
mock_config_entry.add_to_hass(hass)
364+
assert await async_setup_component(hass, DOMAIN, {})
365+
await hass.async_block_till_done()
366+
367+
item_list: list[str] = []
368+
for device_entry in device_registry.devices.values():
369+
item_list.extend(x[1] for x in device_entry.identifiers)
370+
assert "14df5c4dc8cb4ba69f9d1ac0eaf7c5c6" in item_list
371+
372+
data.pop("14df5c4dc8cb4ba69f9d1ac0eaf7c5c6")
373+
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
374+
await hass.config_entries.async_reload(init_integration.entry_id)
375+
await hass.async_block_till_done()
376+
377+
item_list = []
378+
for device_entry in device_registry.devices.values():
379+
item_list.extend(x[1] for x in device_entry.identifiers)
380+
assert "14df5c4dc8cb4ba69f9d1ac0eaf7c5c6" not in item_list
381+
382+
383+
#### pw-beta only ####
384+
@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True)
385+
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
386+
async def test_entry_migration(
387+
hass: HomeAssistant, mock_smile_anna: MagicMock
388+
) -> None:
389+
"""Test config entry 1.2 -> 1.1 migration."""
390+
entry = MockConfigEntry(
391+
domain=DOMAIN,
392+
data={
393+
CONF_HOST: "127.0.0.1",
394+
CONF_MAC: "AA:BB:CC:DD:EE:FF",
395+
CONF_PASSWORD: "test-password",
396+
CONF_PORT: 80,
397+
CONF_TIMEOUT: 30,
398+
CONF_USERNAME: "smile",
399+
},
400+
minor_version=2,
401+
version=1,
402+
unique_id="smile98765",
403+
)
404+
405+
entry.runtime_data = MagicMock(api=mock_smile_anna)
406+
entry.add_to_hass(hass)
407+
await hass.config_entries.async_setup(entry.entry_id)
408+
await hass.async_block_till_done()
409+
410+
assert entry.version == 1
411+
assert entry.minor_version == 1
412+
assert entry.data.get(CONF_TIMEOUT) is None
413+
assert entry.state is ConfigEntryState.LOADED

0 commit comments

Comments
 (0)