From 6c790b18463db325ff2cc7fd4ec44feb8bd29d96 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 14:02:54 +0100 Subject: [PATCH 1/9] Add code device, child and camera modules to smartcamera --- kasa/experimental/modules/__init__.py | 11 ++ kasa/experimental/modules/camera.py | 47 +++++++ kasa/experimental/modules/childdevice.py | 23 ++++ kasa/experimental/modules/device.py | 40 ++++++ kasa/experimental/smartcamera.py | 141 +++++++++++++++++---- kasa/experimental/smartcameramodule.py | 89 +++++++++++++ kasa/module.py | 4 + kasa/smart/smartchilddevice.py | 30 ++++- kasa/smart/smartdevice.py | 31 +++-- kasa/tests/smartcamera/test_smartcamera.py | 29 ++++- 10 files changed, 405 insertions(+), 40 deletions(-) create mode 100644 kasa/experimental/modules/__init__.py create mode 100644 kasa/experimental/modules/camera.py create mode 100644 kasa/experimental/modules/childdevice.py create mode 100644 kasa/experimental/modules/device.py create mode 100644 kasa/experimental/smartcameramodule.py diff --git a/kasa/experimental/modules/__init__.py b/kasa/experimental/modules/__init__.py new file mode 100644 index 000000000..9f1683845 --- /dev/null +++ b/kasa/experimental/modules/__init__.py @@ -0,0 +1,11 @@ +"""Modules for SMARTCAMERA devices.""" + +from .camera import Camera +from .childdevice import ChildDevice +from .device import DeviceModule + +__all__ = [ + "Camera", + "ChildDevice", + "DeviceModule", +] diff --git a/kasa/experimental/modules/camera.py b/kasa/experimental/modules/camera.py new file mode 100644 index 000000000..80b0c7175 --- /dev/null +++ b/kasa/experimental/modules/camera.py @@ -0,0 +1,47 @@ +"""Implementation of device module.""" + +from __future__ import annotations + +from ...device_type import DeviceType +from ...feature import Feature +from ..smartcameramodule import SmartCameraModule + + +class Camera(SmartCameraModule): + """Implementation of device module.""" + + NAME = "Camera" + QUERY_GETTER_NAME = "getLensMaskConfig" + QUERY_MODULE_NAME = "lens_mask" + QUERY_SECTION_NAMES = "lens_mask_info" + + def _initialize_features(self): + """Initialize features after the initial update.""" + if self.data: + self._add_feature( + Feature( + self, + id="state", + name="State", + attribute_getter="is_on", + attribute_setter="set_state", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + @property + def is_on(self) -> bool: + """Return the device id.""" + return self.data["lens_mask_info"]["enabled"] == "on" + + async def set_state(self, on: bool): + """Set the device state.""" + params = {"enabled": "on" if on else "off"} + await self._device._query_setter_helper( + "setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params + ) + + async def _check_supported(self): + """Additional check to see if the module is supported by the device.""" + return self._device.device_type is DeviceType.Camera diff --git a/kasa/experimental/modules/childdevice.py b/kasa/experimental/modules/childdevice.py new file mode 100644 index 000000000..24f65b470 --- /dev/null +++ b/kasa/experimental/modules/childdevice.py @@ -0,0 +1,23 @@ +"""Module for child devices.""" + +from ...device_type import DeviceType +from ..smartcameramodule import SmartCameraModule + + +class ChildDevice(SmartCameraModule): + """Implementation for child devices.""" + + NAME = "childdevice" + QUERY_GETTER_NAME = "getChildDeviceList" + QUERY_MODULE_NAME = "childControl" + + def query(self) -> dict: + """Query to execute during the update cycle. + + Default implementation uses the raw query getter w/o parameters. + """ + return {self.QUERY_GETTER_NAME: {self.QUERY_MODULE_NAME: {"start_index": 0}}} + + async def _check_supported(self): + """Additional check to see if the module is supported by the device.""" + return self._device.device_type is DeviceType.Hub diff --git a/kasa/experimental/modules/device.py b/kasa/experimental/modules/device.py new file mode 100644 index 000000000..5910d8b65 --- /dev/null +++ b/kasa/experimental/modules/device.py @@ -0,0 +1,40 @@ +"""Implementation of device module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartcameramodule import SmartCameraModule + + +class DeviceModule(SmartCameraModule): + """Implementation of device module.""" + + NAME = "devicemodule" + QUERY_GETTER_NAME = "getDeviceInfo" + QUERY_MODULE_NAME = "device_info" + QUERY_SECTION_NAMES = ["basic_info", "info"] + + def _initialize_features(self): + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self, + id="device_id", + name="Device ID", + attribute_getter="device_id", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + async def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because this module is critical. + """ + + @property + def device_id(self) -> str: + """Return the device id.""" + return self.data["basic_info"]["dev_id"] diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py index 3224c0034..4c1aba39c 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/experimental/smartcamera.py @@ -2,16 +2,26 @@ from __future__ import annotations +import logging from typing import Any from ..device_type import DeviceType -from ..exceptions import SmartErrorCode -from ..smart import SmartDevice +from ..module import Module +from ..smart import SmartChildDevice, SmartDevice +from .modules.childdevice import ChildDevice +from .modules.device import DeviceModule +from .smartcameramodule import SmartCameraModule +from .smartcameraprotocol import _ChildCameraProtocolWrapper + +_LOGGER = logging.getLogger(__name__) class SmartCamera(SmartDevice): """Class for smart cameras.""" + # Modules that are called as part of the init procedure on first update + FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice} + @staticmethod def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: """Find type to be displayed as a supported device category.""" @@ -20,17 +30,103 @@ def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: return DeviceType.Hub return DeviceType.Camera - async def update(self, update_children: bool = False): - """Update the device.""" + def _update_internal_info(self, info_resp): + """Update the internal device info.""" + info = self._try_get_response(info_resp, "getDeviceInfo") + self._info = self._map_info(info["device_info"]) + + def _update_children_info(self): + """Update the internal child device info from the parent info.""" + if child_info := self._try_get_response( + self._last_update, "getChildDeviceList", {} + ): + for info in child_info["child_device_list"]: + self._children[info["device_id"]]._update_internal_state(info) + + async def _initialize_smart_child(self, info): + """Initialize a smart child device attached to a smartcamera.""" + child_id = info["device_id"] + child_protocol = _ChildCameraProtocolWrapper(child_id, self.protocol) + try: + initial_response = await child_protocol.query( + {"component_nego": None, "get_connect_cloud_state": None} + ) + child_components = { + item["id"]: item["ver_code"] + for item in initial_response["component_nego"]["component_list"] + } + except Exception as ex: + _LOGGER.exception("Error initialising child %s: %s", child_id, ex) + self._children[child_id] = await SmartChildDevice.create( + parent=self, + child_info=info, + child_components=child_components, + protocol=child_protocol, + last_update=initial_response, + ) + + async def _initialize_children(self): + """Initialize children for hubs.""" + if not ( + child_info := self._try_get_response( + self._last_update, "getChildDeviceList", {} + ) + ): + return + for info in child_info["child_device_list"]: + if ( + category := info.get("category") + ) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: + # Is a smart child device + await self._initialize_smart_child(info) + else: + _LOGGER.debug("Child device type not supported: %s", info) + + async def _initialize_modules(self): + """Initialize modules based on component negotiation response.""" + for mod in SmartCameraModule.REGISTERED_MODULES.values(): + module = mod(self, mod.NAME) + if await module._check_supported(): + self._modules[module.name] = module + + async def _initialize_features(self): + """Initialize device features.""" + for module in self.modules.values(): + module._initialize_features() + for feat in module._module_features.values(): + self._add_feature(feat) + for child in self._children.values(): + await child._initialize_features() + + async def _query_setter_helper( + self, method: str, module: str, section: str, params: dict | None = None + ) -> Any: + res = await self.protocol.query({method: {module: {section: params}}}) + + return res + + async def _query_getter_helper( + self, method: str, module: str, sections: str | list[str] + ) -> Any: + res = await self.protocol.query({method: {module: {"name": sections}}}) + + return res + + async def _negotiate(self): + """Perform initialization. + + We fetch the device info and the available components as early as possible. + If the device reports supporting child devices, they are also initialized. + """ initial_query = { "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, "getLensMaskConfig": {"lens_mask": {"name": ["lens_mask_info"]}}, + "getChildDeviceList": {"childControl": {"start_index": 0}}, } resp = await self.protocol.query(initial_query) self._last_update.update(resp) - info = self._try_get_response(resp, "getDeviceInfo") - self._info = self._map_info(info["device_info"]) - self._last_update = resp + self._update_internal_info(resp) + await self._initialize_children() def _map_info(self, device_info: dict) -> dict: basic_info = device_info["basic_info"] @@ -48,25 +144,14 @@ def _map_info(self, device_info: dict) -> dict: @property def is_on(self) -> bool: """Return true if the device is on.""" - if isinstance(self._last_update["getLensMaskConfig"], SmartErrorCode): - return True - return ( - self._last_update["getLensMaskConfig"]["lens_mask"]["lens_mask_info"][ - "enabled" - ] - == "on" - ) + if (camera := self.modules.get(Module.Camera)) and not camera.disabled: + return camera.is_on + return True async def set_state(self, on: bool): """Set the device state.""" - if isinstance(self._last_update["getLensMaskConfig"], SmartErrorCode): - return - query = { - "setLensMaskConfig": { - "lens_mask": {"lens_mask_info": {"enabled": "on" if on else "off"}} - }, - } - return await self.protocol.query(query) + if (camera := self.modules.get(Module.Camera)) and not camera.disabled: + await camera.set_state(on) @property def device_type(self) -> DeviceType: @@ -82,6 +167,16 @@ def alias(self) -> str | None: return self._info.get("alias") return None + # setDeviceInfo sets the device_name + # "setDeviceInfo": {"device_info": {"basic_info": {"device_name": alias}}}, + async def set_alias(self, alias: str): + """Set the device name (alias).""" + return await self.protocol.query( + { + "setDeviceAlias": {"system": {"sys": {"dev_alias": alias}}}, + } + ) + @property def hw_info(self) -> dict: """Return hardware info for the device.""" diff --git a/kasa/experimental/smartcameramodule.py b/kasa/experimental/smartcameramodule.py new file mode 100644 index 000000000..0dcb4202e --- /dev/null +++ b/kasa/experimental/smartcameramodule.py @@ -0,0 +1,89 @@ +"""Base implementation for SMART modules.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ..exceptions import DeviceError, KasaException, SmartErrorCode +from ..smart.smartmodule import SmartModule + +if TYPE_CHECKING: + from .smartcamera import SmartCamera + +_LOGGER = logging.getLogger(__name__) + + +class SmartCameraModule(SmartModule): + """Base class for SMARTCAMERA modules.""" + + NAME: str + + #: Query to execute during the main update cycle + QUERY_GETTER_NAME: str + QUERY_MODULE_NAME: str + QUERY_SECTION_NAMES: str | list[str] + + REGISTERED_MODULES = {} + + _device: SmartCamera + + def query(self) -> dict: + """Query to execute during the update cycle. + + Default implementation uses the raw query getter w/o parameters. + """ + return { + self.QUERY_GETTER_NAME: { + self.QUERY_MODULE_NAME: {"name": self.QUERY_SECTION_NAMES} + } + } + + async def call(self, method, module, section, params=None): + """Call a method. + + Just a helper method. + """ + if method[:3] == "get": + return await self._device._query_getter_helper(method, module, section) + else: + return await self._device._query_setter_helper( + method, module, section, params + ) + + @property + def data(self): + """Return response data for the module.""" + dev = self._device + q = self.query() + + if not q: + return dev.sys_info + + if len(q) == 1: + query_resp = dev._last_update.get(self.QUERY_GETTER_NAME, {}) + if isinstance(query_resp, SmartErrorCode): + raise DeviceError( + f"Error accessing module data in {self._module}", + error_code=SmartErrorCode, + ) + if not query_resp: + raise KasaException( + f"You need to call update() prior accessing module data" + f" for '{self._module}'" + ) + return query_resp.get(self.QUERY_MODULE_NAME) + else: + found = {key: val for key, val in dev._last_update.items() if key in q} + for key in q: + if key not in found: + raise KasaException( + f"{key} not found, you need to call update() prior accessing" + f" module data for '{self._module}'" + ) + if isinstance(found[key], SmartErrorCode): + raise DeviceError( + f"Error accessing module data {key} in {self._module}", + error_code=SmartErrorCode, + ) + return found diff --git a/kasa/module.py b/kasa/module.py index 2c6014e55..e10b2d632 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -55,6 +55,7 @@ if TYPE_CHECKING: from . import interfaces from .device import Device + from .experimental import modules as experimental from .iot import modules as iot from .smart import modules as smart @@ -127,6 +128,9 @@ class Module(ABC): "WaterleakSensor" ) + # SMARTCAMERA only modules + Camera: Final[ModuleName[experimental.Camera]] = ModuleName("Camera") + def __init__(self, device: Device, module: str): self._device = device self._module = module diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 1fe0014e7..8a52046b3 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -38,15 +38,17 @@ def __init__( parent: SmartDevice, info, component_info, + *, config: DeviceConfig | None = None, protocol: SmartProtocol | None = None, ) -> None: - super().__init__(parent.host, config=parent.config, protocol=parent.protocol) + super().__init__(parent.host, config=parent.config, protocol=protocol) self._parent = parent self._update_internal_state(info) self._components = component_info self._id = info["device_id"] - self.protocol = _ChildProtocolWrapper(self._id, parent.protocol) + # wrap device protocol if no protocol is given + self.protocol = protocol or _ChildProtocolWrapper(self._id, parent.protocol) async def update(self, update_children: bool = True): """Update child module info. @@ -79,9 +81,27 @@ async def _update(self, update_children: bool = True): self._last_update_time = now @classmethod - async def create(cls, parent: SmartDevice, child_info, child_components): - """Create a child device based on device info and component listing.""" - child: SmartChildDevice = cls(parent, child_info, child_components) + async def create( + cls, + parent: SmartDevice, + child_info, + child_components, + protocol: SmartProtocol | None = None, + *, + last_update: dict | None = None, + ): + """Create a child device based on device info and component listing. + + If creating a smart child from a different protocol, i.e. a camera hub, + protocol: SmartProtocol and last_update should be provided as per the + FIRST_UPDATE_MODULES expected by the update cycle as these cannot be + derived from the parent. + """ + child: SmartChildDevice = cls( + parent, child_info, child_components, protocol=protocol + ) + if last_update: + child._last_update = last_update await child._initialize_modules() return child diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 0a8c136c0..0f62162ea 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -37,15 +37,15 @@ # same issue, homekit perhaps? NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] -# Modules that are called as part of the init procedure on first update -FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice, Cloud} - # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. class SmartDevice(Device): """Base class to represent a SMART protocol based device.""" + # Modules that are called as part of the init procedure on first update + FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice, Cloud} + def __init__( self, host: str, @@ -67,6 +67,7 @@ def __init__( self._last_update = {} self._last_update_time: float | None = None self._on_since: datetime | None = None + self._info: dict[str, Any] = {} async def _initialize_children(self): """Initialize children for power strips.""" @@ -154,6 +155,18 @@ async def _negotiate(self): if "child_device" in self._components and not self.children: await self._initialize_children() + def _update_children_info(self): + """Update the internal child device info from the parent info.""" + if child_info := self._try_get_response( + self._last_update, "get_child_device_list", {} + ): + for info in child_info["child_device_list"]: + self._children[info["device_id"]]._update_internal_state(info) + + def _update_internal_info(self, info_resp): + """Update the internal device info.""" + self._info = self._try_get_response(info_resp, "get_device_info") + async def update(self, update_children: bool = False): """Update the device.""" if self.credentials is None and self.credentials_hash is None: @@ -172,11 +185,7 @@ async def update(self, update_children: bool = False): resp = await self._modular_update(first_update, now) - if child_info := self._try_get_response( - self._last_update, "get_child_device_list", {} - ): - for info in child_info["child_device_list"]: - self._children[info["device_id"]]._update_internal_state(info) + self._update_children_info() # Call child update which will only update module calls, info is updated # from get_child_device_list. update_children only affects hub devices, other # devices will always update children to prevent errors on module access. @@ -227,10 +236,10 @@ async def _modular_update( mq = { module: query for module in self._modules.values() - if module.disabled is False and (query := module.query()) + if (first_update or module.disabled is False) and (query := module.query()) } for module, query in mq.items(): - if first_update and module.__class__ in FIRST_UPDATE_MODULES: + if first_update and module.__class__ in self.FIRST_UPDATE_MODULES: module._last_update_time = update_time continue if ( @@ -256,7 +265,7 @@ async def _modular_update( info_resp = self._last_update if first_update else resp self._last_update.update(**resp) - self._info = self._try_get_response(info_resp, "get_device_info") + self._update_internal_info(info_resp) # Call handle update for modules that want to update internal data for module in self._modules.values(): diff --git a/kasa/tests/smartcamera/test_smartcamera.py b/kasa/tests/smartcamera/test_smartcamera.py index 9c8893c02..50a1a1366 100644 --- a/kasa/tests/smartcamera/test_smartcamera.py +++ b/kasa/tests/smartcamera/test_smartcamera.py @@ -6,7 +6,7 @@ from kasa import Device, DeviceType -from ..conftest import device_smartcamera +from ..conftest import device_smartcamera, hub_smartcamera @device_smartcamera @@ -18,3 +18,30 @@ async def test_state(dev: Device): await dev.set_state(not state) await dev.update() assert dev.is_on is not state + + +@device_smartcamera +async def test_alias(dev): + test_alias = "TEST1234" + original = dev.alias + + assert isinstance(original, str) + await dev.set_alias(test_alias) + await dev.update() + assert dev.alias == test_alias + + await dev.set_alias(original) + await dev.update() + assert dev.alias == original + + +@hub_smartcamera +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.alias + await child.update() + assert "Time" not in child.modules + assert child.time From 4b4d9a171f62d22a9726b4fd5cb9011c282cad9f Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 14:08:26 +0100 Subject: [PATCH 2/9] Update post_update_hook docstring --- kasa/experimental/modules/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/experimental/modules/device.py b/kasa/experimental/modules/device.py index 5910d8b65..607a00615 100644 --- a/kasa/experimental/modules/device.py +++ b/kasa/experimental/modules/device.py @@ -28,7 +28,7 @@ def _initialize_features(self): ) async def _post_update_hook(self): - """Perform actions after a device update. + """Overriden to prevent module disabling. Overrides the default behaviour to disable a module if the query returns an error because this module is critical. From 29ce472066cc4df7c105264308bd9fcce3951d57 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 14:11:55 +0100 Subject: [PATCH 3/9] Remove lens_mask info from initial query --- kasa/experimental/smartcamera.py | 1 - 1 file changed, 1 deletion(-) diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py index 4c1aba39c..d41587ad6 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/experimental/smartcamera.py @@ -120,7 +120,6 @@ async def _negotiate(self): """ initial_query = { "getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}}, - "getLensMaskConfig": {"lens_mask": {"name": ["lens_mask_info"]}}, "getChildDeviceList": {"childControl": {"start_index": 0}}, } resp = await self.protocol.query(initial_query) From 3d31f0899b17becf7b1600ec5270a674ffac1b51 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 15:50:24 +0100 Subject: [PATCH 4/9] Add type annotations for ruff ANN check --- kasa/experimental/modules/camera.py | 10 ++++---- kasa/experimental/modules/childdevice.py | 2 +- kasa/experimental/modules/device.py | 6 ++--- kasa/experimental/smartcamera.py | 30 ++++++++++++++---------- kasa/experimental/smartcameramodule.py | 10 ++++++-- kasa/smart/smartchilddevice.py | 10 ++++---- kasa/smart/smartdevice.py | 6 ++--- kasa/smart/smartmodule.py | 2 +- 8 files changed, 43 insertions(+), 33 deletions(-) diff --git a/kasa/experimental/modules/camera.py b/kasa/experimental/modules/camera.py index 80b0c7175..0883c0cea 100644 --- a/kasa/experimental/modules/camera.py +++ b/kasa/experimental/modules/camera.py @@ -15,12 +15,12 @@ class Camera(SmartCameraModule): QUERY_MODULE_NAME = "lens_mask" QUERY_SECTION_NAMES = "lens_mask_info" - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" if self.data: self._add_feature( Feature( - self, + self._device, id="state", name="State", attribute_getter="is_on", @@ -35,13 +35,13 @@ def is_on(self) -> bool: """Return the device id.""" return self.data["lens_mask_info"]["enabled"] == "on" - async def set_state(self, on: bool): + async def set_state(self, on: bool) -> dict: """Set the device state.""" params = {"enabled": "on" if on else "off"} - await self._device._query_setter_helper( + return await self._device._query_setter_helper( "setLensMaskConfig", self.QUERY_MODULE_NAME, "lens_mask_info", params ) - async def _check_supported(self): + async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device.""" return self._device.device_type is DeviceType.Camera diff --git a/kasa/experimental/modules/childdevice.py b/kasa/experimental/modules/childdevice.py index 24f65b470..837793f1c 100644 --- a/kasa/experimental/modules/childdevice.py +++ b/kasa/experimental/modules/childdevice.py @@ -18,6 +18,6 @@ def query(self) -> dict: """ return {self.QUERY_GETTER_NAME: {self.QUERY_MODULE_NAME: {"start_index": 0}}} - async def _check_supported(self): + async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device.""" return self._device.device_type is DeviceType.Hub diff --git a/kasa/experimental/modules/device.py b/kasa/experimental/modules/device.py index 607a00615..34474ef2b 100644 --- a/kasa/experimental/modules/device.py +++ b/kasa/experimental/modules/device.py @@ -14,11 +14,11 @@ class DeviceModule(SmartCameraModule): QUERY_MODULE_NAME = "device_info" QUERY_SECTION_NAMES = ["basic_info", "info"] - def _initialize_features(self): + def _initialize_features(self) -> None: """Initialize features after the initial update.""" self._add_feature( Feature( - self, + self._device, id="device_id", name="Device ID", attribute_getter="device_id", @@ -27,7 +27,7 @@ def _initialize_features(self): ) ) - async def _post_update_hook(self): + async def _post_update_hook(self) -> None: """Overriden to prevent module disabling. Overrides the default behaviour to disable a module if the query returns diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py index d41587ad6..5ddfd0709 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/experimental/smartcamera.py @@ -30,12 +30,12 @@ def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: return DeviceType.Hub return DeviceType.Camera - def _update_internal_info(self, info_resp): + def _update_internal_info(self, info_resp: dict) -> None: """Update the internal device info.""" info = self._try_get_response(info_resp, "getDeviceInfo") self._info = self._map_info(info["device_info"]) - def _update_children_info(self): + def _update_children_info(self) -> None: """Update the internal child device info from the parent info.""" if child_info := self._try_get_response( self._last_update, "getChildDeviceList", {} @@ -43,7 +43,7 @@ def _update_children_info(self): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) - async def _initialize_smart_child(self, info): + async def _initialize_smart_child(self, info: dict) -> SmartDevice: """Initialize a smart child device attached to a smartcamera.""" child_id = info["device_id"] child_protocol = _ChildCameraProtocolWrapper(child_id, self.protocol) @@ -57,7 +57,7 @@ async def _initialize_smart_child(self, info): } except Exception as ex: _LOGGER.exception("Error initialising child %s: %s", child_id, ex) - self._children[child_id] = await SmartChildDevice.create( + return await SmartChildDevice.create( parent=self, child_info=info, child_components=child_components, @@ -65,7 +65,7 @@ async def _initialize_smart_child(self, info): last_update=initial_response, ) - async def _initialize_children(self): + async def _initialize_children(self) -> None: """Initialize children for hubs.""" if not ( child_info := self._try_get_response( @@ -73,23 +73,26 @@ async def _initialize_children(self): ) ): return + children = {} for info in child_info["child_device_list"]: if ( category := info.get("category") ) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: + child_id = info["device_id"] # Is a smart child device - await self._initialize_smart_child(info) + children[child_id] = await self._initialize_smart_child(info) else: _LOGGER.debug("Child device type not supported: %s", info) + self._children = children - async def _initialize_modules(self): + async def _initialize_modules(self) -> None: """Initialize modules based on component negotiation response.""" for mod in SmartCameraModule.REGISTERED_MODULES.values(): module = mod(self, mod.NAME) if await module._check_supported(): self._modules[module.name] = module - async def _initialize_features(self): + async def _initialize_features(self) -> None: """Initialize device features.""" for module in self.modules.values(): module._initialize_features() @@ -100,7 +103,7 @@ async def _initialize_features(self): async def _query_setter_helper( self, method: str, module: str, section: str, params: dict | None = None - ) -> Any: + ) -> dict: res = await self.protocol.query({method: {module: {section: params}}}) return res @@ -112,7 +115,7 @@ async def _query_getter_helper( return res - async def _negotiate(self): + async def _negotiate(self) -> None: """Perform initialization. We fetch the device info and the available components as early as possible. @@ -147,10 +150,11 @@ def is_on(self) -> bool: return camera.is_on return True - async def set_state(self, on: bool): + async def set_state(self, on: bool) -> dict: """Set the device state.""" if (camera := self.modules.get(Module.Camera)) and not camera.disabled: - await camera.set_state(on) + return await camera.set_state(on) + return {} @property def device_type(self) -> DeviceType: @@ -168,7 +172,7 @@ def alias(self) -> str | None: # setDeviceInfo sets the device_name # "setDeviceInfo": {"device_info": {"basic_info": {"device_name": alias}}}, - async def set_alias(self, alias: str): + async def set_alias(self, alias: str) -> dict: """Set the device name (alias).""" return await self.protocol.query( { diff --git a/kasa/experimental/smartcameramodule.py b/kasa/experimental/smartcameramodule.py index 0dcb4202e..b781c7aa7 100644 --- a/kasa/experimental/smartcameramodule.py +++ b/kasa/experimental/smartcameramodule.py @@ -39,11 +39,17 @@ def query(self) -> dict: } } - async def call(self, method, module, section, params=None): + async def call(self, method: str, params: dict | None = None) -> dict: """Call a method. Just a helper method. """ + if params: + module = next(iter(params)) + section = next(iter(params[module])) + else: + module = "system" + section = "null" if method[:3] == "get": return await self._device._query_getter_helper(method, module, section) else: @@ -52,7 +58,7 @@ async def call(self, method, module, section, params=None): ) @property - def data(self): + def data(self) -> dict: """Return response data for the module.""" dev = self._device q = self.query() diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 8a52046b3..f3e39ce9d 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -36,8 +36,8 @@ class SmartChildDevice(SmartDevice): def __init__( self, parent: SmartDevice, - info, - component_info, + info: dict, + component_info: dict, *, config: DeviceConfig | None = None, protocol: SmartProtocol | None = None, @@ -84,12 +84,12 @@ async def _update(self, update_children: bool = True): async def create( cls, parent: SmartDevice, - child_info, - child_components, + child_info: dict, + child_components: dict, protocol: SmartProtocol | None = None, *, last_update: dict | None = None, - ): + ) -> SmartDevice: """Create a child device based on device info and component listing. If creating a smart child from a different protocol, i.e. a camera hub, diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 0f62162ea..f4012b68f 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -155,7 +155,7 @@ async def _negotiate(self): if "child_device" in self._components and not self.children: await self._initialize_children() - def _update_children_info(self): + def _update_children_info(self) -> None: """Update the internal child device info from the parent info.""" if child_info := self._try_get_response( self._last_update, "get_child_device_list", {} @@ -163,7 +163,7 @@ def _update_children_info(self): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) - def _update_internal_info(self, info_resp): + def _update_internal_info(self, info_resp: dict) -> None: """Update the internal device info.""" self._info = self._try_get_response(info_resp, "get_device_info") @@ -579,7 +579,7 @@ def internal_state(self) -> Any: """Return all the internal state data.""" return self._last_update - def _update_internal_state(self, info): + def _update_internal_state(self, info: dict) -> None: """Update the internal info state. This is used by the parent to push updates to its children. diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 8fea1d9fb..2fc95c5b9 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -138,7 +138,7 @@ def query(self) -> dict: """ return {self.QUERY_GETTER_NAME: None} - async def call(self, method, params=None): + async def call(self, method, params: dict | None = None) -> dict: """Call a method. Just a helper method. From b6c11b82a1d40ff0dfac74e53422a3ef7de344ef Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 15:59:56 +0100 Subject: [PATCH 5/9] Fix mypy error --- kasa/smart/modules/lightstripeffect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index 3b0ff7da5..6b231a380 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -145,7 +145,7 @@ async def set_custom_effect( :param str effect_dict: The custom effect dict to set """ - return await self.call( + await self.call( "set_lighting_effect", effect_dict, ) From 72217a750308b7f4510d825f736a257b519b2550 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:02:08 +0100 Subject: [PATCH 6/9] Remove call typing --- kasa/smart/modules/lightstripeffect.py | 2 +- kasa/smart/smartmodule.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index 6b231a380..3b0ff7da5 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -145,7 +145,7 @@ async def set_custom_effect( :param str effect_dict: The custom effect dict to set """ - await self.call( + return await self.call( "set_lighting_effect", effect_dict, ) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 2fc95c5b9..8fea1d9fb 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -138,7 +138,7 @@ def query(self) -> dict: """ return {self.QUERY_GETTER_NAME: None} - async def call(self, method, params: dict | None = None) -> dict: + async def call(self, method, params=None): """Call a method. Just a helper method. From cc1e0c0caece7d61c7d41d0a6de7a8cd089f7b72 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:27:28 +0100 Subject: [PATCH 7/9] Apply suggestions from code review Co-authored-by: Teemu R. --- kasa/experimental/smartcamera.py | 9 ++++++--- kasa/experimental/smartcameramodule.py | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py index 5ddfd0709..1784b40a9 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/experimental/smartcamera.py @@ -57,6 +57,7 @@ async def _initialize_smart_child(self, info: dict) -> SmartDevice: } except Exception as ex: _LOGGER.exception("Error initialising child %s: %s", child_id, ex) + return await SmartChildDevice.create( parent=self, child_info=info, @@ -73,16 +74,17 @@ async def _initialize_children(self) -> None: ) ): return + children = {} for info in child_info["child_device_list"]: if ( category := info.get("category") ) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: child_id = info["device_id"] - # Is a smart child device children[child_id] = await self._initialize_smart_child(info) else: _LOGGER.debug("Child device type not supported: %s", info) + self._children = children async def _initialize_modules(self) -> None: @@ -98,6 +100,7 @@ async def _initialize_features(self) -> None: module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) + for child in self._children.values(): await child._initialize_features() @@ -148,12 +151,14 @@ def is_on(self) -> bool: """Return true if the device is on.""" if (camera := self.modules.get(Module.Camera)) and not camera.disabled: return camera.is_on + return True async def set_state(self, on: bool) -> dict: """Set the device state.""" if (camera := self.modules.get(Module.Camera)) and not camera.disabled: return await camera.set_state(on) + return {} @property @@ -170,8 +175,6 @@ def alias(self) -> str | None: return self._info.get("alias") return None - # setDeviceInfo sets the device_name - # "setDeviceInfo": {"device_info": {"basic_info": {"device_name": alias}}}, async def set_alias(self, alias: str) -> dict: """Set the device name (alias).""" return await self.protocol.query( diff --git a/kasa/experimental/smartcameramodule.py b/kasa/experimental/smartcameramodule.py index b781c7aa7..a8d1b3ad1 100644 --- a/kasa/experimental/smartcameramodule.py +++ b/kasa/experimental/smartcameramodule.py @@ -50,6 +50,7 @@ async def call(self, method: str, params: dict | None = None) -> dict: else: module = "system" section = "null" + if method[:3] == "get": return await self._device._query_getter_helper(method, module, section) else: @@ -73,11 +74,13 @@ def data(self) -> dict: f"Error accessing module data in {self._module}", error_code=SmartErrorCode, ) + if not query_resp: raise KasaException( f"You need to call update() prior accessing module data" f" for '{self._module}'" ) + return query_resp.get(self.QUERY_MODULE_NAME) else: found = {key: val for key, val in dev._last_update.items() if key in q} From dee0cab294e521586263c20af49f4627ad944c9d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:46:59 +0100 Subject: [PATCH 8/9] Update post review --- kasa/experimental/modules/camera.py | 1 - kasa/experimental/smartcamera.py | 2 +- kasa/experimental/smartcameramodule.py | 10 ++++------ kasa/smart/smartmodule.py | 8 ++++++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/kasa/experimental/modules/camera.py b/kasa/experimental/modules/camera.py index 0883c0cea..2967a70ee 100644 --- a/kasa/experimental/modules/camera.py +++ b/kasa/experimental/modules/camera.py @@ -10,7 +10,6 @@ class Camera(SmartCameraModule): """Implementation of device module.""" - NAME = "Camera" QUERY_GETTER_NAME = "getLensMaskConfig" QUERY_MODULE_NAME = "lens_mask" QUERY_SECTION_NAMES = "lens_mask_info" diff --git a/kasa/experimental/smartcamera.py b/kasa/experimental/smartcamera.py index 1784b40a9..52a6acdfa 100644 --- a/kasa/experimental/smartcamera.py +++ b/kasa/experimental/smartcamera.py @@ -90,7 +90,7 @@ async def _initialize_children(self) -> None: async def _initialize_modules(self) -> None: """Initialize modules based on component negotiation response.""" for mod in SmartCameraModule.REGISTERED_MODULES.values(): - module = mod(self, mod.NAME) + module = mod(self, mod._module_name()) if await module._check_supported(): self._modules[module.name] = module diff --git a/kasa/experimental/smartcameramodule.py b/kasa/experimental/smartcameramodule.py index a8d1b3ad1..fed97cb35 100644 --- a/kasa/experimental/smartcameramodule.py +++ b/kasa/experimental/smartcameramodule.py @@ -17,11 +17,11 @@ class SmartCameraModule(SmartModule): """Base class for SMARTCAMERA modules.""" - NAME: str - #: Query to execute during the main update cycle QUERY_GETTER_NAME: str + #: Module name to be queried QUERY_MODULE_NAME: str + #: Section name or names to be queried QUERY_SECTION_NAMES: str | list[str] REGISTERED_MODULES = {} @@ -53,10 +53,8 @@ async def call(self, method: str, params: dict | None = None) -> dict: if method[:3] == "get": return await self._device._query_getter_helper(method, module, section) - else: - return await self._device._query_setter_helper( - method, module, section, params - ) + + return await self._device._query_setter_helper(method, module, section, params) @property def data(self) -> dict: diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 8fea1d9fb..f20186ec6 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -80,7 +80,7 @@ def __init_subclass__(cls, **kwargs): # other classes can inherit from smartmodule and not be registered if cls.__module__.split(".")[-2] == "modules": _LOGGER.debug("Registering %s", cls) - cls.REGISTERED_MODULES[cls.__name__] = cls + cls.REGISTERED_MODULES[cls._module_name()] = cls def _set_error(self, err: Exception | None): if err is None: @@ -118,10 +118,14 @@ def disabled(self) -> bool: """Return true if the module is disabled due to errors.""" return self._error_count >= self.DISABLE_AFTER_ERROR_COUNT + @classmethod + def _module_name(cls): + return getattr(cls, "NAME", cls.__name__) + @property def name(self) -> str: """Name of the module.""" - return getattr(self, "NAME", self.__class__.__name__) + return self._module_name() async def _post_update_hook(self): # noqa: B027 """Perform actions after a device update. From 5caa504854b3f50e2ae53afaf2bc0ed042453506 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Oct 2024 17:00:46 +0100 Subject: [PATCH 9/9] Remove is self.data check from camera module --- kasa/experimental/modules/camera.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/kasa/experimental/modules/camera.py b/kasa/experimental/modules/camera.py index 2967a70ee..76701b52a 100644 --- a/kasa/experimental/modules/camera.py +++ b/kasa/experimental/modules/camera.py @@ -16,18 +16,17 @@ class Camera(SmartCameraModule): def _initialize_features(self) -> None: """Initialize features after the initial update.""" - if self.data: - self._add_feature( - Feature( - self._device, - id="state", - name="State", - attribute_getter="is_on", - attribute_setter="set_state", - type=Feature.Type.Switch, - category=Feature.Category.Primary, - ) + self._add_feature( + Feature( + self._device, + id="state", + name="State", + attribute_getter="is_on", + attribute_setter="set_state", + type=Feature.Type.Switch, + category=Feature.Category.Primary, ) + ) @property def is_on(self) -> bool: