Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<!--SUPPORTED_END-->
Expand Down
2 changes: 2 additions & 0 deletions SUPPORTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ All Tapo devices require authentication.<br>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

Expand Down
98 changes: 90 additions & 8 deletions devtools/dump_devinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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():
Expand Down
1 change: 1 addition & 0 deletions devtools/helpers/smartcamrequests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}}},
Expand Down
6 changes: 6 additions & 0 deletions docs/source/contribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <topics-hub-children>`.
```

```{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`.
Expand Down
1 change: 1 addition & 0 deletions docs/source/guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions docs/source/guides/hub.md
Original file line number Diff line number Diff line change
@@ -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 <hub-ip> --child <device-id> 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.
15 changes: 15 additions & 0 deletions docs/source/topics.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,21 @@ The classes providing this functionality are:
- {class}`KlapTransport <kasa.transports.KlapTransport>`
- {class}`KlapTransportV2 <kasa.transports.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 <topics-hub-children>` for the full
explanation, API table, fixture-dump behaviour, and integrator notes.

(topics-errors-and-exceptions)=
## Errors and Exceptions

Expand Down
25 changes: 16 additions & 9 deletions kasa/protocols/smartprotocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 10 additions & 2 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
):
Expand Down
Loading
Loading