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
13 changed files with 431 additions and 92 deletions

View File

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

View File

@@ -7,6 +7,8 @@ import pytest
from kasa import Credentials, DeviceConfig, SmartProtocol
from kasa.exceptions import SmartErrorCode
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
@@ -227,16 +229,20 @@ class FakeSmartTransport(BaseTransport):
# imported here to avoid circular import
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"]
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]
model = child_dev_info["model"]
region = child_dev_info.get("specs", "XX")
child_fixture_name = f"{model}({region})_{hw_version}_{sw_version}"
model = child_dev_info.get("device_model", child_dev_info.get("model"))
assert 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 fixture",
protocol_filter={"SMART.CHILD"},
protocol_filter={protocol},
model_filter={child_fixture_name},
)
if child_fixtures:
@@ -249,7 +255,9 @@ class FakeSmartTransport(BaseTransport):
and (category := child_info.get("category"))
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["get_device_info"]["device_id"] = device_id
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]
parent_fixture_name, set()
).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:
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,
)
# 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.protocols.smartcamprotocol import SmartCamProtocol
from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT
from kasa.transports.basetransport import BaseTransport
from .fakeprotocol_smart import FakeSmartTransport
@@ -125,10 +126,26 @@ class FakeSmartCamTransport(BaseTransport):
@staticmethod
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]:
info = info[key]
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 = {
"getMatterSetupInfo": (
"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_IOT_DEVICES
+ SUPPORTED_SMART_DEVICES
+ SUPPORTED_SMART_CHILD_DEVICES
+ SUPPORTED_SMARTCAM_DEVICES
+ SUPPORTED_SMARTCAM_CHILD_DEVICES
)
@@ -82,14 +90,8 @@ def get_fixture_infos() -> list[FixtureInfo]:
fixture_data = []
for file, protocol in SUPPORTED_DEVICES:
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)
fixture_name = p.name
@@ -188,7 +190,7 @@ def filter_fixtures(
IotDevice._get_device_type_from_sys_info(fixture_data.data)
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"]
return SmartCamDevice._get_device_type_from_sysinfo(info) in device_type
return False

View File

@@ -10,7 +10,13 @@ import pytest
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
@@ -24,7 +30,7 @@ async def test_state(dev: Device):
assert dev.is_on is not state
@camera_smartcam
@not_child_camera_smartcam
async def test_stream_rtsp_url(dev: Device):
camera_module = dev.modules.get(Module.Camera)
assert camera_module
@@ -84,7 +90,7 @@ async def test_stream_rtsp_url(dev: Device):
assert url is None
@camera_smartcam
@not_child_camera_smartcam
async def test_onvif_url(dev: Device):
"""Test the onvif url."""
camera_module = dev.modules.get(Module.Camera)

View File

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

View File

@@ -31,7 +31,7 @@ from kasa.iot.iottimezone import (
)
from kasa.iot.modules import IotLightPreset
from kasa.smart import SmartChildDevice, SmartDevice
from kasa.smartcam import SmartCamDevice
from kasa.smartcam import SmartCamChild, SmartCamDevice
def _get_subclasses(of_class):
@@ -84,13 +84,24 @@ async def test_device_class_ctors(device_class_name_obj):
credentials = Credentials("foo", "bar")
config = DeviceConfig(host, port_override=port, credentials=credentials)
klass = device_class_name_obj[1]
if issubclass(klass, SmartChildDevice):
if issubclass(klass, SmartChildDevice | SmartCamChild):
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(
parent,
{"dummy": "info", "device_id": "dummy"},
{"dummy": "info", "device_id": "dummy", **smartcam_required},
{
"component_list": [{"id": "device", "ver_code": 1}],
"app_component_list": [{"name": "device", "version": 1}],
},
)
else:
@@ -108,13 +119,24 @@ async def test_device_class_repr(device_class_name_obj):
credentials = Credentials("foo", "bar")
config = DeviceConfig(host, port_override=port, credentials=credentials)
klass = device_class_name_obj[1]
if issubclass(klass, SmartChildDevice):
if issubclass(klass, SmartChildDevice | SmartCamChild):
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(
parent,
{"dummy": "info", "device_id": "dummy"},
{"dummy": "info", "device_id": "dummy", **smartcam_required},
{
"component_list": [{"id": "device", "ver_code": 1}],
"app_component_list": [{"name": "device", "version": 1}],
},
)
else:
@@ -132,11 +154,14 @@ async def test_device_class_repr(device_class_name_obj):
SmartChildDevice: DeviceType.Unknown,
SmartDevice: DeviceType.Unknown,
SmartCamDevice: DeviceType.Camera,
SmartCamChild: DeviceType.Camera,
}
type_ = CLASS_TO_DEFAULT_TYPE[klass]
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>"
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

View File

@@ -4,11 +4,18 @@ import copy
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.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.smartcam import SmartCamDevice
from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT
from .conftest import (
FixtureInfo,
@@ -113,6 +120,18 @@ async def test_smartcam_fixtures(fixture_info: FixtureInfo):
saved_fixture_data = {
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