Add dynamic child device handling to tplink integration (#135229)

Add dynamic child device handling to tplink integration. For child devices that could be added/removed to hubs.
This commit is contained in:
Steven B. 2025-01-15 19:45:06 +00:00 committed by GitHub
parent c6cab3259c
commit 51e3bf42f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 403 additions and 112 deletions

View File

@ -18,7 +18,6 @@ from kasa import (
KasaException, KasaException,
) )
from kasa.httpclient import get_cookie_jar from kasa.httpclient import get_cookie_jar
from kasa.iot import IotStrip
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import network from homeassistant.components import network
@ -235,17 +234,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
parent_coordinator = TPLinkDataUpdateCoordinator( parent_coordinator = TPLinkDataUpdateCoordinator(
hass, device, timedelta(seconds=5), entry hass, device, timedelta(seconds=5), entry
) )
child_coordinators: list[TPLinkDataUpdateCoordinator] = []
# The iot HS300 allows a limited number of concurrent requests and fetching the
# emeter information requires separate ones so create child coordinators here.
if isinstance(device, IotStrip):
child_coordinators = [
# The child coordinators only update energy data so we can
# set a longer update interval to avoid flooding the device
TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60), entry)
for child in device.children
]
camera_creds: Credentials | None = None camera_creds: Credentials | None = None
if camera_creds_dict := entry.data.get(CONF_CAMERA_CREDENTIALS): if camera_creds_dict := entry.data.get(CONF_CAMERA_CREDENTIALS):
@ -254,9 +242,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
) )
live_view = entry.data.get(CONF_LIVE_VIEW) live_view = entry.data.get(CONF_LIVE_VIEW)
entry.runtime_data = TPLinkData( entry.runtime_data = TPLinkData(parent_coordinator, camera_creds, live_view)
parent_coordinator, child_coordinators, camera_creds, live_view
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True

View File

@ -8,6 +8,7 @@ from typing import Final, cast
from kasa import Feature from kasa import Feature
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription, BinarySensorEntityDescription,
@ -16,6 +17,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TPLinkConfigEntry from . import TPLinkConfigEntry
from .deprecate import async_cleanup_deprecated
from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription
@ -73,19 +75,30 @@ async def async_setup_entry(
"""Set up sensors.""" """Set up sensors."""
data = config_entry.runtime_data data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator parent_coordinator = data.parent_coordinator
children_coordinators = data.children_coordinators
device = parent_coordinator.device device = parent_coordinator.device
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( known_child_device_ids: set[str] = set()
hass=hass, first_check = True
device=device,
coordinator=parent_coordinator, def _check_device() -> None:
feature_type=Feature.Type.BinarySensor, entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
entity_class=TPLinkBinarySensorEntity, hass=hass,
descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, device=device,
child_coordinators=children_coordinators, coordinator=parent_coordinator,
) feature_type=Feature.Type.BinarySensor,
async_add_entities(entities) entity_class=TPLinkBinarySensorEntity,
descriptions=BINARYSENSOR_DESCRIPTIONS_MAP,
known_child_device_ids=known_child_device_ids,
first_check=first_check,
)
async_cleanup_deprecated(
hass, BINARY_SENSOR_DOMAIN, config_entry.entry_id, entities
)
async_add_entities(entities)
_check_device()
first_check = False
config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntity): class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntity):

View File

@ -83,20 +83,27 @@ async def async_setup_entry(
"""Set up buttons.""" """Set up buttons."""
data = config_entry.runtime_data data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator parent_coordinator = data.parent_coordinator
children_coordinators = data.children_coordinators
device = parent_coordinator.device device = parent_coordinator.device
known_child_device_ids: set[str] = set()
first_check = True
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( def _check_device() -> None:
hass=hass, entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
device=device, hass=hass,
coordinator=parent_coordinator, device=device,
feature_type=Feature.Type.Action, coordinator=parent_coordinator,
entity_class=TPLinkButtonEntity, feature_type=Feature.Type.Action,
descriptions=BUTTON_DESCRIPTIONS_MAP, entity_class=TPLinkButtonEntity,
child_coordinators=children_coordinators, descriptions=BUTTON_DESCRIPTIONS_MAP,
) known_child_device_ids=known_child_device_ids,
async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities) first_check=first_check,
async_add_entities(entities) )
async_cleanup_deprecated(hass, BUTTON_DOMAIN, config_entry.entry_id, entities)
async_add_entities(entities)
_check_device()
first_check = False
config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity): class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity):

View File

@ -7,10 +7,12 @@ from datetime import timedelta
import logging import logging
from kasa import AuthenticationError, Credentials, Device, KasaException from kasa import AuthenticationError, Credentials, Device, KasaException
from kasa.iot import IotStrip
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -24,7 +26,6 @@ class TPLinkData:
"""Data for the tplink integration.""" """Data for the tplink integration."""
parent_coordinator: TPLinkDataUpdateCoordinator parent_coordinator: TPLinkDataUpdateCoordinator
children_coordinators: list[TPLinkDataUpdateCoordinator]
camera_credentials: Credentials | None camera_credentials: Credentials | None
live_view: bool | None live_view: bool | None
@ -60,6 +61,9 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
), ),
) )
self._previous_child_device_ids = {child.device_id for child in device.children}
self.removed_child_device_ids: set[str] = set()
self._child_coordinators: dict[str, TPLinkDataUpdateCoordinator] = {}
async def _async_update_data(self) -> None: async def _async_update_data(self) -> None:
"""Fetch all device and sensor data from api.""" """Fetch all device and sensor data from api."""
@ -83,3 +87,48 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
"exc": str(ex), "exc": str(ex),
}, },
) from ex ) from ex
await self._process_child_devices()
async def _process_child_devices(self) -> None:
"""Process child devices and remove stale devices."""
current_child_device_ids = {child.device_id for child in self.device.children}
if (
stale_device_ids := self._previous_child_device_ids
- current_child_device_ids
):
device_registry = dr.async_get(self.hass)
for device_id in stale_device_ids:
device = device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
child_coordinator = self._child_coordinators.pop(device_id, None)
if child_coordinator:
await child_coordinator.async_shutdown()
self._previous_child_device_ids = current_child_device_ids
self.removed_child_device_ids = stale_device_ids
def get_child_coordinator(
self,
child: Device,
) -> TPLinkDataUpdateCoordinator:
"""Get separate child coordinator for a device or self if not needed."""
# The iot HS300 allows a limited number of concurrent requests and fetching the
# emeter information requires separate ones so create child coordinators here.
if isinstance(self.device, IotStrip):
if not (child_coordinator := self._child_coordinators.get(child.device_id)):
# The child coordinators only update energy data so we can
# set a longer update interval to avoid flooding the device
child_coordinator = TPLinkDataUpdateCoordinator(
self.hass, child, timedelta(seconds=60), self.config_entry
)
self._child_coordinators[child.device_id] = child_coordinator
return child_coordinator
return self

View File

@ -434,7 +434,8 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
feature_type: Feature.Type, feature_type: Feature.Type,
entity_class: type[_E], entity_class: type[_E],
descriptions: Mapping[str, _D], descriptions: Mapping[str, _D],
child_coordinators: list[TPLinkDataUpdateCoordinator] | None = None, known_child_device_ids: set[str],
first_check: bool,
) -> list[_E]: ) -> list[_E]:
"""Create entities for device and its children. """Create entities for device and its children.
@ -442,36 +443,69 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
""" """
entities: list[_E] = [] entities: list[_E] = []
# Add parent entities before children so via_device id works. # Add parent entities before children so via_device id works.
entities.extend( # Only add the parent entities the first time
cls._entities_for_device( if first_check:
entities.extend(
cls._entities_for_device(
hass,
device,
coordinator=coordinator,
feature_type=feature_type,
entity_class=entity_class,
descriptions=descriptions,
)
)
# Remove any device ids removed via the coordinator so they can be re-added
for removed_child_id in coordinator.removed_child_device_ids:
_LOGGER.debug(
"Removing %s from known %s child ids for device %s"
"as it has been removed by the coordinator",
removed_child_id,
entity_class.__name__,
device.host,
)
known_child_device_ids.discard(removed_child_id)
current_child_devices = {child.device_id: child for child in device.children}
current_child_device_ids = set(current_child_devices.keys())
new_child_device_ids = current_child_device_ids - known_child_device_ids
children = []
if new_child_device_ids:
children = [
child
for child_id, child in current_child_devices.items()
if child_id in new_child_device_ids
]
known_child_device_ids.update(new_child_device_ids)
if children:
_LOGGER.debug(
"Getting %s entities for %s child devices on device %s",
entity_class.__name__,
len(children),
device.host,
)
for child in children:
child_coordinator = coordinator.get_child_coordinator(child)
child_entities = cls._entities_for_device(
hass, hass,
device, child,
coordinator=coordinator, coordinator=child_coordinator,
feature_type=feature_type, feature_type=feature_type,
entity_class=entity_class, entity_class=entity_class,
descriptions=descriptions, descriptions=descriptions,
parent=device,
) )
) _LOGGER.debug(
if device.children: "Device %s, found %s child %s entities for child id %s",
_LOGGER.debug("Initializing device with %s children", len(device.children)) device.host,
for idx, child in enumerate(device.children): len(entities),
# HS300 does not like too many concurrent requests and its entity_class.__name__,
# emeter data requires a request for each socket, so we receive child.device_id,
# separate coordinators. )
if child_coordinators: entities.extend(child_entities)
child_coordinator = child_coordinators[idx]
else:
child_coordinator = coordinator
entities.extend(
cls._entities_for_device(
hass,
child,
coordinator=child_coordinator,
feature_type=feature_type,
entity_class=entity_class,
descriptions=descriptions,
parent=device,
)
)
return entities return entities

View File

@ -9,6 +9,7 @@ from typing import Final, cast
from kasa import Device, Feature from kasa import Device, Feature
from homeassistant.components.number import ( from homeassistant.components.number import (
DOMAIN as NUMBER_DOMAIN,
NumberEntity, NumberEntity,
NumberEntityDescription, NumberEntityDescription,
NumberMode, NumberMode,
@ -17,6 +18,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TPLinkConfigEntry from . import TPLinkConfigEntry
from .deprecate import async_cleanup_deprecated
from .entity import ( from .entity import (
CoordinatedTPLinkFeatureEntity, CoordinatedTPLinkFeatureEntity,
TPLinkDataUpdateCoordinator, TPLinkDataUpdateCoordinator,
@ -77,19 +79,27 @@ async def async_setup_entry(
"""Set up number entities.""" """Set up number entities."""
data = config_entry.runtime_data data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator parent_coordinator = data.parent_coordinator
children_coordinators = data.children_coordinators
device = parent_coordinator.device device = parent_coordinator.device
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( known_child_device_ids: set[str] = set()
hass=hass, first_check = True
device=device,
coordinator=parent_coordinator,
feature_type=Feature.Type.Number,
entity_class=TPLinkNumberEntity,
descriptions=NUMBER_DESCRIPTIONS_MAP,
child_coordinators=children_coordinators,
)
async_add_entities(entities) def _check_device() -> None:
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
hass=hass,
device=device,
coordinator=parent_coordinator,
feature_type=Feature.Type.Number,
entity_class=TPLinkNumberEntity,
descriptions=NUMBER_DESCRIPTIONS_MAP,
known_child_device_ids=known_child_device_ids,
first_check=first_check,
)
async_cleanup_deprecated(hass, NUMBER_DOMAIN, config_entry.entry_id, entities)
async_add_entities(entities)
_check_device()
first_check = False
config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity):

View File

@ -7,11 +7,16 @@ from typing import Final, cast
from kasa import Device, Feature from kasa import Device, Feature
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SelectEntity,
SelectEntityDescription,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TPLinkConfigEntry from . import TPLinkConfigEntry
from .deprecate import async_cleanup_deprecated
from .entity import ( from .entity import (
CoordinatedTPLinkFeatureEntity, CoordinatedTPLinkFeatureEntity,
TPLinkDataUpdateCoordinator, TPLinkDataUpdateCoordinator,
@ -54,19 +59,27 @@ async def async_setup_entry(
"""Set up select entities.""" """Set up select entities."""
data = config_entry.runtime_data data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator parent_coordinator = data.parent_coordinator
children_coordinators = data.children_coordinators
device = parent_coordinator.device device = parent_coordinator.device
known_child_device_ids: set[str] = set()
first_check = True
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( def _check_device() -> None:
hass=hass, entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
device=device, hass=hass,
coordinator=parent_coordinator, device=device,
feature_type=Feature.Type.Choice, coordinator=parent_coordinator,
entity_class=TPLinkSelectEntity, feature_type=Feature.Type.Choice,
descriptions=SELECT_DESCRIPTIONS_MAP, entity_class=TPLinkSelectEntity,
child_coordinators=children_coordinators, descriptions=SELECT_DESCRIPTIONS_MAP,
) known_child_device_ids=known_child_device_ids,
async_add_entities(entities) first_check=first_check,
)
async_cleanup_deprecated(hass, SELECT_DOMAIN, config_entry.entry_id, entities)
async_add_entities(entities)
_check_device()
first_check = False
config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity): class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity):

View File

@ -129,20 +129,27 @@ async def async_setup_entry(
"""Set up sensors.""" """Set up sensors."""
data = config_entry.runtime_data data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator parent_coordinator = data.parent_coordinator
children_coordinators = data.children_coordinators
device = parent_coordinator.device device = parent_coordinator.device
known_child_device_ids: set[str] = set()
first_check = True
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( def _check_device() -> None:
hass=hass, entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
device=device, hass=hass,
coordinator=parent_coordinator, device=device,
feature_type=Feature.Type.Sensor, coordinator=parent_coordinator,
entity_class=TPLinkSensorEntity, feature_type=Feature.Type.Sensor,
descriptions=SENSOR_DESCRIPTIONS_MAP, entity_class=TPLinkSensorEntity,
child_coordinators=children_coordinators, descriptions=SENSOR_DESCRIPTIONS_MAP,
) known_child_device_ids=known_child_device_ids,
async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities) first_check=first_check,
async_add_entities(entities) )
async_cleanup_deprecated(hass, SENSOR_DOMAIN, config_entry.entry_id, entities)
async_add_entities(entities)
_check_device()
first_check = False
config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity):

View File

@ -8,11 +8,16 @@ from typing import Any, cast
from kasa import Feature from kasa import Feature
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TPLinkConfigEntry from . import TPLinkConfigEntry
from .deprecate import async_cleanup_deprecated
from .entity import ( from .entity import (
CoordinatedTPLinkFeatureEntity, CoordinatedTPLinkFeatureEntity,
TPLinkFeatureEntityDescription, TPLinkFeatureEntityDescription,
@ -84,17 +89,26 @@ async def async_setup_entry(
data = config_entry.runtime_data data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator parent_coordinator = data.parent_coordinator
device = parent_coordinator.device device = parent_coordinator.device
known_child_device_ids: set[str] = set()
first_check = True
entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( def _check_device() -> None:
hass=hass, entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children(
device=device, hass=hass,
coordinator=parent_coordinator, device=device,
feature_type=Feature.Switch, coordinator=parent_coordinator,
entity_class=TPLinkSwitch, feature_type=Feature.Switch,
descriptions=SWITCH_DESCRIPTIONS_MAP, entity_class=TPLinkSwitch,
) descriptions=SWITCH_DESCRIPTIONS_MAP,
known_child_device_ids=known_child_device_ids,
first_check=first_check,
)
async_cleanup_deprecated(hass, SWITCH_DOMAIN, config_entry.entry_id, entities)
async_add_entities(entities)
async_add_entities(entities) _check_device()
first_check = False
config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device))
class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity): class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity):

View File

@ -8,7 +8,16 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from kasa import AuthenticationError, DeviceConfig, Feature, KasaException, Module from kasa import (
AuthenticationError,
Device,
DeviceConfig,
DeviceType,
Feature,
KasaException,
Module,
)
from kasa.iot import IotStrip
import pytest import pytest
from homeassistant import setup from homeassistant import setup
@ -827,3 +836,152 @@ async def test_migrate_remove_device_config(
assert entry.data == expected_entry_data assert entry.data == expected_entry_data
assert "Migration to version 1.5 complete" in caplog.text assert "Migration to version 1.5 complete" in caplog.text
@pytest.mark.parametrize(
("device_type"),
[
(Device),
(IotStrip),
],
)
@pytest.mark.parametrize(
("platform", "feature_id", "translated_name"),
[
pytest.param("switch", "led", "led", id="switch"),
pytest.param(
"sensor", "current_consumption", "current_consumption", id="sensor"
),
pytest.param("binary_sensor", "overheated", "overheated", id="binary_sensor"),
pytest.param("number", "smooth_transition_on", "smooth_on", id="number"),
pytest.param("select", "light_preset", "light_preset", id="select"),
pytest.param("button", "reboot", "restart", id="button"),
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_automatic_device_addition_and_removal(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connect: AsyncMock,
mock_discovery: AsyncMock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
platform: str,
feature_id: str,
translated_name: str,
device_type: type,
) -> None:
"""Test for automatic device addition and removal."""
children = {
f"child{index}": _mocked_device(
alias=f"child {index}",
features=[feature_id],
device_type=DeviceType.StripSocket,
device_id=f"child{index}",
)
for index in range(1, 5)
}
mock_device = _mocked_device(
alias="hub",
children=[children["child1"], children["child2"]],
features=[feature_id],
device_type=DeviceType.Hub,
spec=device_type,
device_id="hub_parent",
)
with override_side_effect(mock_connect["connect"], lambda *_, **__: mock_device):
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
for child_id in (1, 2):
entity_id = f"{platform}.child_{child_id}_{translated_name}"
state = hass.states.get(entity_id)
assert state
assert entity_registry.async_get(entity_id)
parent_device = device_registry.async_get_device(
identifiers={(DOMAIN, "hub_parent")}
)
assert parent_device
for device_id in ("child1", "child2"):
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
)
assert device_entry
assert device_entry.via_device_id == parent_device.id
# Remove one of the devices
mock_device.children = [children["child1"]]
freezer.tick(5)
async_fire_time_changed(hass)
entity_id = f"{platform}.child_2_{translated_name}"
state = hass.states.get(entity_id)
assert state is None
assert entity_registry.async_get(entity_id) is None
assert device_registry.async_get_device(identifiers={(DOMAIN, "child2")}) is None
# Re-dd the previously removed child device
mock_device.children = [
children["child1"],
children["child2"],
]
freezer.tick(5)
async_fire_time_changed(hass)
for child_id in (1, 2):
entity_id = f"{platform}.child_{child_id}_{translated_name}"
state = hass.states.get(entity_id)
assert state
assert entity_registry.async_get(entity_id)
for device_id in ("child1", "child2"):
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
)
assert device_entry
assert device_entry.via_device_id == parent_device.id
# Add child devices
mock_device.children = [children["child1"], children["child3"], children["child4"]]
freezer.tick(5)
async_fire_time_changed(hass)
for child_id in (1, 3, 4):
entity_id = f"{platform}.child_{child_id}_{translated_name}"
state = hass.states.get(entity_id)
assert state
assert entity_registry.async_get(entity_id)
for device_id in ("child1", "child3", "child4"):
assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)})
# Add the previously removed child device
mock_device.children = [
children["child1"],
children["child2"],
children["child3"],
children["child4"],
]
freezer.tick(5)
async_fire_time_changed(hass)
for child_id in (1, 2, 3, 4):
entity_id = f"{platform}.child_{child_id}_{translated_name}"
state = hass.states.get(entity_id)
assert state
assert entity_registry.async_get(entity_id)
for device_id in ("child1", "child2", "child3", "child4"):
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
)
assert device_entry
assert device_entry.via_device_id == parent_device.id