Skip to content
Merged
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
16 changes: 14 additions & 2 deletions kasa/experimental/smartcamera.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import Any

from ..device_type import DeviceType
from ..exceptions import SmartErrorCode
from ..smart import SmartDevice
Expand All @@ -10,6 +12,14 @@
class SmartCamera(SmartDevice):
"""Class for smart cameras."""

@staticmethod
def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType:
"""Find type to be displayed as a supported device category."""
device_type = sysinfo["device_type"]
if device_type.endswith("HUB"):
return DeviceType.Hub
return DeviceType.Camera

async def update(self, update_children: bool = False):
"""Update the device."""
initial_query = {
Expand All @@ -26,7 +36,7 @@ def _map_info(self, device_info: dict) -> dict:
basic_info = device_info["basic_info"]
return {
"model": basic_info["device_model"],
"type": basic_info["device_type"],
"device_type": basic_info["device_type"],
"alias": basic_info["device_alias"],
"fw_ver": basic_info["sw_version"],
"hw_ver": basic_info["hw_version"],
Expand Down Expand Up @@ -61,7 +71,9 @@ async def set_state(self, on: bool):
@property
def device_type(self) -> DeviceType:
"""Return the device type."""
return DeviceType.Camera
if self._device_type == DeviceType.Unknown:
self._device_type = self._get_device_type_from_sysinfo(self._info)
return self._device_type

@property
def alias(self) -> str | None:
Expand Down
23 changes: 21 additions & 2 deletions kasa/tests/device_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
DeviceType,
Discover,
)
from kasa.experimental.smartcamera import SmartCamera
from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch
from kasa.smart import SmartDevice

from .fakeprotocol_iot import FakeIotProtocol
from .fakeprotocol_smart import FakeSmartProtocol
from .fakeprotocol_smartcamera import FakeSmartCameraProtocol
from .fixtureinfo import (
FIXTURE_DATA,
ComponentFilter,
Expand Down Expand Up @@ -313,6 +315,17 @@ def parametrize(
device_iot = parametrize(
"devices iot", model_filter=ALL_DEVICES_IOT, protocol_filter={"IOT"}
)
device_smartcamera = parametrize("devices smartcamera", protocol_filter={"SMARTCAMERA"})
camera_smartcamera = parametrize(
"camera smartcamera",
device_type_filter=[DeviceType.Camera],
protocol_filter={"SMARTCAMERA"},
)
hub_smartcamera = parametrize(
"hub smartcamera",
device_type_filter=[DeviceType.Hub],
protocol_filter={"SMARTCAMERA"},
)


def check_categories():
Expand All @@ -329,6 +342,8 @@ def check_categories():
+ hubs_smart.args[1]
+ sensors_smart.args[1]
+ thermostats_smart.args[1]
+ camera_smartcamera.args[1]
+ hub_smartcamera.args[1]
)
diffs: set[FixtureInfo] = set(FIXTURE_DATA) - set(categorized_fixtures)
if diffs:
Expand All @@ -344,8 +359,10 @@ def check_categories():


def device_for_fixture_name(model, protocol):
if "SMART" in protocol:
if protocol in {"SMART", "SMART.CHILD"}:
return SmartDevice
elif protocol == "SMARTCAMERA":
return SmartCamera
else:
for d in STRIPS_IOT:
if d in model:
Expand Down Expand Up @@ -395,8 +412,10 @@ async def get_device_for_fixture(fixture_data: FixtureInfo) -> Device:
d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)(
host="127.0.0.123"
)
if "SMART" in fixture_data.protocol:
if fixture_data.protocol in {"SMART", "SMART.CHILD"}:
d.protocol = FakeSmartProtocol(fixture_data.data, fixture_data.name)
elif fixture_data.protocol == "SMARTCAMERA":
d.protocol = FakeSmartCameraProtocol(fixture_data.data, fixture_data.name)
else:
d.protocol = FakeIotProtocol(fixture_data.data)

Expand Down
217 changes: 217 additions & 0 deletions kasa/tests/fakeprotocol_smartcamera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
from __future__ import annotations

import copy
from json import loads as json_loads
from warnings import warn

from kasa import Credentials, DeviceConfig, SmartProtocol
from kasa.experimental.smartcameraprotocol import SmartCameraProtocol
from kasa.protocol import BaseTransport
from kasa.smart import SmartChildDevice

from .fakeprotocol_smart import FakeSmartProtocol


class FakeSmartCameraProtocol(SmartCameraProtocol):
def __init__(self, info, fixture_name):
super().__init__(
transport=FakeSmartCameraTransport(info, fixture_name),
)

async def query(self, request, retry_count: int = 3):
"""Implement query here so can still patch SmartProtocol.query."""
resp_dict = await self._query(request, retry_count)
return resp_dict


class FakeSmartCameraTransport(BaseTransport):
def __init__(
self,
info,
fixture_name,
*,
list_return_size=10,
):
super().__init__(
config=DeviceConfig(
"127.0.0.123",
credentials=Credentials(
username="dummy_user",
password="dummy_password", # noqa: S106
),
),
)
self.fixture_name = fixture_name
self.info = copy.deepcopy(info)
self.child_protocols = self._get_child_protocols()
self.list_return_size = list_return_size

@property
def default_port(self):
"""Default port for the transport."""
return 443

@property
def credentials_hash(self):
"""The hashed credentials used by the transport."""
return self._credentials.username + self._credentials.password + "camerahash"

async def send(self, request: str):
request_dict = json_loads(request)
method = request_dict["method"]

if method == "multipleRequest":
params = request_dict["params"]
responses = []
for request in params["requests"]:
response = await self._send_request(request) # type: ignore[arg-type]
# Devices do not continue after error
if response["error_code"] != 0:
break
response["method"] = request["method"] # type: ignore[index]
responses.append(response)
return {"result": {"responses": responses}, "error_code": 0}
else:
return await self._send_request(request_dict)

def _get_child_protocols(self):
child_infos = self.info.get("getChildDeviceList", {}).get(
"child_device_list", []
)
found_child_fixture_infos = []
child_protocols = {}
# imported here to avoid circular import
from .conftest import filter_fixtures

for child_info in child_infos:
if (
(device_id := child_info.get("device_id"))
and (category := child_info.get("category"))
and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP
):
hw_version = child_info["hw_ver"]
sw_version = child_info["fw_ver"]
sw_version = sw_version.split(" ")[0]
model = child_info["model"]
region = child_info["specs"]
child_fixture_name = f"{model}({region})_{hw_version}_{sw_version}"
child_fixtures = filter_fixtures(
"Child fixture",
protocol_filter={"SMART.CHILD"},
model_filter=child_fixture_name,
)
if child_fixtures:
fixture_info = next(iter(child_fixtures))
found_child_fixture_infos.append(child_info)
child_protocols[device_id] = FakeSmartProtocol(
fixture_info.data, fixture_info.name
)
else:
warn(
f"Could not find child fixture {child_fixture_name}",
stacklevel=1,
)
else:
warn(
f"Child is a cameraprotocol which needs to be implemented {child_info}",
stacklevel=1,
)
# Replace child infos with the infos that found child fixtures
if child_infos:
self.info["getChildDeviceList"]["child_device_list"] = (
found_child_fixture_infos
)
return child_protocols

async def _handle_control_child(self, params: dict):
"""Handle control_child command."""
device_id = params.get("device_id")
assert device_id in self.child_protocols, "Fixture does not have child info"

child_protocol: SmartProtocol = self.child_protocols[device_id]

request_data = params.get("request_data", {})

child_method = request_data.get("method")
child_params = request_data.get("params") # noqa: F841

resp = await child_protocol.query({child_method: child_params})
resp["error_code"] = 0
for val in resp.values():
return {
"result": {"response_data": {"result": val, "error_code": 0}},
"error_code": 0,
}

@staticmethod
def _get_param_set_value(info: dict, set_keys: list[str], value):
for key in set_keys[:-1]:
info = info[key]
info[set_keys[-1]] = value

SETTERS = {
("system", "sys", "dev_alias"): [
"getDeviceInfo",
"device_info",
"basic_info",
"device_alias",
],
("lens_mask", "lens_mask_info", "enabled"): [
"getLensMaskConfig",
"lens_mask",
"lens_mask_info",
"enabled",
],
}

async def _send_request(self, request_dict: dict):
method = request_dict["method"]

info = self.info
if method == "controlChild":
return await self._handle_control_child(
request_dict["params"]["childControl"]
)

if method == "set":
for key, val in request_dict.items():
if key != "method":
module = key
section = next(iter(val))
skey_val = val[section]
for skey, sval in skey_val.items():
section_key = skey
section_value = sval
break
if setter_keys := self.SETTERS.get((module, section, section_key)):
self._get_param_set_value(info, setter_keys, section_value)
return {"error_code": 0}
else:
return {"error_code": -1}
elif method[:3] == "get":
params = request_dict.get("params")
if method in info:
result = copy.deepcopy(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)])
)
start_index = (
start_index
if (params and (start_index := params.get("start_index")))
else 0
)

result[list_key] = result[list_key][
start_index : start_index + self.list_return_size
]
return {"result": result, "error_code": 0}
else:
return {"error_code": -1}
return {"error_code": -1}

async def close(self) -> None:
pass

async def reset(self) -> None:
pass
Loading