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 .alarm import Alarm
from .babycrydetection import BabyCryDetection from .babycrydetection import BabyCryDetection
from .battery import Battery
from .camera import Camera from .camera import Camera
from .childdevice import ChildDevice from .childdevice import ChildDevice
from .device import DeviceModule from .device import DeviceModule
@ -18,6 +19,7 @@ from .time import Time
__all__ = [ __all__ = [
"Alarm", "Alarm",
"BabyCryDetection", "BabyCryDetection",
"Battery",
"Camera", "Camera",
"ChildDevice", "ChildDevice",
"DeviceModule", "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, None,
) )
def _map_child_info_from_parent(self, device_info: dict) -> dict: @staticmethod
return { def _map_child_info_from_parent(device_info: dict) -> dict:
"model": device_info["device_model"], mappings = {
"device_type": device_info["device_type"], "device_model": "model",
"alias": device_info["alias"], "sw_ver": "fw_ver",
"fw_ver": device_info["sw_ver"], "hw_id": "hwId",
"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"],
} }
return {mappings.get(k, k): v for k, v in device_info.items()}
def _update_internal_state(self, info: dict[str, Any]) -> None: def _update_internal_state(self, info: dict[str, Any]) -> None:
"""Update the internal info state. """Update the internal info state.

View File

@ -238,18 +238,17 @@ class SmartCamDevice(SmartDevice):
await self._initialize_children() await self._initialize_children()
def _map_info(self, device_info: dict) -> dict: def _map_info(self, device_info: dict) -> dict:
"""Map the basic keys to the keys used by SmartDevices."""
basic_info = device_info["basic_info"] basic_info = device_info["basic_info"]
return { mappings = {
"model": basic_info["device_model"], "device_model": "model",
"device_type": basic_info["device_type"], "device_alias": "alias",
"alias": basic_info["device_alias"], "sw_version": "fw_ver",
"fw_ver": basic_info["sw_version"], "hw_version": "hw_ver",
"hw_ver": basic_info["hw_version"], "hw_id": "hwId",
"mac": basic_info["mac"], "dev_id": "device_id",
"hwId": basic_info.get("hw_id"),
"oem_id": basic_info["oem_id"],
"device_id": basic_info["dev_id"],
} }
return {mappings.get(k, k): v for k, v in basic_info.items()}
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:

View File

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

View File

@ -435,6 +435,15 @@ async def get_device_for_fixture(
d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)( d = device_for_fixture_name(fixture_data.name, fixture_data.protocol)(
host="127.0.0.123" 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"}: if fixture_data.protocol in {"SMART", "SMART.CHILD"}:
d.protocol = FakeSmartProtocol( d.protocol = FakeSmartProtocol(
fixture_data.data, fixture_data.name, verbatim=verbatim 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 child_fixture["get_device_info"]["device_id"] = device_id
found_child_fixture_infos.append(child_fixture["get_device_info"]) found_child_fixture_infos.append(child_fixture["get_device_info"])
child_protocols[device_id] = FakeSmartProtocol( 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 # Look for fixture inline
elif (child_fixtures := parent_fixture_info.get("child_devices")) and ( elif (child_fixtures := parent_fixture_info.get("child_devices")) and (
@ -273,6 +276,7 @@ class FakeSmartTransport(BaseTransport):
child_fixture, child_fixture,
f"{parent_fixture_name}-{device_id}", f"{parent_fixture_name}-{device_id}",
is_child=True, is_child=True,
verbatim=verbatim,
) )
else: else:
pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] 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. # list for smartcam children in order for updates to work.
found_child_fixture_infos.append(child_fixture[CHILD_INFO_FROM_PARENT]) found_child_fixture_infos.append(child_fixture[CHILD_INFO_FROM_PARENT])
child_protocols[device_id] = FakeSmartCamProtocol( 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: else:
warn( warn(

View File

@ -6,7 +6,7 @@ from typing import Any
from kasa import Credentials, DeviceConfig, SmartProtocol from kasa import Credentials, DeviceConfig, SmartProtocol
from kasa.protocols.smartcamprotocol import SmartCamProtocol 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 kasa.transports.basetransport import BaseTransport
from .fakeprotocol_smart import FakeSmartTransport from .fakeprotocol_smart import FakeSmartTransport
@ -243,6 +243,20 @@ class FakeSmartCamTransport(BaseTransport):
else: else:
return {"error_code": -1} 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: if method in info:
params = request_dict.get("params") params = request_dict.get("params")
result = copy.deepcopy(info[method]) 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(): for modname, module in child._modules.items():
if ( if (
not (q := module.query()) not (q := module.query())
and modname not in {"DeviceModule", "Light"} and modname not in {"DeviceModule", "Light", "Battery", "Camera"}
and not module.SYSINFO_LOOKUP_KEYS and not module.SYSINFO_LOOKUP_KEYS
): ):
q = {f"get_dummy_{modname}": {}} 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