diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index 3ea4bb6a..06130a37 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -2,6 +2,7 @@ from .alarm import Alarm from .babycrydetection import BabyCryDetection +from .battery import Battery from .camera import Camera from .childdevice import ChildDevice from .device import DeviceModule @@ -18,6 +19,7 @@ from .time import Time __all__ = [ "Alarm", "BabyCryDetection", + "Battery", "Camera", "ChildDevice", "DeviceModule", diff --git a/kasa/smartcam/modules/battery.py b/kasa/smartcam/modules/battery.py new file mode 100644 index 00000000..d6bd97f3 --- /dev/null +++ b/kasa/smartcam/modules/battery.py @@ -0,0 +1,113 @@ +"""Implementation of baby cry detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class Battery(SmartCamModule): + """Implementation of a battery module.""" + + REQUIRED_COMPONENT = "battery" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + "battery_low", + "Battery low", + container=self, + attribute_getter="battery_low", + icon="mdi:alert", + type=Feature.Type.BinarySensor, + category=Feature.Category.Debug, + ) + ) + + self._add_feature( + Feature( + self._device, + "battery_level", + "Battery level", + container=self, + attribute_getter="battery_percent", + icon="mdi:battery", + unit_getter=lambda: "%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + self._add_feature( + Feature( + self._device, + "battery_temperature", + "Battery temperature", + container=self, + attribute_getter="battery_temperature", + icon="mdi:battery", + unit_getter=lambda: "celsius", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + "battery_voltage", + "Battery voltage", + container=self, + attribute_getter="battery_voltage", + icon="mdi:battery", + unit_getter=lambda: "V", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + "battery_charging", + "Battery charging", + container=self, + attribute_getter="battery_charging", + icon="mdi:alert", + type=Feature.Type.BinarySensor, + category=Feature.Category.Debug, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def battery_percent(self) -> int: + """Return battery level.""" + return self._device.sys_info["battery_percent"] + + @property + def battery_low(self) -> bool: + """Return True if battery is low.""" + return self._device.sys_info["low_battery"] + + @property + def battery_temperature(self) -> bool: + """Return battery voltage in C.""" + return self._device.sys_info["battery_temperature"] + + @property + def battery_voltage(self) -> bool: + """Return battery voltage in V.""" + return self._device.sys_info["battery_voltage"] / 1_000 + + @property + def battery_charging(self) -> bool: + """Return True if battery is charging.""" + return self._device.sys_info["battery_voltage"] != "NO" diff --git a/kasa/smartcam/smartcamchild.py b/kasa/smartcam/smartcamchild.py index f02f21c9..d1b263b4 100644 --- a/kasa/smartcam/smartcamchild.py +++ b/kasa/smartcam/smartcamchild.py @@ -63,18 +63,14 @@ class SmartCamChild(SmartChildDevice, SmartCamDevice): 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"], + @staticmethod + def _map_child_info_from_parent(device_info: dict) -> dict: + mappings = { + "device_model": "model", + "sw_ver": "fw_ver", + "hw_id": "hwId", } + return {mappings.get(k, k): v for k, v in device_info.items()} def _update_internal_state(self, info: dict[str, Any]) -> None: """Update the internal info state. diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 06629678..b8d2cf80 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -238,18 +238,17 @@ class SmartCamDevice(SmartDevice): await self._initialize_children() def _map_info(self, device_info: dict) -> dict: + """Map the basic keys to the keys used by SmartDevices.""" basic_info = device_info["basic_info"] - return { - "model": basic_info["device_model"], - "device_type": basic_info["device_type"], - "alias": basic_info["device_alias"], - "fw_ver": basic_info["sw_version"], - "hw_ver": basic_info["hw_version"], - "mac": basic_info["mac"], - "hwId": basic_info.get("hw_id"), - "oem_id": basic_info["oem_id"], - "device_id": basic_info["dev_id"], + mappings = { + "device_model": "model", + "device_alias": "alias", + "sw_version": "fw_ver", + "hw_version": "hw_ver", + "hw_id": "hwId", + "dev_id": "device_id", } + return {mappings.get(k, k): v for k, v in basic_info.items()} @property def is_on(self) -> bool: diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index 85addd65..7b85680e 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -33,6 +33,8 @@ class SmartCamModule(SmartModule): "BabyCryDetection" ) + SmartCamBattery: Final[ModuleName[modules.Battery]] = ModuleName("Battery") + SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName( "devicemodule" ) diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 77e31ceb..f28b17e3 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -435,6 +435,15 @@ async def get_device_for_fixture( d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( host="127.0.0.123" ) + + # smart child devices sometimes check _is_hub_child which needs a parent + # of DeviceType.Hub + class DummyParent: + device_type = DeviceType.Hub + + if fixture_data.protocol in {"SMARTCAM.CHILD"}: + d._parent = DummyParent() + if fixture_data.protocol in {"SMART", "SMART.CHILD"}: d.protocol = FakeSmartProtocol( fixture_data.data, fixture_data.name, verbatim=verbatim diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index 393b5f31..27b99438 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -262,7 +262,10 @@ class FakeSmartTransport(BaseTransport): child_fixture["get_device_info"]["device_id"] = device_id found_child_fixture_infos.append(child_fixture["get_device_info"]) child_protocols[device_id] = FakeSmartProtocol( - child_fixture, fixture_info_tuple.name, is_child=True + child_fixture, + fixture_info_tuple.name, + is_child=True, + verbatim=verbatim, ) # Look for fixture inline elif (child_fixtures := parent_fixture_info.get("child_devices")) and ( @@ -273,6 +276,7 @@ class FakeSmartTransport(BaseTransport): child_fixture, f"{parent_fixture_name}-{device_id}", is_child=True, + verbatim=verbatim, ) else: pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] @@ -299,7 +303,10 @@ class FakeSmartTransport(BaseTransport): # 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 + child_fixture, + fixture_info_tuple.name, + is_child=True, + verbatim=verbatim, ) else: warn( diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 431a761d..53a9ec17 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -6,7 +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.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT, SmartCamChild from kasa.transports.basetransport import BaseTransport from .fakeprotocol_smart import FakeSmartTransport @@ -243,6 +243,20 @@ class FakeSmartCamTransport(BaseTransport): else: return {"error_code": -1} + # smartcam child devices do not make requests for getDeviceInfo as they + # get updated from the parent's query. If this is being called from a + # child it must be because the fixture has been created directly on the + # child device with a dummy parent. In this case return the child info + # from parent that's inside the fixture. + if ( + not self.verbatim + and method == "getDeviceInfo" + and (cifp := info.get(CHILD_INFO_FROM_PARENT)) + ): + mapped = SmartCamChild._map_child_info_from_parent(cifp) + result = {"device_info": {"basic_info": mapped}} + return {"result": result, "error_code": 0} + if method in info: params = request_dict.get("params") result = copy.deepcopy(info[method]) diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 1cae0abc..0cc38a71 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -269,7 +269,7 @@ async def test_hub_children_update_delays( for modname, module in child._modules.items(): if ( not (q := module.query()) - and modname not in {"DeviceModule", "Light"} + and modname not in {"DeviceModule", "Light", "Battery", "Camera"} and not module.SYSINFO_LOOKUP_KEYS ): q = {f"get_dummy_{modname}": {}} diff --git a/tests/smartcam/modules/test_battery.py b/tests/smartcam/modules/test_battery.py new file mode 100644 index 00000000..12cab14b --- /dev/null +++ b/tests/smartcam/modules/test_battery.py @@ -0,0 +1,33 @@ +"""Tests for smartcam battery module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +battery_smartcam = parametrize( + "has battery", + component_filter="battery", + protocol_filter={"SMARTCAM", "SMARTCAM.CHILD"}, +) + + +@battery_smartcam +async def test_battery(dev: Device): + """Test device battery.""" + battery = dev.modules.get(SmartCamModule.SmartCamBattery) + assert battery + + feat_ids = { + "battery_level", + "battery_low", + "battery_temperature", + "battery_voltage", + "battery_charging", + } + for feat_id in feat_ids: + feat = dev.features.get(feat_id) + assert feat + assert feat.value is not None