Discover new device at runtime in Plugwise (#117688)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Bouwe Westerdijk 2024-05-29 11:00:07 +02:00 committed by GitHub
parent 6b7ff2bf44
commit 83e62c5239
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 228 additions and 66 deletions

View File

@ -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):

View File

@ -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):

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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