Enable dynamic hub child creation and deletion on update (#1454)

This commit is contained in:
Steven B.
2025-01-15 19:10:32 +00:00
committed by GitHub
parent 17356c10f1
commit b23019e748
8 changed files with 445 additions and 115 deletions

View File

@@ -38,6 +38,7 @@ Plug 3: False
True
"""
from ...device_type import DeviceType
from ..smartmodule import SmartModule
@@ -46,3 +47,10 @@ class ChildDevice(SmartModule):
REQUIRED_COMPONENT = "child_device"
QUERY_GETTER_NAME = "get_child_device_list"
def query(self) -> dict:
"""Query to execute during the update cycle."""
q = super().query()
if self._device.device_type is DeviceType.Hub:
q["get_child_device_component_list"] = None
return q

View File

@@ -109,6 +109,11 @@ class SmartChildDevice(SmartDevice):
)
self._last_update_time = now
# We can first initialize the features after the first update.
# We make here an assumption that every device has at least a single feature.
if not self._features:
await self._initialize_features()
@classmethod
async def create(
cls,

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import base64
import logging
import time
from collections.abc import Mapping, Sequence
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, TypeAlias, cast
@@ -68,10 +68,11 @@ class SmartDevice(Device):
self._state_information: dict[str, Any] = {}
self._modules: dict[str | ModuleName[Module], SmartModule] = {}
self._parent: SmartDevice | None = None
self._children: Mapping[str, SmartDevice] = {}
self._children: dict[str, SmartDevice] = {}
self._last_update_time: float | None = None
self._on_since: datetime | None = None
self._info: dict[str, Any] = {}
self._logged_missing_child_ids: set[str] = set()
async def _initialize_children(self) -> None:
"""Initialize children for power strips."""
@@ -82,23 +83,86 @@ class SmartDevice(Device):
resp = await self.protocol.query(child_info_query)
self.internal_state.update(resp)
children = self.internal_state["get_child_device_list"]["child_device_list"]
children_components_raw = {
child["device_id"]: child
for child in self.internal_state["get_child_device_component_list"][
"child_component_list"
]
}
async def _try_create_child(
self, info: dict, child_components: dict
) -> SmartDevice | None:
from .smartchilddevice import SmartChildDevice
self._children = {
child_info["device_id"]: await SmartChildDevice.create(
parent=self,
child_info=child_info,
child_components_raw=children_components_raw[child_info["device_id"]],
)
for child_info in children
return await SmartChildDevice.create(
parent=self,
child_info=info,
child_components_raw=child_components,
)
async def _create_delete_children(
self,
child_device_resp: dict[str, list],
child_device_components_resp: dict[str, list],
) -> bool:
"""Create and delete children. Return True if children changed.
Adds newly found children and deletes children that are no longer
reported by the device. It will only log once per child_id that
can't be created to avoid spamming the logs on every update.
"""
changed = False
smart_children_components = {
child["device_id"]: child
for child in child_device_components_resp["child_component_list"]
}
children = self._children
child_ids: set[str] = set()
existing_child_ids = set(self._children.keys())
for info in child_device_resp["child_device_list"]:
if (child_id := info.get("device_id")) and (
child_components := smart_children_components.get(child_id)
):
child_ids.add(child_id)
if child_id in existing_child_ids:
continue
child = await self._try_create_child(info, child_components)
if child:
_LOGGER.debug("Created child device %s for %s", child, self.host)
changed = True
children[child_id] = child
continue
if child_id not in self._logged_missing_child_ids:
self._logged_missing_child_ids.add(child_id)
_LOGGER.debug("Child device type not supported: %s", info)
continue
if child_id:
if child_id not in self._logged_missing_child_ids:
self._logged_missing_child_ids.add(child_id)
_LOGGER.debug(
"Could not find child components for device %s, "
"child_id %s, components: %s: ",
self.host,
child_id,
smart_children_components,
)
continue
# If we couldn't get a child device id we still only want to
# log once to avoid spamming the logs on every update cycle
# so store it under an empty string
if "" not in self._logged_missing_child_ids:
self._logged_missing_child_ids.add("")
_LOGGER.debug(
"Could not find child id for device %s, info: %s", self.host, info
)
removed_ids = existing_child_ids - child_ids
for removed_id in removed_ids:
changed = True
removed = children.pop(removed_id)
_LOGGER.debug("Removed child device %s from %s", removed, self.host)
return changed
@property
def children(self) -> Sequence[SmartDevice]:
@@ -164,21 +228,29 @@ class SmartDevice(Device):
if "child_device" in self._components and not self.children:
await self._initialize_children()
def _update_children_info(self) -> None:
"""Update the internal child device info from the parent info."""
async def _update_children_info(self) -> bool:
"""Update the internal child device info from the parent info.
Return true if children added or deleted.
"""
changed = False
if child_info := self._try_get_response(
self._last_update, "get_child_device_list", {}
):
changed = await self._create_delete_children(
child_info, self._last_update["get_child_device_component_list"]
)
for info in child_info["child_device_list"]:
child_id = info["device_id"]
child_id = info.get("device_id")
if child_id not in self._children:
_LOGGER.debug(
"Skipping child update for %s, probably unsupported device",
child_id,
)
# _create_delete_children has already logged a message
continue
self._children[child_id]._update_internal_state(info)
return changed
def _update_internal_info(self, info_resp: dict) -> None:
"""Update the internal device info."""
self._info = self._try_get_response(info_resp, "get_device_info")
@@ -201,13 +273,13 @@ class SmartDevice(Device):
resp = await self._modular_update(first_update, now)
self._update_children_info()
children_changed = await self._update_children_info()
# Call child update which will only update module calls, info is updated
# from get_child_device_list. update_children only affects hub devices, other
# devices will always update children to prevent errors on module access.
# This needs to go after updating the internal state of the children so that
# child modules have access to their sysinfo.
if first_update or update_children or self.device_type != DeviceType.Hub:
if children_changed or update_children or self.device_type != DeviceType.Hub:
for child in self._children.values():
if TYPE_CHECKING:
assert isinstance(child, SmartChildDevice)
@@ -469,8 +541,6 @@ class SmartDevice(Device):
module._initialize_features()
for feat in module._module_features.values():
self._add_feature(feat)
for child in self._children.values():
await child._initialize_features()
@property
def _is_hub_child(self) -> bool:

View File

@@ -19,7 +19,10 @@ class ChildDevice(SmartCamModule):
Default implementation uses the raw query getter w/o parameters.
"""
return {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}}
q = {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}}
if self._device.device_type is DeviceType.Hub:
q["getChildDeviceComponentList"] = {"childControl": {"start_index": 0}}
return q
async def _check_supported(self) -> bool:
"""Additional check to see if the module is supported by the device."""

View File

@@ -70,21 +70,29 @@ class SmartCamDevice(SmartDevice):
"""
self._info = self._map_info(info)
def _update_children_info(self) -> None:
"""Update the internal child device info from the parent info."""
async def _update_children_info(self) -> bool:
"""Update the internal child device info from the parent info.
Return true if children added or deleted.
"""
changed = False
if child_info := self._try_get_response(
self._last_update, "getChildDeviceList", {}
):
changed = await self._create_delete_children(
child_info, self._last_update["getChildDeviceComponentList"]
)
for info in child_info["child_device_list"]:
child_id = info["device_id"]
child_id = info.get("device_id")
if child_id not in self._children:
_LOGGER.debug(
"Skipping child update for %s, probably unsupported device",
child_id,
)
# _create_delete_children has already logged a message
continue
self._children[child_id]._update_internal_state(info)
return changed
async def _initialize_smart_child(
self, info: dict, child_components_raw: ComponentsRaw
) -> SmartDevice:
@@ -113,7 +121,6 @@ class SmartCamDevice(SmartDevice):
child_id = info["device_id"]
child_protocol = _ChildCameraProtocolWrapper(child_id, self.protocol)
last_update = {"getDeviceInfo": {"device_info": {"basic_info": info}}}
app_component_list = {
"app_component_list": child_components_raw["component_list"]
}
@@ -124,7 +131,6 @@ class SmartCamDevice(SmartDevice):
child_info=info,
child_components_raw=app_component_list,
protocol=child_protocol,
last_update=last_update,
)
async def _initialize_children(self) -> None:
@@ -136,35 +142,22 @@ class SmartCamDevice(SmartDevice):
resp = await self.protocol.query(child_info_query)
self.internal_state.update(resp)
smart_children_components = {
child["device_id"]: child
for child in resp["getChildDeviceComponentList"]["child_component_list"]
}
children = {}
async def _try_create_child(
self, info: dict, child_components: dict
) -> SmartDevice | None:
if not (category := info.get("category")):
return None
# Smart
if category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP:
return await self._initialize_smart_child(info, child_components)
# Smartcam
from .smartcamchild import SmartCamChild
for info in resp["getChildDeviceList"]["child_device_list"]:
if (
(category := info.get("category"))
and (child_id := info.get("device_id"))
and (child_components := smart_children_components.get(child_id))
):
# Smart
if category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP:
children[child_id] = await self._initialize_smart_child(
info, child_components
)
continue
# Smartcam
if category in SmartCamChild.CHILD_DEVICE_TYPE_MAP:
children[child_id] = await self._initialize_smartcam_child(
info, child_components
)
continue
if category in SmartCamChild.CHILD_DEVICE_TYPE_MAP:
return await self._initialize_smartcam_child(info, child_components)
_LOGGER.debug("Child device type not supported: %s", info)
self._children = children
return None
async def _initialize_modules(self) -> None:
"""Initialize modules based on component negotiation response."""
@@ -190,9 +183,6 @@ class SmartCamDevice(SmartDevice):
for feat in module._module_features.values():
self._add_feature(feat)
for child in self._children.values():
await child._initialize_features()
async def _query_setter_helper(
self, method: str, module: str, section: str, params: dict | None = None
) -> dict: