From 83e62c523905b9ff6d80e5b8fc9821cd7f120a47 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Wed, 29 May 2024 11:00:07 +0200 Subject: [PATCH] Discover new device at runtime in Plugwise (#117688) Co-authored-by: Franck Nijhof --- .../components/plugwise/binary_sensor.py | 40 +++++--- homeassistant/components/plugwise/climate.py | 22 +++-- .../components/plugwise/coordinator.py | 17 +++- homeassistant/components/plugwise/number.py | 25 +++-- homeassistant/components/plugwise/select.py | 24 +++-- homeassistant/components/plugwise/sensor.py | 38 +++++--- homeassistant/components/plugwise/switch.py | 31 ++++-- tests/components/plugwise/test_init.py | 97 ++++++++++++++++++- 8 files changed, 228 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 51dbb84733e..ef1051fa7b2 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry @@ -83,22 +83,32 @@ async def async_setup_entry( """Set up the Smile binary_sensors from a config entry.""" coordinator = entry.runtime_data - entities: list[PlugwiseBinarySensorEntity] = [] - for device_id, device in coordinator.data.devices.items(): - if not (binary_sensors := device.get("binary_sensors")): - continue - for description in BINARY_SENSORS: - if description.key not in binary_sensors: - continue + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return - entities.append( - PlugwiseBinarySensorEntity( - coordinator, - device_id, - description, + entities: list[PlugwiseBinarySensorEntity] = [] + for device_id, device in coordinator.data.devices.items(): + if not (binary_sensors := device.get("binary_sensors")): + continue + for description in BINARY_SENSORS: + if description.key not in binary_sensors: + continue + + entities.append( + PlugwiseBinarySensorEntity( + coordinator, + device_id, + description, + ) ) - ) - async_add_entities(entities) + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 73151185e72..006cfbe87da 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -14,7 +14,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,11 +33,21 @@ async def async_setup_entry( """Set up the Smile Thermostats from a config entry.""" coordinator = entry.runtime_data - async_add_entities( - PlugwiseClimateEntity(coordinator, device_id) - for device_id, device in coordinator.data.devices.items() - if device["dev_class"] in MASTER_THERMOSTATS - ) + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + async_add_entities( + PlugwiseClimateEntity(coordinator, device_id) + for device_id, device in coordinator.data.devices.items() + if device["dev_class"] in MASTER_THERMOSTATS + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 4cb1a35867e..34d983510ed 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -15,11 +15,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_USERNAME, DOMAIN, LOGGER +from .const import DEFAULT_PORT, DEFAULT_USERNAME, DOMAIN, LOGGER class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): @@ -54,14 +55,13 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): timeout=30, websession=async_get_clientsession(hass, verify_ssl=False), ) + self.device_list: list[dr.DeviceEntry] = [] + self.new_devices: bool = False async def _connect(self) -> None: """Connect to the Plugwise Smile.""" self._connected = await self.api.connect() self.api.get_all_devices() - self.update_interval = DEFAULT_SCAN_INTERVAL.get( - str(self.api.smile_type), timedelta(seconds=60) - ) async def _async_update_data(self) -> PlugwiseData: """Fetch data from Plugwise.""" @@ -81,4 +81,13 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): raise ConfigEntryError("Device with unsupported firmware") from err except ConnectionFailedError as err: raise UpdateFailed("Failed to connect to the Plugwise Smile") from err + + device_reg = dr.async_get(self.hass) + device_list = dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ) + + self.new_devices = len(data.devices.keys()) - len(self.device_list) > 0 + self.device_list = device_list + return data diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index ee7199cbb88..f00b9e38876 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.const import EntityCategory, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry @@ -71,15 +71,24 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Plugwise number platform.""" - coordinator = entry.runtime_data - async_add_entities( - PlugwiseNumberEntity(coordinator, device_id, description) - for device_id, device in coordinator.data.devices.items() - for description in NUMBER_TYPES - if description.key in device - ) + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + async_add_entities( + PlugwiseNumberEntity(coordinator, device_id, description) + for device_id, device in coordinator.data.devices.items() + for description in NUMBER_TYPES + if description.key in device + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity): diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 68e1110950a..88c97b9b9f3 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -9,7 +9,7 @@ from plugwise import Smile from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry @@ -66,12 +66,22 @@ async def async_setup_entry( """Set up the Smile selector from a config entry.""" coordinator = entry.runtime_data - async_add_entities( - PlugwiseSelectEntity(coordinator, device_id, description) - for device_id, device in coordinator.data.devices.items() - for description in SELECT_TYPES - if description.options_key in device - ) + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + async_add_entities( + PlugwiseSelectEntity(coordinator, device_id, description) + for device_id, device in coordinator.data.devices.items() + for description in SELECT_TYPES + if description.options_key in device + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 69ee52ae777..147bab828a8 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry @@ -408,23 +408,33 @@ async def async_setup_entry( """Set up the Smile sensors from a config entry.""" coordinator = entry.runtime_data - entities: list[PlugwiseSensorEntity] = [] - for device_id, device in coordinator.data.devices.items(): - if not (sensors := device.get("sensors")): - continue - for description in SENSORS: - if description.key not in sensors: + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + entities: list[PlugwiseSensorEntity] = [] + for device_id, device in coordinator.data.devices.items(): + if not (sensors := device.get("sensors")): continue + for description in SENSORS: + if description.key not in sensors: + continue - entities.append( - PlugwiseSensorEntity( - coordinator, - device_id, - description, + entities.append( + PlugwiseSensorEntity( + coordinator, + device_id, + description, + ) ) - ) - async_add_entities(entities) + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseSensorEntity(PlugwiseEntity, SensorEntity): diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 2c4b53cfb50..3ed2d14b8dd 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry @@ -62,15 +62,28 @@ async def async_setup_entry( """Set up the Smile switches from a config entry.""" coordinator = entry.runtime_data - entities: list[PlugwiseSwitchEntity] = [] - for device_id, device in coordinator.data.devices.items(): - if not (switches := device.get("switches")): - continue - for description in SWITCHES: - if description.key not in switches: + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + entities: list[PlugwiseSwitchEntity] = [] + for device_id, device in coordinator.data.devices.items(): + if not (switches := device.get("switches")): continue - entities.append(PlugwiseSwitchEntity(coordinator, device_id, description)) - async_add_entities(entities) + for description in SWITCHES: + if description.key not in switches: + continue + entities.append( + PlugwiseSwitchEntity(coordinator, device_id, description) + ) + + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity): diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 7323cf73be3..9c709f1c4f6 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -1,6 +1,7 @@ """Tests for the Plugwise Climate integration.""" -from unittest.mock import MagicMock +from datetime import timedelta +from unittest.mock import MagicMock, patch from plugwise.exceptions import ( ConnectionFailedError, @@ -15,15 +16,45 @@ from homeassistant.components.plugwise.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +HA_PLUGWISE_SMILE_ASYNC_UPDATE = ( + "homeassistant.components.plugwise.coordinator.Smile.async_update" +) HEATER_ID = "1cbf783bb11e4a7c8a6843dee3a86927" # Opentherm device_id for migration PLUG_ID = "cd0ddb54ef694e11ac18ed1cbce5dbbd" # VCR device_id for migration SECONDARY_ID = ( "1cbf783bb11e4a7c8a6843dee3a86927" # Heater_central device_id for migration ) +TOM = { + "01234567890abcdefghijklmnopqrstu": { + "available": True, + "dev_class": "thermo_sensor", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "temperature": 18.6, + "temperature_difference": 2.3, + "valve_position": 0.0, + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01", + }, +} async def test_load_unload_config_entry( @@ -165,3 +196,63 @@ async def test_migrate_unique_id_relay( entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated assert entity_migrated.unique_id == new_unique_id + + +async def test_update_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smile_adam_2: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test a clean-up of the device_registry.""" + utcnow = dt_util.utcnow() + data = mock_smile_adam_2.async_update.return_value + + mock_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 28 + ) + assert ( + len( + dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + ) + == 6 + ) + + # Add a 2nd Tom/Floor + data.devices.update(TOM) + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + async_fire_time_changed(hass, utcnow + timedelta(minutes=1)) + await hass.async_block_till_done() + + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 33 + ) + assert ( + len( + dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + ) + == 7 + ) + item_list: list[str] = [] + for device_entry in list(device_registry.devices.values()): + item_list.extend(x[1] for x in device_entry.identifiers) + assert "01234567890abcdefghijklmnopqrstu" in item_list