Skip to content

Enable dynamic hub child creation and deletion on update #1454

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions kasa/smart/modules/childdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
True
"""

from ...device_type import DeviceType
from ..smartmodule import SmartModule


Expand All @@ -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()
Comment on lines 49 to +53
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's time to make this query explicit?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean? We know super will always be the QUERY_GETTER_NAME so this avoids typing the free string twice.

Copy link
Member

@rytilahti rytilahti Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have usually removed the QUERY_GETTER_NAME and simply formulated the query in its place, when I have added new queries to existing ones. This way it's less magical and easier to follow for readers, but it's fine also as it is. Your call!

if self._device.device_type is DeviceType.Hub:
q["get_child_device_component_list"] = None
return q
5 changes: 5 additions & 0 deletions kasa/smart/smartchilddevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ async def _update(self, update_children: bool = True) -> None:
)
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,
Expand Down
124 changes: 97 additions & 27 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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

Expand Down Expand Up @@ -68,10 +68,11 @@ def __init__(
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."""
Expand All @@ -82,23 +83,86 @@ async def _initialize_children(self) -> None:
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]:
Expand Down Expand Up @@ -164,21 +228,29 @@ async def _negotiate(self) -> None:
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")
Expand All @@ -201,13 +273,13 @@ async def update(self, update_children: bool = True) -> None:

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)
Expand Down Expand Up @@ -469,8 +541,6 @@ async def _initialize_features(self) -> None:
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:
Expand Down
5 changes: 4 additions & 1 deletion kasa/smartcam/modules/childdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ def query(self) -> dict:

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."""
Expand Down
66 changes: 28 additions & 38 deletions kasa/smartcam/smartcamdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,21 +70,29 @@ def _update_internal_state(self, info: dict[str, Any]) -> None:
"""
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:
Expand Down Expand Up @@ -113,7 +121,6 @@ async def _initialize_smartcam_child(
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"]
}
Expand All @@ -124,7 +131,6 @@ async def _initialize_smartcam_child(
child_info=info,
child_components_raw=app_component_list,
protocol=child_protocol,
last_update=last_update,
)

async def _initialize_children(self) -> None:
Expand All @@ -136,35 +142,22 @@ async def _initialize_children(self) -> None:
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 = {}
from .smartcamchild import SmartCamChild
async def _try_create_child(
self, info: dict, child_components: dict
) -> SmartDevice | None:
if not (category := info.get("category")):
return None

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
# Smart
if category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP:
return await self._initialize_smart_child(info, child_components)
# Smartcam
from .smartcamchild import SmartCamChild

_LOGGER.debug("Child device type not supported: %s", info)
if category in SmartCamChild.CHILD_DEVICE_TYPE_MAP:
return await self._initialize_smartcam_child(info, child_components)

self._children = children
return None

async def _initialize_modules(self) -> None:
"""Initialize modules based on component negotiation response."""
Expand All @@ -190,9 +183,6 @@ async def _initialize_features(self) -> None:
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:
Expand Down
Loading
Loading