Add smartcam child device support for smartcam hubs (#1413)

This commit is contained in:
Steven B. 2025-01-14 08:38:04 +00:00 committed by GitHub
parent a211cc0af5
commit 589d15091a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 431 additions and 92 deletions

View File

@ -54,7 +54,8 @@ from kasa.protocols.smartcamprotocol import (
from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS
from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
from kasa.smart import SmartChildDevice, SmartDevice from kasa.smart import SmartChildDevice, SmartDevice
from kasa.smartcam import SmartCamDevice from kasa.smartcam import SmartCamChild, SmartCamDevice
from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT
Call = namedtuple("Call", "module method") Call = namedtuple("Call", "module method")
FixtureResult = namedtuple("FixtureResult", "filename, folder, data, protocol_suffix") FixtureResult = namedtuple("FixtureResult", "filename, folder, data, protocol_suffix")
@ -62,11 +63,13 @@ FixtureResult = namedtuple("FixtureResult", "filename, folder, data, protocol_su
SMART_FOLDER = "tests/fixtures/smart/" SMART_FOLDER = "tests/fixtures/smart/"
SMARTCAM_FOLDER = "tests/fixtures/smartcam/" SMARTCAM_FOLDER = "tests/fixtures/smartcam/"
SMART_CHILD_FOLDER = "tests/fixtures/smart/child/" SMART_CHILD_FOLDER = "tests/fixtures/smart/child/"
SMARTCAM_CHILD_FOLDER = "tests/fixtures/smartcam/child/"
IOT_FOLDER = "tests/fixtures/iot/" IOT_FOLDER = "tests/fixtures/iot/"
SMART_PROTOCOL_SUFFIX = "SMART" SMART_PROTOCOL_SUFFIX = "SMART"
SMARTCAM_SUFFIX = "SMARTCAM" SMARTCAM_SUFFIX = "SMARTCAM"
SMART_CHILD_SUFFIX = "SMART.CHILD" SMART_CHILD_SUFFIX = "SMART.CHILD"
SMARTCAM_CHILD_SUFFIX = "SMARTCAM.CHILD"
IOT_SUFFIX = "IOT" IOT_SUFFIX = "IOT"
NO_GIT_FIXTURE_FOLDER = "kasa-fixtures" NO_GIT_FIXTURE_FOLDER = "kasa-fixtures"
@ -844,9 +847,8 @@ async def get_smart_test_calls(protocol: SmartProtocol):
return test_calls, successes return test_calls, successes
def get_smart_child_fixture(response): def get_smart_child_fixture(response, model_info, folder, suffix):
"""Get a seperate fixture for the child device.""" """Get a seperate fixture for the child device."""
model_info = SmartDevice._get_device_info(response, None)
hw_version = model_info.hardware_version hw_version = model_info.hardware_version
fw_version = model_info.firmware_version fw_version = model_info.firmware_version
model = model_info.long_name model = model_info.long_name
@ -855,12 +857,68 @@ def get_smart_child_fixture(response):
save_filename = f"{model}_{hw_version}_{fw_version}" save_filename = f"{model}_{hw_version}_{fw_version}"
return FixtureResult( return FixtureResult(
filename=save_filename, filename=save_filename,
folder=SMART_CHILD_FOLDER, folder=folder,
data=response, data=response,
protocol_suffix=SMART_CHILD_SUFFIX, protocol_suffix=suffix,
) )
def scrub_child_device_ids(
main_response: dict, child_responses: dict
) -> dict[str, str]:
"""Scrub all the child device ids in the responses."""
# Make the scrubbed id map
scrubbed_child_id_map = {
device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}"
for index, device_id in enumerate(child_responses.keys())
if device_id != ""
}
for child_id, response in child_responses.items():
scrubbed_child_id = scrubbed_child_id_map[child_id]
# scrub the device id in the child's get info response
# The checks for the device_id will ensure we can get a fixture
# even if the data is unexpectedly not available although it should
# always be there
if "get_device_info" in response and "device_id" in response["get_device_info"]:
response["get_device_info"]["device_id"] = scrubbed_child_id
elif (
basic_info := response.get("getDeviceInfo", {})
.get("device_info", {})
.get("basic_info")
) and "dev_id" in basic_info:
basic_info["dev_id"] = scrubbed_child_id
else:
_LOGGER.error(
"Cannot find device id in child get device info: %s", child_id
)
# Scrub the device ids in the parent for smart protocol
if gc := main_response.get("get_child_device_component_list"):
for child in gc["child_component_list"]:
device_id = child["device_id"]
child["device_id"] = scrubbed_child_id_map[device_id]
for child in main_response["get_child_device_list"]["child_device_list"]:
device_id = child["device_id"]
child["device_id"] = scrubbed_child_id_map[device_id]
# 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"]:
device_id = child["device_id"]
child["device_id"] = scrubbed_child_id_map[device_id]
for child in main_response["getChildDeviceList"]["child_device_list"]:
if device_id := child.get("device_id"):
child["device_id"] = scrubbed_child_id_map[device_id]
continue
elif dev_id := child.get("dev_id"):
child["dev_id"] = scrubbed_child_id_map[dev_id]
continue
_LOGGER.error("Could not find a device id for the child device: %s", child)
return scrubbed_child_id_map
async def get_smart_fixtures( async def get_smart_fixtures(
protocol: SmartProtocol, protocol: SmartProtocol,
*, *,
@ -917,21 +975,19 @@ async def get_smart_fixtures(
finally: finally:
await protocol.close() await protocol.close()
# Put all the successes into a dict[child_device_id or "", successes[]]
device_requests: dict[str, list[SmartCall]] = {} device_requests: dict[str, list[SmartCall]] = {}
for success in successes: for success in successes:
device_request = device_requests.setdefault(success.child_device_id, []) device_request = device_requests.setdefault(success.child_device_id, [])
device_request.append(success) device_request.append(success)
scrubbed_device_ids = {
device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index}"
for index, device_id in enumerate(device_requests.keys())
if device_id != ""
}
final = await _make_final_calls( final = await _make_final_calls(
protocol, device_requests[""], "All successes", batch_size, child_device_id="" protocol, device_requests[""], "All successes", batch_size, child_device_id=""
) )
fixture_results = [] fixture_results = []
# Make the final child calls
child_responses = {}
for child_device_id, requests in device_requests.items(): for child_device_id, requests in device_requests.items():
if child_device_id == "": if child_device_id == "":
continue continue
@ -942,55 +998,82 @@ async def get_smart_fixtures(
batch_size, batch_size,
child_device_id=child_device_id, child_device_id=child_device_id,
) )
child_responses[child_device_id] = response
scrubbed = scrubbed_device_ids[child_device_id] # scrub the child ids
if "get_device_info" in response and "device_id" in response["get_device_info"]: scrubbed_child_id_map = scrub_child_device_ids(final, child_responses)
response["get_device_info"]["device_id"] = scrubbed
# If the child is a different model to the parent create a seperate fixture # Redact data from the main device response. _wrap_redactors ensure we do
if "get_device_info" in final: # not redact the scrubbed child device ids and replaces REDACTED_partial_id
parent_model = final["get_device_info"]["model"] # with zeros
elif "getDeviceInfo" in final: final = redact_data(final, _wrap_redactors(SMART_REDACTORS))
parent_model = final["getDeviceInfo"]["device_info"]["basic_info"][
"device_model" # 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.
if smart_cam_child_list := final.get("getChildDeviceList"):
child_infos_on_parent = {
info["device_id"]: info
for info in smart_cam_child_list["child_device_list"]
}
for child_id, response in child_responses.items():
scrubbed_child_id = scrubbed_child_id_map[child_id]
# Get the parent model for checking whether to create a seperate child fixture
if model := final.get("get_device_info", {}).get("model"):
parent_model = model
elif (
device_model := final.get("getDeviceInfo", {})
.get("device_info", {})
.get("basic_info", {})
.get("device_model")
):
parent_model = device_model
else: else:
raise KasaException("Cannot determine parent device model.") parent_model = None
_LOGGER.error("Cannot determine parent device model.")
# different model smart child device
if ( if (
"component_nego" in response (child_model := response.get("get_device_info", {}).get("model"))
and "get_device_info" in response and parent_model
and (child_model := response["get_device_info"].get("model"))
and child_model != parent_model and child_model != parent_model
): ):
response = redact_data(response, _wrap_redactors(SMART_REDACTORS)) response = redact_data(response, _wrap_redactors(SMART_REDACTORS))
fixture_results.append(get_smart_child_fixture(response)) model_info = SmartDevice._get_device_info(response, None)
fixture_results.append(
get_smart_child_fixture(
response, model_info, SMART_CHILD_FOLDER, SMART_CHILD_SUFFIX
)
)
# different model smartcam child device
elif (
(
child_model := response.get("getDeviceInfo", {})
.get("device_info", {})
.get("basic_info", {})
.get("device_model")
)
and parent_model
and child_model != parent_model
):
response = redact_data(response, _wrap_redactors(SMART_REDACTORS))
# There is more info in the childDeviceList on the parent
# particularly the region is needed here.
child_info_from_parent = child_infos_on_parent[scrubbed_child_id]
response[CHILD_INFO_FROM_PARENT] = child_info_from_parent
model_info = SmartCamChild._get_device_info(response, None)
fixture_results.append(
get_smart_child_fixture(
response, model_info, SMARTCAM_CHILD_FOLDER, SMARTCAM_CHILD_SUFFIX
)
)
# same model child device
else: else:
cd = final.setdefault("child_devices", {}) cd = final.setdefault("child_devices", {})
cd[scrubbed] = response cd[scrubbed_child_id] = response
# Scrub the device ids in the parent for smart protocol
if gc := final.get("get_child_device_component_list"):
for child in gc["child_component_list"]:
device_id = child["device_id"]
child["device_id"] = scrubbed_device_ids[device_id]
for child in final["get_child_device_list"]["child_device_list"]:
device_id = child["device_id"]
child["device_id"] = scrubbed_device_ids[device_id]
# Scrub the device ids in the parent for the smart camera protocol
if gc := final.get("getChildDeviceComponentList"):
for child in gc["child_component_list"]:
device_id = child["device_id"]
child["device_id"] = scrubbed_device_ids[device_id]
for child in final["getChildDeviceList"]["child_device_list"]:
if device_id := child.get("device_id"):
child["device_id"] = scrubbed_device_ids[device_id]
continue
elif dev_id := child.get("dev_id"):
child["dev_id"] = scrubbed_device_ids[dev_id]
continue
_LOGGER.error("Could not find a device for the child device: %s", child)
final = redact_data(final, _wrap_redactors(SMART_REDACTORS))
discovery_result = None discovery_result = None
if discovery_info: if discovery_info:
final["discovery_result"] = redact_data( final["discovery_result"] = redact_data(

View File

@ -13,7 +13,7 @@ from typing import Any, NamedTuple
from kasa.device_type import DeviceType from kasa.device_type import DeviceType
from kasa.iot import IotDevice from kasa.iot import IotDevice
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from kasa.smartcam import SmartCamDevice from kasa.smartcam import SmartCamChild, SmartCamDevice
class SupportedVersion(NamedTuple): class SupportedVersion(NamedTuple):
@ -49,6 +49,7 @@ IOT_FOLDER = "tests/fixtures/iot/"
SMART_FOLDER = "tests/fixtures/smart/" SMART_FOLDER = "tests/fixtures/smart/"
SMART_CHILD_FOLDER = "tests/fixtures/smart/child" SMART_CHILD_FOLDER = "tests/fixtures/smart/child"
SMARTCAM_FOLDER = "tests/fixtures/smartcam/" SMARTCAM_FOLDER = "tests/fixtures/smartcam/"
SMARTCAM_CHILD_FOLDER = "tests/fixtures/smartcam/child"
def generate_supported(args): def generate_supported(args):
@ -66,6 +67,7 @@ def generate_supported(args):
_get_supported_devices(supported, SMART_FOLDER, SmartDevice) _get_supported_devices(supported, SMART_FOLDER, SmartDevice)
_get_supported_devices(supported, SMART_CHILD_FOLDER, SmartDevice) _get_supported_devices(supported, SMART_CHILD_FOLDER, SmartDevice)
_get_supported_devices(supported, SMARTCAM_FOLDER, SmartCamDevice) _get_supported_devices(supported, SMARTCAM_FOLDER, SmartCamDevice)
_get_supported_devices(supported, SMARTCAM_CHILD_FOLDER, SmartCamChild)
readme_updated = _update_supported_file( readme_updated = _update_supported_file(
README_FILENAME, _supported_summary(supported), print_diffs README_FILENAME, _supported_summary(supported), print_diffs

View File

@ -1,5 +1,6 @@
"""Package for supporting tapo-branded cameras.""" """Package for supporting tapo-branded cameras."""
from .smartcamchild import SmartCamChild
from .smartcamdevice import SmartCamDevice from .smartcamdevice import SmartCamDevice
__all__ = ["SmartCamDevice"] __all__ = ["SmartCamDevice", "SmartCamChild"]

View File

@ -0,0 +1,115 @@
"""Child device implementation."""
from __future__ import annotations
import logging
from typing import Any
from ..device import DeviceInfo
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper
from ..protocols.smartprotocol import SmartProtocol
from ..smart.smartchilddevice import SmartChildDevice
from ..smart.smartdevice import ComponentsRaw, SmartDevice
from .smartcamdevice import SmartCamDevice
_LOGGER = logging.getLogger(__name__)
# SmartCamChild devices have a different info format from getChildDeviceInfo
# than when querying getDeviceInfo directly on the child.
# As _get_device_info is also called by dump_devtools and generate_supported
# this key will be expected by _get_device_info
CHILD_INFO_FROM_PARENT = "child_info_from_parent"
class SmartCamChild(SmartChildDevice, SmartCamDevice):
"""Presentation of a child device.
This wraps the protocol communications and sets internal data for the child.
"""
CHILD_DEVICE_TYPE_MAP = {
"camera": DeviceType.Camera,
}
def __init__(
self,
parent: SmartDevice,
info: dict,
component_info_raw: ComponentsRaw,
*,
config: DeviceConfig | None = None,
protocol: SmartProtocol | None = None,
) -> None:
_protocol = protocol or _ChildCameraProtocolWrapper(
info["device_id"], parent.protocol
)
super().__init__(parent, info, component_info_raw, protocol=_protocol)
self._child_info_from_parent: dict = {}
@property
def device_info(self) -> DeviceInfo:
"""Return device info.
Child device does not have it info and components in _last_update so
this overrides the base implementation to call _get_device_info with
info and components combined as they would be in _last_update.
"""
return self._get_device_info(
{
CHILD_INFO_FROM_PARENT: self._child_info_from_parent,
},
None,
)
def _map_child_info_from_parent(self, device_info: dict) -> dict:
return {
"model": device_info["device_model"],
"device_type": device_info["device_type"],
"alias": device_info["alias"],
"fw_ver": device_info["sw_ver"],
"hw_ver": device_info["hw_ver"],
"mac": device_info["mac"],
"hwId": device_info.get("hw_id"),
"oem_id": device_info["oem_id"],
"device_id": device_info["device_id"],
}
def _update_internal_state(self, info: dict[str, Any]) -> None:
"""Update the internal info state.
This is used by the parent to push updates to its children.
"""
# smartcam children have info with different keys to their own
# getDeviceInfo queries
self._child_info_from_parent = info
# self._info will have the values normalized across smart and smartcam
# devices
self._info = self._map_child_info_from_parent(info)
@staticmethod
def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> DeviceInfo:
"""Get model information for a device."""
if not (cifp := info.get(CHILD_INFO_FROM_PARENT)):
return SmartCamDevice._get_device_info(info, discovery_info)
model = cifp["device_model"]
device_type = SmartCamDevice._get_device_type_from_sysinfo(cifp)
fw_version_full = cifp["sw_ver"]
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
return DeviceInfo(
short_name=model,
long_name=model,
brand="tapo",
device_family=cifp["device_type"],
device_type=device_type,
hardware_version=cifp["hw_ver"],
firmware_version=firmware_version,
firmware_build=firmware_build,
requires_auth=True,
region=cifp.get("region"),
)

View File

@ -63,6 +63,13 @@ class SmartCamDevice(SmartDevice):
info = self._try_get_response(info_resp, "getDeviceInfo") info = self._try_get_response(info_resp, "getDeviceInfo")
self._info = self._map_info(info["device_info"]) self._info = self._map_info(info["device_info"])
def _update_internal_state(self, info: dict[str, Any]) -> None:
"""Update the internal info state.
This is used by the parent to push updates to its children.
"""
self._info = self._map_info(info)
def _update_children_info(self) -> None: def _update_children_info(self) -> None:
"""Update the internal child device info from the parent info.""" """Update the internal child device info from the parent info."""
if child_info := self._try_get_response( if child_info := self._try_get_response(
@ -99,6 +106,27 @@ class SmartCamDevice(SmartDevice):
last_update=initial_response, last_update=initial_response,
) )
async def _initialize_smartcam_child(
self, info: dict, child_components_raw: ComponentsRaw
) -> SmartDevice:
"""Initialize a smart child device attached to a smartcam device."""
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"]
}
from .smartcamchild import SmartCamChild
return await SmartCamChild.create(
parent=self,
child_info=info,
child_components_raw=app_component_list,
protocol=child_protocol,
last_update=last_update,
)
async def _initialize_children(self) -> None: async def _initialize_children(self) -> None:
"""Initialize children for hubs.""" """Initialize children for hubs."""
child_info_query = { child_info_query = {
@ -113,17 +141,27 @@ class SmartCamDevice(SmartDevice):
for child in resp["getChildDeviceComponentList"]["child_component_list"] for child in resp["getChildDeviceComponentList"]["child_component_list"]
} }
children = {} children = {}
from .smartcamchild import SmartCamChild
for info in resp["getChildDeviceList"]["child_device_list"]: for info in resp["getChildDeviceList"]["child_device_list"]:
if ( if (
(category := info.get("category")) (category := info.get("category"))
and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP
and (child_id := info.get("device_id")) and (child_id := info.get("device_id"))
and (child_components := smart_children_components.get(child_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( children[child_id] = await self._initialize_smart_child(
info, child_components info, child_components
) )
else: continue
# Smartcam
if category in SmartCamChild.CHILD_DEVICE_TYPE_MAP:
children[child_id] = await self._initialize_smartcam_child(
info, child_components
)
continue
_LOGGER.debug("Child device type not supported: %s", info) _LOGGER.debug("Child device type not supported: %s", info)
self._children = children self._children = children

View File

@ -335,7 +335,7 @@ device_smartcam = parametrize("devices smartcam", protocol_filter={"SMARTCAM"})
camera_smartcam = parametrize( camera_smartcam = parametrize(
"camera smartcam", "camera smartcam",
device_type_filter=[DeviceType.Camera], device_type_filter=[DeviceType.Camera],
protocol_filter={"SMARTCAM"}, protocol_filter={"SMARTCAM", "SMARTCAM.CHILD"},
) )
hub_smartcam = parametrize( hub_smartcam = parametrize(
"hub smartcam", "hub smartcam",
@ -377,7 +377,7 @@ check_categories()
def device_for_fixture_name(model, protocol): def device_for_fixture_name(model, protocol):
if protocol in {"SMART", "SMART.CHILD"}: if protocol in {"SMART", "SMART.CHILD"}:
return SmartDevice return SmartDevice
elif protocol == "SMARTCAM": elif protocol in {"SMARTCAM", "SMARTCAM.CHILD"}:
return SmartCamDevice return SmartCamDevice
else: else:
for d in STRIPS_IOT: for d in STRIPS_IOT:
@ -434,7 +434,7 @@ async def get_device_for_fixture(
d.protocol = FakeSmartProtocol( d.protocol = FakeSmartProtocol(
fixture_data.data, fixture_data.name, verbatim=verbatim fixture_data.data, fixture_data.name, verbatim=verbatim
) )
elif fixture_data.protocol == "SMARTCAM": elif fixture_data.protocol in {"SMARTCAM", "SMARTCAM.CHILD"}:
d.protocol = FakeSmartCamProtocol( d.protocol = FakeSmartCamProtocol(
fixture_data.data, fixture_data.name, verbatim=verbatim fixture_data.data, fixture_data.name, verbatim=verbatim
) )

View File

@ -7,6 +7,8 @@ import pytest
from kasa import Credentials, DeviceConfig, SmartProtocol from kasa import Credentials, DeviceConfig, SmartProtocol
from kasa.exceptions import SmartErrorCode from kasa.exceptions import SmartErrorCode
from kasa.smart import SmartChildDevice from kasa.smart import SmartChildDevice
from kasa.smartcam import SmartCamChild
from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT
from kasa.transports.basetransport import BaseTransport from kasa.transports.basetransport import BaseTransport
@ -227,16 +229,20 @@ class FakeSmartTransport(BaseTransport):
# imported here to avoid circular import # imported here to avoid circular import
from .conftest import filter_fixtures from .conftest import filter_fixtures
def try_get_child_fixture_info(child_dev_info): def try_get_child_fixture_info(child_dev_info, protocol):
hw_version = child_dev_info["hw_ver"] hw_version = child_dev_info["hw_ver"]
sw_version = child_dev_info["fw_ver"] sw_version = child_dev_info.get("sw_ver", child_dev_info.get("fw_ver"))
sw_version = sw_version.split(" ")[0] sw_version = sw_version.split(" ")[0]
model = child_dev_info["model"] model = child_dev_info.get("device_model", child_dev_info.get("model"))
region = child_dev_info.get("specs", "XX") assert sw_version
child_fixture_name = f"{model}({region})_{hw_version}_{sw_version}" assert model
region = child_dev_info.get("specs", child_dev_info.get("region"))
region = f"({region})" if region else ""
child_fixture_name = f"{model}{region}_{hw_version}_{sw_version}"
child_fixtures = filter_fixtures( child_fixtures = filter_fixtures(
"Child fixture", "Child fixture",
protocol_filter={"SMART.CHILD"}, protocol_filter={protocol},
model_filter={child_fixture_name}, model_filter={child_fixture_name},
) )
if child_fixtures: if child_fixtures:
@ -249,7 +255,9 @@ class FakeSmartTransport(BaseTransport):
and (category := child_info.get("category")) and (category := child_info.get("category"))
and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP
): ):
if fixture_info_tuple := try_get_child_fixture_info(child_info): if fixture_info_tuple := try_get_child_fixture_info(
child_info, "SMART.CHILD"
):
child_fixture = copy.deepcopy(fixture_info_tuple.data) child_fixture = copy.deepcopy(fixture_info_tuple.data)
child_fixture["get_device_info"]["device_id"] = device_id child_fixture["get_device_info"]["device_id"] = device_id
found_child_fixture_infos.append(child_fixture["get_device_info"]) found_child_fixture_infos.append(child_fixture["get_device_info"])
@ -270,9 +278,32 @@ class FakeSmartTransport(BaseTransport):
pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined]
parent_fixture_name, set() parent_fixture_name, set()
).add("child_devices") ).add("child_devices")
elif (
(device_id := child_info.get("device_id"))
and (category := child_info.get("category"))
and category in SmartCamChild.CHILD_DEVICE_TYPE_MAP
and (
fixture_info_tuple := try_get_child_fixture_info(
child_info, "SMARTCAM.CHILD"
)
)
):
from .fakeprotocol_smartcam import FakeSmartCamProtocol
child_fixture = copy.deepcopy(fixture_info_tuple.data)
child_fixture["getDeviceInfo"]["device_info"]["basic_info"][
"dev_id"
] = device_id
child_fixture[CHILD_INFO_FROM_PARENT]["device_id"] = device_id
# We copy the child device info to the parent getChildDeviceInfo
# list for smartcam children in order for updates to work.
found_child_fixture_infos.append(child_fixture[CHILD_INFO_FROM_PARENT])
child_protocols[device_id] = FakeSmartCamProtocol(
child_fixture, fixture_info_tuple.name, is_child=True
)
else: else:
warn( warn(
f"Child is a cameraprotocol which needs to be implemented {child_info}", f"Child is a protocol which needs to be implemented {child_info}",
stacklevel=2, stacklevel=2,
) )
# Replace parent child infos with the infos from the child fixtures so # Replace parent child infos with the infos from the child fixtures so

View File

@ -6,6 +6,7 @@ from typing import Any
from kasa import Credentials, DeviceConfig, SmartProtocol from kasa import Credentials, DeviceConfig, SmartProtocol
from kasa.protocols.smartcamprotocol import SmartCamProtocol from kasa.protocols.smartcamprotocol import SmartCamProtocol
from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT
from kasa.transports.basetransport import BaseTransport from kasa.transports.basetransport import BaseTransport
from .fakeprotocol_smart import FakeSmartTransport from .fakeprotocol_smart import FakeSmartTransport
@ -125,10 +126,26 @@ class FakeSmartCamTransport(BaseTransport):
@staticmethod @staticmethod
def _get_param_set_value(info: dict, set_keys: list[str], value): def _get_param_set_value(info: dict, set_keys: list[str], value):
cifp = info.get(CHILD_INFO_FROM_PARENT)
for key in set_keys[:-1]: for key in set_keys[:-1]:
info = info[key] info = info[key]
info[set_keys[-1]] = value info[set_keys[-1]] = value
if (
cifp
and set_keys[0] == "getDeviceInfo"
and (
child_info_parent_key
:= FakeSmartCamTransport.CHILD_INFO_SETTER_MAP.get(set_keys[-1])
)
):
cifp[child_info_parent_key] = value
CHILD_INFO_SETTER_MAP = {
"device_alias": "alias",
}
FIXTURE_MISSING_MAP = { FIXTURE_MISSING_MAP = {
"getMatterSetupInfo": ( "getMatterSetupInfo": (
"matter", "matter",

View File

@ -60,11 +60,19 @@ SUPPORTED_SMARTCAM_DEVICES = [
) )
] ]
SUPPORTED_SMARTCAM_CHILD_DEVICES = [
(device, "SMARTCAM.CHILD")
for device in glob.glob(
os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smartcam/child/*.json"
)
]
SUPPORTED_DEVICES = ( SUPPORTED_DEVICES = (
SUPPORTED_IOT_DEVICES SUPPORTED_IOT_DEVICES
+ SUPPORTED_SMART_DEVICES + SUPPORTED_SMART_DEVICES
+ SUPPORTED_SMART_CHILD_DEVICES + SUPPORTED_SMART_CHILD_DEVICES
+ SUPPORTED_SMARTCAM_DEVICES + SUPPORTED_SMARTCAM_DEVICES
+ SUPPORTED_SMARTCAM_CHILD_DEVICES
) )
@ -82,14 +90,8 @@ def get_fixture_infos() -> list[FixtureInfo]:
fixture_data = [] fixture_data = []
for file, protocol in SUPPORTED_DEVICES: for file, protocol in SUPPORTED_DEVICES:
p = Path(file) p = Path(file)
folder = Path(__file__).parent / "fixtures"
if protocol == "SMART":
folder = folder / "smart"
if protocol == "SMART.CHILD":
folder = folder / "smart/child"
p = folder / file
with open(p) as f: with open(file) as f:
data = json.load(f) data = json.load(f)
fixture_name = p.name fixture_name = p.name
@ -188,7 +190,7 @@ def filter_fixtures(
IotDevice._get_device_type_from_sys_info(fixture_data.data) IotDevice._get_device_type_from_sys_info(fixture_data.data)
in device_type in device_type
) )
elif fixture_data.protocol == "SMARTCAM": elif fixture_data.protocol in {"SMARTCAM", "SMARTCAM.CHILD"}:
info = fixture_data.data["getDeviceInfo"]["device_info"]["basic_info"] info = fixture_data.data["getDeviceInfo"]["device_info"]["basic_info"]
return SmartCamDevice._get_device_type_from_sysinfo(info) in device_type return SmartCamDevice._get_device_type_from_sysinfo(info) in device_type
return False return False

View File

@ -10,7 +10,13 @@ import pytest
from kasa import Credentials, Device, DeviceType, Module, StreamResolution from kasa import Credentials, Device, DeviceType, Module, StreamResolution
from ...conftest import camera_smartcam, device_smartcam from ...conftest import device_smartcam, parametrize
not_child_camera_smartcam = parametrize(
"not child camera smartcam",
device_type_filter=[DeviceType.Camera],
protocol_filter={"SMARTCAM"},
)
@device_smartcam @device_smartcam
@ -24,7 +30,7 @@ async def test_state(dev: Device):
assert dev.is_on is not state assert dev.is_on is not state
@camera_smartcam @not_child_camera_smartcam
async def test_stream_rtsp_url(dev: Device): async def test_stream_rtsp_url(dev: Device):
camera_module = dev.modules.get(Module.Camera) camera_module = dev.modules.get(Module.Camera)
assert camera_module assert camera_module
@ -84,7 +90,7 @@ async def test_stream_rtsp_url(dev: Device):
assert url is None assert url is None
@camera_smartcam @not_child_camera_smartcam
async def test_onvif_url(dev: Device): async def test_onvif_url(dev: Device):
"""Test the onvif url.""" """Test the onvif url."""
camera_module = dev.modules.get(Module.Camera) camera_module = dev.modules.get(Module.Camera)

View File

@ -52,12 +52,12 @@ async def test_alias(dev):
async def test_hub(dev): async def test_hub(dev):
assert dev.children assert dev.children
for child in dev.children: for child in dev.children:
assert "Cloud" in child.modules assert child.modules
assert child.modules["Cloud"].data assert child.device_info
assert child.alias assert child.alias
await child.update() await child.update()
assert "Time" not in child.modules assert child.device_id
assert child.time
@device_smartcam @device_smartcam

View File

@ -31,7 +31,7 @@ from kasa.iot.iottimezone import (
) )
from kasa.iot.modules import IotLightPreset from kasa.iot.modules import IotLightPreset
from kasa.smart import SmartChildDevice, SmartDevice from kasa.smart import SmartChildDevice, SmartDevice
from kasa.smartcam import SmartCamDevice from kasa.smartcam import SmartCamChild, SmartCamDevice
def _get_subclasses(of_class): def _get_subclasses(of_class):
@ -84,13 +84,24 @@ async def test_device_class_ctors(device_class_name_obj):
credentials = Credentials("foo", "bar") credentials = Credentials("foo", "bar")
config = DeviceConfig(host, port_override=port, credentials=credentials) config = DeviceConfig(host, port_override=port, credentials=credentials)
klass = device_class_name_obj[1] klass = device_class_name_obj[1]
if issubclass(klass, SmartChildDevice): if issubclass(klass, SmartChildDevice | SmartCamChild):
parent = SmartDevice(host, config=config) parent = SmartDevice(host, config=config)
smartcam_required = {
"device_model": "foo",
"device_type": "SMART.TAPODOORBELL",
"alias": "Foo",
"sw_ver": "1.1",
"hw_ver": "1.0",
"mac": "1.2.3.4",
"hwId": "hw_id",
"oem_id": "oem_id",
}
dev = klass( dev = klass(
parent, parent,
{"dummy": "info", "device_id": "dummy"}, {"dummy": "info", "device_id": "dummy", **smartcam_required},
{ {
"component_list": [{"id": "device", "ver_code": 1}], "component_list": [{"id": "device", "ver_code": 1}],
"app_component_list": [{"name": "device", "version": 1}],
}, },
) )
else: else:
@ -108,13 +119,24 @@ async def test_device_class_repr(device_class_name_obj):
credentials = Credentials("foo", "bar") credentials = Credentials("foo", "bar")
config = DeviceConfig(host, port_override=port, credentials=credentials) config = DeviceConfig(host, port_override=port, credentials=credentials)
klass = device_class_name_obj[1] klass = device_class_name_obj[1]
if issubclass(klass, SmartChildDevice): if issubclass(klass, SmartChildDevice | SmartCamChild):
parent = SmartDevice(host, config=config) parent = SmartDevice(host, config=config)
smartcam_required = {
"device_model": "foo",
"device_type": "SMART.TAPODOORBELL",
"alias": "Foo",
"sw_ver": "1.1",
"hw_ver": "1.0",
"mac": "1.2.3.4",
"hwId": "hw_id",
"oem_id": "oem_id",
}
dev = klass( dev = klass(
parent, parent,
{"dummy": "info", "device_id": "dummy"}, {"dummy": "info", "device_id": "dummy", **smartcam_required},
{ {
"component_list": [{"id": "device", "ver_code": 1}], "component_list": [{"id": "device", "ver_code": 1}],
"app_component_list": [{"name": "device", "version": 1}],
}, },
) )
else: else:
@ -132,11 +154,14 @@ async def test_device_class_repr(device_class_name_obj):
SmartChildDevice: DeviceType.Unknown, SmartChildDevice: DeviceType.Unknown,
SmartDevice: DeviceType.Unknown, SmartDevice: DeviceType.Unknown,
SmartCamDevice: DeviceType.Camera, SmartCamDevice: DeviceType.Camera,
SmartCamChild: DeviceType.Camera,
} }
type_ = CLASS_TO_DEFAULT_TYPE[klass] type_ = CLASS_TO_DEFAULT_TYPE[klass]
child_repr = "<DeviceType.Unknown(child) of <DeviceType.Unknown at 127.0.0.2 - update() needed>>" child_repr = "<DeviceType.Unknown(child) of <DeviceType.Unknown at 127.0.0.2 - update() needed>>"
not_child_repr = f"<{type_} at 127.0.0.2 - update() needed>" not_child_repr = f"<{type_} at 127.0.0.2 - update() needed>"
expected_repr = child_repr if klass is SmartChildDevice else not_child_repr expected_repr = (
child_repr if klass in {SmartChildDevice, SmartCamChild} else not_child_repr
)
assert repr(dev) == expected_repr assert repr(dev) == expected_repr

View File

@ -4,11 +4,18 @@ import copy
import pytest import pytest
from devtools.dump_devinfo import get_legacy_fixture, get_smart_fixtures from devtools.dump_devinfo import (
_wrap_redactors,
get_legacy_fixture,
get_smart_fixtures,
)
from kasa.iot import IotDevice from kasa.iot import IotDevice
from kasa.protocols import IotProtocol from kasa.protocols import IotProtocol
from kasa.protocols.protocol import redact_data
from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from kasa.smartcam import SmartCamDevice from kasa.smartcam import SmartCamDevice
from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT
from .conftest import ( from .conftest import (
FixtureInfo, FixtureInfo,
@ -113,6 +120,18 @@ async def test_smartcam_fixtures(fixture_info: FixtureInfo):
saved_fixture_data = { saved_fixture_data = {
key: val for key, val in saved_fixture_data.items() if val != -1001 key: val for key, val in saved_fixture_data.items() if val != -1001
} }
# Remove the child info from parent from the comparison because the
# child may have been created by a different parent fixture
saved_fixture_data.pop(CHILD_INFO_FROM_PARENT, None)
created_cifp = created_child_fixture.data.pop(CHILD_INFO_FROM_PARENT, None)
# Still check that the created child info from parent was redacted.
# only smartcam children generate child_info_from_parent
if created_cifp:
redacted_cifp = redact_data(created_cifp, _wrap_redactors(SMART_REDACTORS))
assert created_cifp == redacted_cifp
assert saved_fixture_data == created_child_fixture.data assert saved_fixture_data == created_child_fixture.data