diff --git a/README.md b/README.md index e571461d3..74185cf67 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ The following devices have been tested and confirmed as working. If your device - **Cameras**: C100, C101, C110, C210, C220, C225, C325WB, C460, C520WS, C720, TC40, TC65, TC70 - **Doorbells and chimes**: D100C, D130, D230 - **Vacuums**: RV20 Max Plus, RV30 Max -- **Hubs**: H100, H200 +- **Hubs**: H100, H200, H500 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 diff --git a/SUPPORTED.md b/SUPPORTED.md index 1d38ee0e7..2cdba4c01 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -360,6 +360,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.3.2 - Hardware: 1.0 (EU) / Firmware: 1.3.6 - Hardware: 1.0 (US) / Firmware: 1.3.6 +- **H500** + - Hardware: 1.0 (US) / Firmware: 1.3.18 ### Hub-Connected Devices diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index fff57370f..d64219d23 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -612,6 +612,51 @@ async def _make_requests_or_exit( await protocol_to_close.close() +async def _smartcam_hub_child_num(protocol: SmartProtocol) -> int | None: + """Return getDeviceInfo.child_num when queryable (hub mismatch diagnostics).""" + try: + di_resp = await protocol.query( + {"getDeviceInfo": {"device_info": {"name": ["basic_info"]}}} + ) + child_num = ( + di_resp.get("getDeviceInfo", {}) + .get("device_info", {}) + .get("basic_info", {}) + .get("child_num") + ) + return child_num if child_num else None + except Exception: + return None + + +def _echo_empty_hub_child_list_warning( + *, + child_result: dict, + child_list: list | None, + child_num: int | None, +) -> None: + """Warn when getChildDeviceList is empty; point to hub enumeration docs.""" + doc_url = "https://python-kasa.readthedocs.io/en/latest/guides/hub.html" + if not isinstance(child_list, list): + mismatch = f" (getDeviceInfo child_num={child_num})" if child_num else "" + click.echo( + click.style( + "getChildDeviceList returned no child_device_list " + f"(keys: {list(child_result.keys())}){mismatch}; " + f"continuing without child fixtures. See {doc_url}", + fg="yellow", + ) + ) + elif not child_list and child_num: + click.echo( + click.style( + "getChildDeviceList is empty but getDeviceInfo reports " + f"child_num={child_num}; hub-only fixture. See {doc_url}", + fg="yellow", + ) + ) + + async def get_smart_camera_test_calls(protocol: SmartProtocol): """Get the list of test calls to make.""" test_calls: list[SmartCall] = [] @@ -652,7 +697,26 @@ async def get_smart_camera_test_calls(protocol: SmartProtocol): supports_multiple=True, ) ) - child_list = child_response["getChildDeviceList"]["child_device_list"] + # H500 (and similar) can succeed with only start_index/sum and omit + # child_device_list entirely; treat that as "no children" for dump. + child_result = child_response.get("getChildDeviceList") or {} + child_list = child_result.get("child_device_list") + child_num = None + if not isinstance(child_list, list) or not child_list: + child_num = await _smartcam_hub_child_num(protocol) + if not isinstance(child_list, list): + _echo_empty_hub_child_list_warning( + child_result=child_result, + child_list=child_list, + child_num=child_num, + ) + child_list = [] + else: + _echo_empty_hub_child_list_warning( + child_result=child_result, + child_list=child_list, + child_num=child_num, + ) for child in child_list: child_id = child.get("device_id") or child.get("dev_id") if not child_id: @@ -890,10 +954,12 @@ def scrub_child_device_ids( # Scrub the device ids in the parent for the smart camera protocol if gc := main_response.get("getChildDeviceComponentList"): - for child in gc["child_component_list"]: + for child in gc.get("child_component_list") or []: device_id = child["device_id"] child["device_id"] = scrubbed_child_id_map[device_id] - for child in main_response["getChildDeviceList"]["child_device_list"]: + for child in (main_response.get("getChildDeviceList") or {}).get( + "child_device_list" + ) or []: if device_id := child.get("device_id"): child["device_id"] = scrubbed_child_id_map[device_id] continue @@ -931,10 +997,10 @@ async def get_smart_fixtures( cp = child_wrapper(test_call.child_device_id, protocol) response = await cp.query(test_call.request) except AuthenticationError as ex: - _echo_error( - f"Unable to query the device due to an authentication error: {ex}", - ) - exit(1) + # H500 and similar can return encrypted garbage for unsupported + # methods and temporarily break the session; close/reopen and + # continue probing rather than aborting the whole dump. + click.echo(click.style(f"FAIL {ex}", fg="red")) except Exception as ex: if ( not test_call.should_succeed @@ -967,8 +1033,19 @@ async def get_smart_fixtures( device_request = device_requests.setdefault(success.child_device_id, []) device_request.append(success) + hub_requests = device_requests.get("", []) + if not hub_requests: + click.echo( + click.style( + "No successful hub queries; cannot build fixture. " + "Device may be wedged — power-cycle and retry.", + fg="red", + ) + ) + return [] + final = await _make_final_calls( - protocol, device_requests[""], "All successes", batch_size, child_device_id="" + protocol, hub_requests, "All successes", batch_size, child_device_id="" ) fixture_results = [] @@ -997,10 +1074,15 @@ async def get_smart_fixtures( # smart cam child devices provide more information in getChildDeviceList on the # parent than they return when queried directly for getDeviceInfo so we will store # it in the child fixture. + child_infos_on_parent: dict[str, dict] = {} if smart_cam_child_list := final.get("getChildDeviceList"): + # Normalize H500-style responses that omit child_device_list entirely. + if not isinstance(smart_cam_child_list.get("child_device_list"), list): + smart_cam_child_list["child_device_list"] = [] child_infos_on_parent = { info["device_id"]: info for info in smart_cam_child_list["child_device_list"] + if isinstance(info, dict) and "device_id" in info } for child_id, response in child_responses.items(): diff --git a/devtools/helpers/smartcamrequests.py b/devtools/helpers/smartcamrequests.py index 6c60b12a7..5d60eda2e 100644 --- a/devtools/helpers/smartcamrequests.py +++ b/devtools/helpers/smartcamrequests.py @@ -58,6 +58,7 @@ {"getTimezone": {"system": {"name": "basic"}}}, {"getClockStatus": {"system": {"name": "clock_status"}}}, {"getAppComponentList": {"app_component": {"name": "app_component_list"}}}, + {"getSupportChildDeviceCategory": {"childControl": {}}}, {"getChildDeviceComponentList": {"childControl": {"start_index": 0}}}, # single request only methods {"get": {"function": {"name": ["module_spec"]}}}, diff --git a/docs/source/contribute.md b/docs/source/contribute.md index 8a0603838..633e228ed 100644 --- a/docs/source/contribute.md +++ b/docs/source/contribute.md @@ -80,6 +80,12 @@ You can also execute the script against a network by using `--target`: `python - The script will run queries against the device, and prompt at the end if you want to save the results. If you choose to do so, it will save the fixture files directly in their correct place to make it easy to create a pull request. +```{note} +Some Tapo hubs (notably H500) report paired children in ``getDeviceInfo.child_num`` but +return an empty ``getChildDeviceList`` over LAN. ``dump_devinfo`` will save a +hub-only fixture in that case — see {ref}`Tapo / Kasa hubs and child devices `. +``` + ```{note} When adding new fixture files, you should run `pre-commit run -a` to re-generate the list of supported devices. You may need to adjust `device_fixtures.py` to add a new model into the correct device categories. Verify that test pass by executing `uv run pytest kasa`. diff --git a/docs/source/guides.md b/docs/source/guides.md index 75b1424b4..ec94da475 100644 --- a/docs/source/guides.md +++ b/docs/source/guides.md @@ -8,6 +8,7 @@ Guides of how to perform common actions using the library. guides/discover guides/connect guides/device +guides/hub guides/module guides/feature guides/light diff --git a/docs/source/guides/hub.md b/docs/source/guides/hub.md new file mode 100644 index 000000000..d2fd16251 --- /dev/null +++ b/docs/source/guides/hub.md @@ -0,0 +1,110 @@ +(topics-hub-children)= +# Tapo / Kasa hubs and child devices + +Tapo hubs (for example **H500**, **H200**) expose paired sensors, cameras, and other +accessories as *child devices*. python-kasa discovers and controls those children +through the hub's SMART camera protocol. + +```{contents} Contents + :local: +``` + +## How child enumeration works + +On each {meth}`~kasa.Device.update()`, hub devices query: + +| API | Role | +|-----|------| +| {code}`getChildDeviceList` | Returns the roster used to build {attr}`~kasa.Device.children` | +| {code}`getChildDeviceComponentList` | Component negotiation data for each child | +| {code}`getDeviceInfo` | Device metadata, including {code}`child_num` | +| {code}`getSupportChildDeviceCategory` | Supported child categories (cameras, Sub-G triggers, etc.) | + +The library builds {attr}`~kasa.Device.children` **only** from +{code}`getChildDeviceList.child_device_list`. Each entry must include at least a +{code}`device_id` and {code}`category` so python-kasa can construct the correct +child device class and route hub-proxied requests. + +Pairing new devices uses a different API surface ({code}`startScanChildDevice`, +{code}`getScanChildDeviceList`, {code}`addScanChildDeviceList`) via the +{mod}`~kasa.smartcam.modules.childsetup` module. That path is for **adding** +devices, not listing ones that are already paired. + +## `child_num` vs `getChildDeviceList` + +These fields answer different questions and **must not be treated as equivalent**. + +| Field | Source | Meaning | +|-------|--------|---------| +| {code}`child_num` | {code}`getDeviceInfo` → {code}`basic_info` | Metadata: how many children the hub believes are paired (often cloud/account state) | +| {code}`sum` / {code}`child_device_list` | {code}`getChildDeviceList` | Enumeration: which children are exposed to this LAN session right now | + +On some hubs (notably **H500** with current firmware), live testing shows: + +- {code}`child_num` may be greater than zero (for example {code}`6`) +- {code}`getChildDeviceList` returns {code}`sum: 0` and an empty or omitted + {code}`child_device_list` + +In that case {attr}`~kasa.Device.children` is empty after {meth}`~kasa.Device.update()` +even though the Tapo app shows paired cameras. This is **hub firmware behaviour**, +not a python-kasa parsing bug. The library logs a warning when it detects the +mismatch. + +Other hub models can show the opposite pattern. For example the **H200** fixture +in the test suite has {code}`child_num: 0` while {code}`getChildDeviceList.sum` is +{code}`5` with five list entries. Do not use {code}`child_num` as a substitute for +the list API on any model. + +## No LAN "reason code" + +There is currently **no documented LAN method** that explains *why* +{code}`getChildDeviceList` is empty while {code}`child_num` is non-zero. Probing +has not found an alternate roster API: category-filtered +{code}`getChildDeviceList`, {code}`getChildDeviceComponentList`, +{code}`getScanChildDeviceList`, and various {code}`get` module shapes either +return empty results or are unsupported. + +The Tapo mobile app may use cloud or additional local channels not exposed through +the third-party SMART protocol surface python-kasa implements. + +## Controlling hub children + +When children **are** listed, commands target a child by {code}`device_id`. The CLI +documents this pattern: + +```shell +kasa --host --child state +``` + +At the library level, child devices appear on {attr}`~kasa.Device.children` after +{meth}`~kasa.Device.update()`. Each child shares the hub transport; the protocol +wrapper injects the child {code}`device_id` into requests. + +Hub-paired cameras may **reject direct LAN authentication** at their own IP address +(challenge mismatch) because they expect hub or cloud context. Connecting to the +**hub** and enumerating children is the supported path when the list API works. + +## Capturing hub fixtures (`dump_devinfo`) + +{mod}`devtools.dump_devinfo` records hub responses and, when +{code}`child_device_list` contains entries, dumps per-child fixtures under +{code}`child_devices/`. + +If {code}`getChildDeviceList` omits {code}`child_device_list` or returns +{code}`sum: 0`, the tool continues with a **hub-only** fixture (no child files). +That accurately reflects what the LAN API returned; re-running the dump while +children are online will not help on hubs that withhold the list over local +access. + +## Summary for integrators + +1. Call {meth}`~kasa.Device.update()` on the hub and inspect + {attr}`~kasa.Device.children`, not {code}`child_num`. +2. An empty {attr}`~kasa.Device.children` with non-zero {code}`child_num` means + the hub did not expose children on {code}`getChildDeviceList` — there is no + further diagnostic field to query. +3. If you need a specific hub-paired camera and the list API is empty, you may need + to connect to that camera by IP when it accepts direct auth (model/firmware + dependent), or wait for Tapo to expose enumeration on LAN. +4. See {class}`~kasa.smartcam.modules.childdevice.ChildDevice` and + {class}`~kasa.smartcam.smartcamdevice.SmartCamDevice` for implementation details. diff --git a/docs/source/topics.md b/docs/source/topics.md index f7d0cdd50..d1f1713f3 100644 --- a/docs/source/topics.md +++ b/docs/source/topics.md @@ -129,6 +129,21 @@ The classes providing this functionality are: - {class}`KlapTransport ` - {class}`KlapTransportV2 ` +(topics-hub-children-summary)= +## Hub child enumeration + +Tapo/Kasa hubs (H500, H200, …) pair child cameras and accessories. python-kasa +builds {attr}`~kasa.Device.children` from {code}`getChildDeviceList`, not from +{code}`getDeviceInfo.child_num`. + +On some hubs — especially **H500** on current firmware — {code}`child_num` can be +non-zero while {code}`getChildDeviceList` returns {code}`sum: 0` and an empty list +over LAN. There is no additional LAN API that reports a reason for that mismatch; +{attr}`~kasa.Device.children` will simply be empty. + +See {ref}`Tapo / Kasa hubs and child devices ` for the full +explanation, API table, fixture-dump behaviour, and integrator notes. + (topics-errors-and-exceptions)= ## Errors and Exceptions diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index ad3e7331e..55718f87c 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -379,15 +379,22 @@ async def _handle_response_lists( ): return - response_list_name = next( - iter( - [ - key - for key in response_result - if isinstance(response_result[key], list) - ] - ) - ) + list_keys = [ + key for key in response_result if isinstance(response_result[key], list) + ] + if not list_keys: + # H500-style hubs: start_index/sum without a list field (e.g. + # getChildDeviceComponentList over LAN). + if list_sum: + _LOGGER.warning( + "Device %s returned sum=%s for method %s but no list field", + self._host, + list_sum, + method, + ) + return + + response_list_name = list_keys[0] while (list_length := len(response_result[response_list_name])) < list_sum: request = self._get_list_request(method, params, list_length) response = await self._execute_query( diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6be2392ce..94ba6a088 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -109,15 +109,23 @@ async def _create_delete_children( can't be created to avoid spamming the logs on every update. """ changed = False + component_list = child_device_components_resp.get("child_component_list", []) + if not isinstance(component_list, list): + component_list = [] smart_children_components = { child["device_id"]: child - for child in child_device_components_resp["child_component_list"] + for child in component_list + if isinstance(child, dict) and child.get("device_id") } children = self._children child_ids: set[str] = set() existing_child_ids = set(self._children.keys()) - for info in child_device_resp["child_device_list"]: + device_list = child_device_resp.get("child_device_list", []) + if not isinstance(device_list, list): + device_list = [] + + for info in device_list: if (child_id := info.get("device_id")) and ( child_components := smart_children_components.get(child_id) ): diff --git a/kasa/smartcam/modules/childdevice.py b/kasa/smartcam/modules/childdevice.py index 812fd0c1b..217abdfef 100644 --- a/kasa/smartcam/modules/childdevice.py +++ b/kasa/smartcam/modules/childdevice.py @@ -1,11 +1,25 @@ -"""Module for child devices.""" +"""Hub child enumeration via ``getChildDeviceList``. + +python-kasa builds :attr:`~kasa.Device.children` from this module's query +response, not from ``getDeviceInfo.child_num``. On some hubs (notably Tapo H500) +those values disagree: ``child_num`` may report paired children while +``getChildDeviceList`` returns ``sum: 0`` and an empty or omitted +``child_device_list`` over LAN. There is no documented LAN "reason" field for +that mismatch — see :ref:`topics-hub-children` in the documentation. +""" from ...device_type import DeviceType from ..smartcammodule import SmartCamModule class ChildDevice(SmartCamModule): - """Implementation for child devices.""" + """Enumerate children paired to a Tapo/Kasa hub. + + Queries ``getChildDeviceList`` (and ``getChildDeviceComponentList`` on hubs) + during :meth:`~kasa.Device.update()`. The list response is authoritative for + :attr:`~kasa.Device.children`; ``child_num`` in device info is informational + only and can disagree — see :ref:`topics-hub-children`. + """ REQUIRED_COMPONENT = "childControl" NAME = "childdevice" diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 7bc6184f3..b1abcbc96 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -103,7 +103,13 @@ def _update_internal_state(self, info: dict[str, Any]) -> None: self._info = self._map_info(info) async def _update_children_info(self) -> bool: - """Update the internal child device info from the parent info. + """Update child devices from ``getChildDeviceList``. + + Only ``child_device_list`` (and ``sum``) drive + :attr:`~kasa.Device.children`. ``getDeviceInfo.child_num`` is not used + here and can disagree on some hubs (e.g. H500 reports paired children in + device info but returns an empty list over LAN). See + :ref:`topics-hub-children`. Return true if children added or deleted. """ @@ -111,11 +117,38 @@ async def _update_children_info(self) -> bool: if child_info := self._try_get_response( self._last_update, "getChildDeviceList", {} ): + child_components_info = self._try_get_response( + self._last_update, "getChildDeviceComponentList", {} + ) changed = await self._create_delete_children( - child_info, self._last_update["getChildDeviceComponentList"] + child_info, child_components_info ) - for info in child_info["child_device_list"]: + child_device_list = child_info.get("child_device_list") + if not isinstance(child_device_list, list): + _LOGGER.warning( + "Missing child_device_list for hub %s (device keys: %s)", + self.host, + list(child_info.keys()), + ) + child_device_list = [] + + child_num = self._info.get("child_num") if self._info else None + if ( + self.device_type is DeviceType.Hub + and child_num + and not child_device_list + ): + _LOGGER.warning( + "Hub %s getDeviceInfo reports child_num=%s but " + "getChildDeviceList is empty (sum=%s); children are not " + "enumerable over LAN — see topics-hub-children in the docs", + self.host, + child_num, + child_info.get("sum"), + ) + + for info in child_device_list: child_id = info.get("device_id") if child_id not in self._children: # _create_delete_children has already logged a message diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 171602330..bd08accdb 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -208,11 +208,14 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): def _hub_remove_device(self, info, params): """Remove hub device.""" items_to_remove = [dev["device_id"] for dev in params["child_device_list"]] - children = info["getChildDeviceList"]["child_device_list"] + child_info = info.setdefault("getChildDeviceList", {}) + children = child_info.get("child_device_list") + if not isinstance(children, list): + children = [] new_children = [ dev for dev in children if dev["device_id"] not in items_to_remove ] - info["getChildDeviceList"]["child_device_list"] = new_children + child_info["child_device_list"] = new_children return {"result": {}, "error_code": 0} @@ -231,9 +234,11 @@ def get_child_device_queries(self, method, params): def _get_method_from_info(self, method, params): result = copy.deepcopy(self.info[method]) if "start_index" in result and "sum" in result: - list_key = next( - iter([key for key in result if isinstance(result[key], list)]) - ) + list_keys = [key for key in result if isinstance(result[key], list)] + if not list_keys: + # H500-style: start_index/sum present but no list field. + return {"result": result, "error_code": 0} + list_key = list_keys[0] assert isinstance(params, dict) module_name = next(iter(params)) diff --git a/tests/fixtures/smartcam/H500(US)_1.0_1.3.18.json b/tests/fixtures/smartcam/H500(US)_1.0_1.3.18.json new file mode 100644 index 000000000..b066afb47 --- /dev/null +++ b/tests/fixtures/smartcam/H500(US)_1.0_1.3.18.json @@ -0,0 +1,426 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "", + "connect_type": "wired", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "owner": "00000000000000000000000000000000", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "H500", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": false, + "hardware_version": "1.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "CC-BA-BD-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "sv": 1 + } + }, + "getAlertConfig": {}, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "setDetailLanguage", + "version": 1 + }, + { + "name": "dateTime", + "version": 1 + }, + { + "name": "system", + "version": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "hubRecord", + "version": 2 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "siren", + "version": 2 + }, + { + "name": "childControl", + "version": 1 + }, + { + "name": "childQuickSetup", + "version": 1 + }, + { + "name": "childInherit", + "version": 1 + }, + { + "name": "deviceLoad", + "version": 1 + }, + { + "name": "subg", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "preWakeUp", + "version": 1 + }, + { + "name": "supportRE", + "version": 1 + }, + { + "name": "testSignal", + "version": 1 + }, + { + "name": "dataDownload", + "version": 1 + }, + { + "name": "testChildSignal", + "version": 1 + }, + { + "name": "ringLog", + "version": 1 + }, + { + "name": "usbsharemanage", + "version": 1 + }, + { + "name": "hardDisk", + "version": 1 + }, + { + "name": "playbackDelete", + "version": 1 + }, + { + "name": "usrDefAudio", + "version": 1 + }, + { + "name": "audioSourceCapability", + "version": 1 + }, + { + "name": "mirrorscreen", + "version": 1 + }, + { + "name": "AIEnhance", + "version": 1 + }, + { + "name": "faceDetection", + "version": 1 + }, + { + "name": "snapshot", + "version": 2 + }, + { + "name": "faceTracking", + "version": 1 + }, + { + "name": "eventCenter", + "version": 2 + }, + { + "name": "matter", + "version": 1 + }, + { + "name": "matterControl", + "version": 1 + }, + { + "name": "localSmart", + "version": 2 + }, + { + "name": "generalCameraManage", + "version": 1 + }, + { + "name": "multiLensCamMgmt", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "chime", + "version": 2 + }, + { + "name": "hubPlayback", + "version": 1 + }, + { + "name": "migrate", + "version": 1 + }, + { + "name": "aovStorage", + "version": 1 + }, + { + "name": "tssDeviceManage", + "version": 1 + }, + { + "name": "recordDownload", + "version": 2 + } + ] + } + }, + "getChildDeviceComponentList": { + "child_component_list": [], + "start_index": 0, + "sum": 0 + }, + "getChildDeviceList": { + "child_device_list": [], + "start_index": 0, + "sum": 0 + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2026-07-08 07:22:03", + "seconds_from_1970": 1783520523 + } + } + }, + "getConnectionType": { + "link_type": "ethernet" + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "hub_h500", + "bind_status": true, + "child_num": 6, + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "H500 1.0", + "device_model": "H500", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "has_set_location_info": 1, + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "latitude": 0, + "local_ip": "127.0.0.123", + "longitude": 0, + "mac": "CC-BA-BD-00-00-00", + "matter_controller_leader_dev_id": "802D008309336A481C303B9E3CDB3C0724A77EF4", + "matter_controller_occupation": "leader", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Smart HomeBase", + "region": "US", + "status": "configured", + "subg_phyid": "f920p9", + "sw_version": "1.3.18 Build 20260423 rel.12749" + }, + "info": { + "avatar": "hub_h500", + "bind_status": true, + "child_num": 6, + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "H500 1.0", + "device_model": "H500", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.TAPOHUB", + "has_set_location_info": 1, + "hw_id": "00000000000000000000000000000000", + "hw_version": "1.0", + "latitude": 0, + "local_ip": "127.0.0.123", + "longitude": 0, + "mac": "CC-BA-BD-00-00-00", + "matter_controller_leader_dev_id": "802D008309336A481C303B9E3CDB3C0724A77EF4", + "matter_controller_occupation": "leader", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Smart HomeBase", + "region": "US", + "status": "configured", + "subg_phyid": "f920p9", + "sw_version": "1.3.18 Build 20260423 rel.12749" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": 30, + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "getMatterSetupInfo": { + "setup_code": "00000000000", + "setup_payload": "00:0.00000000000000.00" + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "on" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "detect_status": "failed", + "disk_name": "1", + "free_space": "7.83 GB", + "loop_record_status": "1", + "percent": "100", + "rw_attr": "rw", + "status": "normal", + "total_space": "10.00 GB", + "type": "local", + "video_free_space": "7.33 GB", + "video_total_space": "9.50 GB", + "write_protect": "0" + } + } + ] + } + }, + "getSirenConfig": { + "duration": 300, + "siren_type": "Doorbell Ring 1", + "volume": "6" + }, + "getSirenStatus": { + "status": "off", + "time_left": 0 + }, + "getSirenTypeList": { + "siren_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Dripping Tap", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "getSupportChildDeviceCategory": { + "device_category_list": [ + { + "category": "ipcamera" + }, + { + "category": "subg.trigger" + }, + { + "category": "subg.plugswitch" + } + ] + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-08:00", + "zone_id": "America/Los_Angeles" + } + } + } +} diff --git a/tests/protocols/test_smartprotocol.py b/tests/protocols/test_smartprotocol.py index 9ccbc3cb2..0a15f5df7 100644 --- a/tests/protocols/test_smartprotocol.py +++ b/tests/protocols/test_smartprotocol.py @@ -440,6 +440,65 @@ async def test_smartcam_protocol_list_request( assert resp == response +async def test_list_response_without_list_field() -> None: + """Hubs may return start_index/sum with no list key (e.g. H500 component list).""" + response = { + "getChildDeviceList": { + "child_device_list": [], + "start_index": 0, + "sum": 0, + }, + "getChildDeviceComponentList": { + "start_index": 0, + "sum": 0, + }, + } + request = { + "getChildDeviceList": {"childControl": {"start_index": 0}}, + "getChildDeviceComponentList": {"childControl": {"start_index": 0}}, + } + + ft = FakeSmartCamTransport( + response, + "foobar", + components_not_included=True, + get_child_fixtures=False, + ) + protocol = SmartCamProtocol(transport=ft) + resp = await protocol.query(request) + assert resp == response + + +async def test_list_response_without_list_field_sum_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Warn when sum claims items exist but no list field is present.""" + caplog.set_level(logging.WARNING) + response = { + "getChildDeviceComponentList": { + "start_index": 0, + "sum": 3, + }, + } + request = { + "getChildDeviceComponentList": {"childControl": {"start_index": 0}}, + } + + ft = FakeSmartCamTransport( + response, + "foobar", + components_not_included=True, + get_child_fixtures=False, + ) + protocol = SmartCamProtocol(transport=ft) + resp = await protocol.query(request) + assert resp == response + assert ( + "returned sum=3 for method getChildDeviceComponentList but no list field" + in (caplog.text) + ) + + async def test_incomplete_list( mocker: MockerFixture, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index aea73381a..e3b581207 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -781,6 +781,30 @@ class DummyModule(SmartModule): assert mod.query() == {} +async def test_create_delete_children_tolerates_missing_list_fields() -> None: + """Hub list responses may omit or mistype child list fields.""" + dev = await get_device_for_fixture_protocol("H500(US)_1.0_1.3.18.json", "SMARTCAM") + assert isinstance(dev, SmartCamDevice) + + changed = await dev._create_delete_children( + cast(dict[str, list], {"start_index": 0, "sum": 0}), + cast( + dict[str, list], + {"start_index": 0, "sum": 0, "child_component_list": "bad"}, + ), + ) + assert changed is False + + changed = await dev._create_delete_children( + cast( + dict[str, list], + {"child_device_list": "bad", "start_index": 0, "sum": 0}, + ), + cast(dict[str, list], {"child_component_list": ["not-a-dict"]}), + ) + assert changed is False + + @hub_all @pytest.mark.xdist_group(name="caplog") @pytest.mark.requires_dummy diff --git a/tests/smartcam/test_smartcamdevice.py b/tests/smartcam/test_smartcamdevice.py index e2c46161f..bdd551064 100644 --- a/tests/smartcam/test_smartcamdevice.py +++ b/tests/smartcam/test_smartcamdevice.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import logging from datetime import UTC, datetime from unittest.mock import AsyncMock, PropertyMock, patch @@ -13,7 +14,7 @@ from kasa.exceptions import AuthenticationError, DeviceError, KasaException from kasa.smartcam import SmartCamDevice -from ..conftest import device_smartcam, hub_smartcam +from ..conftest import device_smartcam, get_device_for_fixture_protocol, hub_smartcam @device_smartcam @@ -52,9 +53,40 @@ async def test_alias(dev: Device) -> None: assert dev.alias == original +@pytest.mark.xdist_group(name="caplog") +async def test_h500_hub_child_enumeration_warnings( + caplog: pytest.LogCaptureFixture, +) -> None: + """H500 reports child_num but returns an empty child list over LAN.""" + caplog.set_level(logging.WARNING) + dev = await get_device_for_fixture_protocol("H500(US)_1.0_1.3.18.json", "SMARTCAM") + assert dev.device_type is DeviceType.Hub + + await dev._update_children_info() + + assert "children are not enumerable over LAN" in caplog.text + + +@pytest.mark.xdist_group(name="caplog") +async def test_h500_missing_child_device_list_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Warn when getChildDeviceList omits child_device_list entirely.""" + caplog.set_level(logging.WARNING) + dev = await get_device_for_fixture_protocol("H500(US)_1.0_1.3.18.json", "SMARTCAM") + dev._last_update["getChildDeviceList"] = {"start_index": 0, "sum": 0} + + await dev._update_children_info() + + assert "Missing child_device_list for hub" in caplog.text + + @hub_smartcam async def test_hub(dev: Device) -> None: - assert dev.children + # H500 (and similar) advertise child_num but can return getChildDeviceList + # with only start_index/sum and no child_device_list over LAN. + if not dev.children: + pytest.skip("Hub fixture has no child devices") for child in dev.children: assert child.modules assert child.device_info