8000 Enable dynamic hub child creation and deletion on update (#1454) · ryenitcher/python-kasa@b23019e · GitHub
[go: up one dir, main page]

Skip to content

Commit b23019e

Browse files
authored
Enable dynamic hub child creation and deletion on update (python-kasa#1454)
1 parent 17356c1 commit b23019e

File tree

8 files changed

+445
-115
lines changed

8 files changed

+445
-115
lines changed

kasa/smart/modules/childdevice.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
True
3939
"""
4040

41+
from ...device_type import DeviceType
4142
from ..smartmodule import SmartModule
4243

4344

@@ -46,3 +47,10 @@ class ChildDevice(SmartModule):
4647

4748
REQUIRED_COMPONENT = "child_device"
4849
QUERY_GETTER_NAME = "get_child_device_list"
50+
51+
def query(self) -> dict:
52+
"""Query to execute during the update cycle."""
53+
q = super().query()
54+
if self._device.device_type is DeviceType.Hub:
55+
q["get_child_device_component_list"] = None
56+
return q

kasa/smart/smartchilddevice.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ async def _update(self, update_children: bool = True) -> None:
109109
)
110110
self._last_update_time = now
111111

112+
# We can first initialize the features after the first update.
113+
# We make here an assumption that every device has at least a single feature.
114+
if not self._features:
115+
await self._initialize_features()
116+
112117
@classmethod
113118
async def create(
114119
cls,

kasa/smart/smartdevice.py

Lines changed: 97 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import base64
66
import logging
77
import time
8-
from collections.abc import Mapping, Sequence
8+
from collections.abc import Sequence
99
from datetime import UTC, datetime, timedelta, tzinfo
1010
from typing import TYPE_CHECKING, Any, TypeAlias, cast
1111

@@ -68,10 +68,11 @@ def __init__(
6868
self._state_information: dict[str, Any] = {}
6969
self._modules: dict[str | ModuleName[Module], SmartModule] = {}
7070
self._parent: SmartDevice | None = None
71-
self._children: Mapping[str, SmartDevice] = {}
71+
self._children: dict[str, SmartDevice] = {}
7272
self._last_update_time: float | None = None
7373
self._on_since: datetime | None = None
7474
self._info: dict[str, Any] = {}
75+
self._logged_missing_child_ids: set[str] = set()
7576

7677
async def _initialize_children(self) -> None:
7778
"""Initialize children for power strips."""
@@ -82,23 +83,86 @@ async def _initialize_children(self) -> None:
8283
resp = await self.protocol.query(child_info_query)
8384
self.internal_state.update(resp)
8485

85-
children = self.internal_state["get_child_device_list"]["child_device_list"]
86-
children_components_raw = {
87-
child["device_id"]: child
88-
for child in self.internal_state["get_child_device_component_list"][
89-
"child_component_list"
90-
]
91-
}
86+
async def _try_create_child(
87+
self, info: dict, child_components: dict
88+
) -> SmartDevice | None:
9289
from .smartchilddevice import SmartChildDevice
9390

94-
self._children = {
95-
child_info["device_id"]: await SmartChildDevice.create(
96-
parent=self,
97-
child_info=child_info,
98-
child_components_raw=children_components_raw[child_info["device_id"]],
99-
)
100-
for child_info in children
91+
return await SmartChildDevice.create(
92+
parent=self,
93+
child_info=info,
94+
child_components_raw=child_components,
95+
)
96+
97+
async def _create_delete_children(
98+
self,
99+
child_device_resp: dict[str, list],
100+
child_device_components_resp: dict[str, list],
101+
) -> bool:
102+
"""Create and delete children. Return True if children changed.
103+
104+
Adds newly found children and deletes children that are no longer
105+
reported by the device. It will only log once per child_id that
106+
can't be created to avoid spamming the logs on every update.
107+
"""
108+
changed = False
109+
smart_children_components = {
110+
child["device_id"]: child
111+
for child in child_device_components_resp["child_component_list"]
101112
}
113+
children = self._children
114+
child_ids: set[str] = set()
115+
existing_child_ids = set(self._children.keys())
116+
117+
for info in child_device_resp["child_device_list"]:
118+
if (child_id := info.get("device_id")) and (
119+
child_components := smart_children_components.get(child_id)
120+
):
121+
child_ids.add(child_id)
122+
123+
if child_id in existing_child_ids:
124+
continue
125+
126+
child = await self._try_create_child(info, child_components)
127+
if child:
128+
_LOGGER.debug("Created child device %s for %s", child, self.host)
129+
changed = True
130+
children[child_id] = child
131+
continue
132+
133+
if child_id not in self._logged_missing_child_ids:
134+
self._logged_missing_child_ids.add(child_id)
135+
_LOGGER.debug("Child device type not supported: %s", info)
136+
continue
137+
138+
if child_id:
139+
if child_id not in self._logged_missing_child_ids:
140+
self._logged_missing_child_ids.add(child_id)
141+
_LOGGER.debug(
142+
"Could not find child components for device %s, "
143+
"child_id %s, components: %s: ",
144+
self.host,
145+
child_id,
146+
smart_children_components,
147+
)
148+
continue
149+
150+
# If we couldn't get a child device id we still only want to
151+
# log once to avoid spamming the logs on every update cycle
152+
# so store it under an empty string
153+
if "" not in self._logged_missing_child_ids:
154+
self._logged_missing_child_ids.add("")
155+
_LOGGER.debug(
156+
"Could not find child id for device %s, info: %s", self.host, info
157+
)
158+
159+
removed_ids = existing_child_ids - child_ids
160+
for removed_id in removed_ids:
161+
changed = True
162+
removed = children.pop(removed_id)
163+
_LOGGER.debug("Removed child device %s from %s", removed, self.host)
164+
165+
return changed
102166

103167
@property
104168
def children(self) -> Sequence[SmartDevice]:
@@ -164,21 +228,29 @@ async def _negotiate(self) -> None:
164228
if "child_device" in self._components and not self.children:
165229
await self._initialize_children()
166230

167-
def _update_children_info(self) -> None:
168-
"""Update the internal child device info from the parent info."""
231+
async def _update_children_info(self) -> bool:
232+
"""Update the internal child device info from the parent info.
233+
234+
Return true if children added or deleted.
235+
"""
236+
changed = False
169237
if child_info := self._try_get_response(
170238
self._last_update, "get_child_device_list", {}
171239
):
240+
changed = await self._create_delete_children(
241+
child_info, self._last_update["get_child_device_component_list"]
242+
)
243+
172244
for info in child_info["child_device_list"]:
173-
child_id = info["device_id"]
245+
child_id = info.get("device_id")
174246
if child_id not in self._children:
175-
_LOGGER.debug(
176-
"Skipping child update for %s, probably unsupported device",
177-
child_id,
178-
)
247+
# _create_delete_children has already logged a message
179248
continue
249+
180250
self._children[child_id]._update_internal_state(info)
181251

252+
return changed
253+
182254
def _update_internal_info(self, info_resp: dict) -> None:
183255
"""Update the internal device info."""
184256
self._info = self._try_get_response(info_resp, "get_device_info")
@@ -201,13 +273,13 @@ async def update(self, update_children: bool = True) -> None:
201273

202274
resp = await self._modular_update(first_update, now)
203275

204-
self._update_children_info()
276+
children_changed = await self._update_children_info()
205277
# Call child update which will only update module calls, info is updated
206278
# from get_child_device_list. update_children only affects hub devices, other
207279
# devices will always update children to prevent errors on module access.
208280
# This needs to go after updating the internal state of the children so that
209281
# child modules have access to their sysinfo.
210-
if first_update or update_children or self.device_type != DeviceType.Hub:
282+
if children_changed or update_children or self.device_type != DeviceType.Hub:
211283
for child in self._children.values():
212284
if TYPE_CHECKING:
213285
assert isinstance(child, SmartChildDevice)
@@ -469,8 +541,6 @@ async def _initialize_features(self) -> None:
469541
module._initialize_features()
470542
for feat in module._module_features.values():
471543
self._add_feature(feat)
472-
for child in self._children.values():
473-
await child._initialize_features()
474544

475545
@property
476546
def _is_hub_child(self) -> bool:

kasa/smartcam/modules/childdevice.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ def query(self) -> dict:
1919
2020
Default implementation uses the raw query getter w/o parameters.
2121
"""
22-
return {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}}
22+
q = {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}}
23+
if self._device.device_type is DeviceType.Hub:
24+
q["getChildDeviceComponentList"] = {"childControl": {"start_index": 0}}
25+
return q
2326

2427
async def _check_supported(self) -> bool:
2528
"""Additional check to see if the module is supported by the device."""

kasa/smartcam/smartcamdevice.py

Lines changed: 28 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -70,21 +70,29 @@ def _update_internal_state(self, info: dict[str, Any]) -> None:
7070
"""
7171
self._info = self._map_info(info)
7272

73-
def _update_children_info(self) -> None:
74-
"""Update the internal child device info from the parent info."""
73+
async def _update_children_info(self) -> bool:
74+
"""Update the internal child device info from the parent info.
75+
76+
Return true if children added or deleted.
77+
"""
78+
changed = False
7579
if child_info := self._try_get_response(
7680
self._last_update, "getChildDeviceList", {}
7781
):
82+
changed = await self._create_delete_children(
83+
child_info, self._last_update["getChildDeviceComponentList"]
84+
)
85+
7886
for info in child_info["child_device_list"]:
79-
child_id = info["device_id"]
87+
child_id = info.get("device_id")
8088
if child_id not in self._children:
81-
_LOGGER.debug(
82-
"Skipping child update for %s, probably unsupported device",
83-
child_id,
84-
)
89+
# _create_delete_children has already logged a message
8590
continue
91+
8692
self._children[child_id]._update_internal_state(info)
8793

94+
return changed
95+
8896
async def _initialize_smart_child(
8997
self, info: dict, child_components_raw: ComponentsRaw
9098
) -> SmartDevice:
@@ -113,7 +121,6 @@ async def _initialize_smartcam_child(
113121
child_id = info["device_id"]
114122
child_protocol = _ChildCameraProtocolWrapper(child_id, self.protocol)
115123

116-
last_update = {"getDeviceInfo": {"device_info": {"basic_info": info}}}
117124
app_component_list = {
118125
"app_component_list": child_components_raw["component_list"]
119126
}
@@ -124,7 +131,6 @@ async def _initialize_smartcam_child(
124131
child_info=info,
125132
child_components_raw=app_component_list,
126133
protocol=child_protocol,
127-
last_update=last_update,
128134
)
129135

130136
async def _initialize_children(self) -> None:
@@ -136,35 +142,22 @@ async def _initialize_children(self) -> None:
136142
resp = await self.protocol.query(child_info_query)
137143
self.internal_state.update(resp)
138144

139-
smart_children_components = {
140-
child["device_id"]: child
141-
for child in resp["getChildDeviceComponentList"]["child_component_list"]
142-
}
143-
children = {}
144-
from .smartcamchild import SmartCamChild
145+
async def _try_create_child(
146+
self, info: dict, child_components: dict
147+
) -> SmartDevice | None:
148+
if not (category := info.get("category")):
149+
return None
145150

146-
for info in resp["getChildDeviceList"]["child_device_list"]:
147-
if (
148-
(category := info.get("category"))
149-
and (child_id := info.get("device_id"))
150-
and (child_components := smart_children_components.get(child_id))
151-
):
152-
# Smart
153-
if category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP:
154-
children[child_id] = await self._initialize_smart_child(
155-
info, child_components
156-
)
157-
continue
158-
# Smartcam
159-
if category in SmartCamChild.CHILD_DEVICE_TYPE_MAP:
160-
children[child_id] = await self._initialize_smartcam_child(
161-
info, child_components
162-
)
163-
continue
151+
# Smart
152+
if category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP:
153+
return await self._initialize_smart_child(info, child_components)
154+
# Smartcam
155+
from .smartcamchild import SmartCamChild
164156

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

167-
self._children = children
160+
return None
168161

169162
async def _initialize_modules(self) -> None:
170163
"""Initialize modules based on comp 707A onent negotiation response."""
@@ -190,9 +183,6 @@ async def _initialize_features(self) -> None:
190183
for feat in module._module_features.values():
191184
self._add_feature(feat)
192185

193-
for child in self._children.values():
194-
await child._initialize_features()
195-
196186
async def _query_setter_helper(
197187
self, method: str, module: str, section: str, params: dict | None = None
198188
) -> dict:

0 commit comments

Comments
 (0)
0