Skip to content

Add battery module to smartcam devices #1452

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jan 14, 2025
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
2 changes: 2 additions & 0 deletions kasa/smartcam/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@
__all__ = [
"Alarm",
"BabyCryDetection",
"Battery",
"Camera",
"ChildDevice",
"DeviceModule",
Expand Down
113 changes: 113 additions & 0 deletions kasa/smartcam/modules/battery.py
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 7 additions & 11 deletions kasa/smartcam/smartcamchild.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this change intentional?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the previous mapping logic only mapped a fix set of keys but didn't leave the extra keys behind which we need to access other properties of sysinfo.

Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,14 @@ def device_info(self) -> DeviceInfo:
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.
Expand Down
19 changes: 9 additions & 10 deletions kasa/smartcam/smartcamdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,18 +238,17 @@ async def _negotiate(self) -> None:
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:
Expand Down
2 changes: 2 additions & 0 deletions kasa/smartcam/smartcammodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class SmartCamModule(SmartModule):
"BabyCryDetection"
)

SmartCamBattery: Final[ModuleName[modules.Battery]] = ModuleName("Battery")

SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName(
"devicemodule"
)
Expand Down
9 changes: 9 additions & 0 deletions tests/device_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions tests/fakeprotocol_smart.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,10 @@ def try_get_child_fixture_info(child_dev_info, protocol):
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 (
Expand All @@ -273,6 +276,7 @@ def try_get_child_fixture_info(child_dev_info, protocol):
child_fixture,
f"{parent_fixture_name}-{device_id}",
is_child=True,
verbatim=verbatim,
)
else:
pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined]
Expand All @@ -299,7 +303,10 @@ def try_get_child_fixture_info(child_dev_info, protocol):
# 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(
Expand Down
16 changes: 15 additions & 1 deletion tests/fakeprotocol_smartcam.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

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
Expand Down Expand Up @@ -243,6 +243,20 @@ async def _send_request(self, request_dict: dict):
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])
Expand Down
2 changes: 1 addition & 1 deletion tests/smart/test_smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}": {}}
Expand Down
33 changes: 33 additions & 0 deletions tests/smartcam/modules/test_battery.py
Original file line number Diff line number Diff line change
@@ -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
Loading