Add battery module to smartcam devices (#1452)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run

This commit is contained in:
Steven B. 2025-01-14 21:57:35 +00:00 committed by GitHub
parent 2542516009
commit 4e7e18cef1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 200 additions and 25 deletions

View File

@ -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",

View File

@ -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"

View File

@ -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.

View File

@ -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:

View File

@ -33,6 +33,8 @@ class SmartCamModule(SmartModule):
"BabyCryDetection"
)
SmartCamBattery: Final[ModuleName[modules.Battery]] = ModuleName("Battery")
SmartCamDeviceModule: Final[ModuleName[modules.DeviceModule]] = ModuleName(
"devicemodule"
)

View File

@ -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

View File

@ -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(

View File

@ -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])

View File

@ -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}": {}}

View File

@ -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