Add FeatureAttributes to smartcam Alarm (#1489)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Has been cancelled
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Has been cancelled

Co-authored-by: Teemu R. <tpr@iki.fi>
This commit is contained in:
Steven B. 2025-01-29 19:32:01 +00:00 committed by GitHub
parent ebd370da74
commit 44c561b04d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 78 additions and 11 deletions

View File

@ -28,7 +28,7 @@ class Energy(Module, ABC):
_supported: ModuleFeature = ModuleFeature(0) _supported: ModuleFeature = ModuleFeature(0)
def supports(self, module_feature: ModuleFeature) -> bool: def supports(self, module_feature: Energy.ModuleFeature) -> bool:
"""Return True if module supports the feature.""" """Return True if module supports the feature."""
return module_feature in self._supported return module_feature in self._supported

View File

@ -3,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Callable, Coroutine
from functools import wraps
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..exceptions import DeviceError, KasaException, SmartErrorCode
@ -20,15 +21,16 @@ _R = TypeVar("_R")
def allow_update_after( def allow_update_after(
func: Callable[Concatenate[_T, _P], Awaitable[dict]], func: Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, dict]]: ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]:
"""Define a wrapper to set _last_update_time to None. """Define a wrapper to set _last_update_time to None.
This will ensure that a module is updated in the next update cycle after This will ensure that a module is updated in the next update cycle after
a value has been changed. a value has been changed.
""" """
async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> dict: @wraps(func)
async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R:
try: try:
return await func(self, *args, **kwargs) return await func(self, *args, **kwargs)
finally: finally:
@ -40,6 +42,7 @@ def allow_update_after(
def raise_if_update_error(func: Callable[[_T], _R]) -> Callable[[_T], _R]: def raise_if_update_error(func: Callable[[_T], _R]) -> Callable[[_T], _R]:
"""Define a wrapper to raise an error if the last module update was an error.""" """Define a wrapper to raise an error if the last module update was an error."""
@wraps(func)
def _wrap(self: _T) -> _R: def _wrap(self: _T) -> _R:
if err := self._last_update_error: if err := self._last_update_error:
raise err raise err

View File

@ -2,8 +2,11 @@
from __future__ import annotations from __future__ import annotations
from typing import Annotated
from ...feature import Feature from ...feature import Feature
from ...interfaces import Alarm as AlarmInterface from ...interfaces import Alarm as AlarmInterface
from ...module import FeatureAttribute
from ...smart.smartmodule import allow_update_after from ...smart.smartmodule import allow_update_after
from ..smartcammodule import SmartCamModule from ..smartcammodule import SmartCamModule
@ -105,12 +108,12 @@ class Alarm(SmartCamModule, AlarmInterface):
) )
@property @property
def alarm_sound(self) -> str: def alarm_sound(self) -> Annotated[str, FeatureAttribute()]:
"""Return current alarm sound.""" """Return current alarm sound."""
return self.data["getSirenConfig"]["siren_type"] return self.data["getSirenConfig"]["siren_type"]
@allow_update_after @allow_update_after
async def set_alarm_sound(self, sound: str) -> dict: async def set_alarm_sound(self, sound: str) -> Annotated[dict, FeatureAttribute()]:
"""Set alarm sound. """Set alarm sound.
See *alarm_sounds* for list of available sounds. See *alarm_sounds* for list of available sounds.
@ -124,7 +127,7 @@ class Alarm(SmartCamModule, AlarmInterface):
return self.data["getSirenTypeList"]["siren_type_list"] return self.data["getSirenTypeList"]["siren_type_list"]
@property @property
def alarm_volume(self) -> int: def alarm_volume(self) -> Annotated[int, FeatureAttribute()]:
"""Return alarm volume. """Return alarm volume.
Unlike duration the device expects/returns a string for volume. Unlike duration the device expects/returns a string for volume.
@ -132,18 +135,22 @@ class Alarm(SmartCamModule, AlarmInterface):
return int(self.data["getSirenConfig"]["volume"]) return int(self.data["getSirenConfig"]["volume"])
@allow_update_after @allow_update_after
async def set_alarm_volume(self, volume: int) -> dict: async def set_alarm_volume(
self, volume: int
) -> Annotated[dict, FeatureAttribute()]:
"""Set alarm volume.""" """Set alarm volume."""
config = self._validate_and_get_config(volume=volume) config = self._validate_and_get_config(volume=volume)
return await self.call("setSirenConfig", {"siren": config}) return await self.call("setSirenConfig", {"siren": config})
@property @property
def alarm_duration(self) -> int: def alarm_duration(self) -> Annotated[int, FeatureAttribute()]:
"""Return alarm duration.""" """Return alarm duration."""
return self.data["getSirenConfig"]["duration"] return self.data["getSirenConfig"]["duration"]
@allow_update_after @allow_update_after
async def set_alarm_duration(self, duration: int) -> dict: async def set_alarm_duration(
self, duration: int
) -> Annotated[dict, FeatureAttribute()]:
"""Set alarm volume.""" """Set alarm volume."""
config = self._validate_and_get_config(duration=duration) config = self._validate_and_get_config(duration=duration)
return await self.call("setSirenConfig", {"siren": config}) return await self.call("setSirenConfig", {"siren": config})

View File

@ -1,10 +1,16 @@
import importlib
import inspect
import pkgutil
import sys
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
import kasa.interfaces
from kasa import Device, LightState, Module, ThermostatState from kasa import Device, LightState, Module, ThermostatState
from kasa.module import _get_feature_attribute
from .device_fixtures import ( from .device_fixtures import (
bulb_iot, bulb_iot,
@ -64,6 +70,57 @@ temp_control_smart = parametrize(
) )
interfaces = pytest.mark.parametrize("interface", kasa.interfaces.__all__)
def _get_subclasses(of_class, package):
"""Get all the subclasses of a given class."""
subclasses = set()
# iter_modules returns ModuleInfo: (module_finder, name, ispkg)
for _, modname, ispkg in pkgutil.iter_modules(package.__path__):
importlib.import_module("." + modname, package=package.__name__)
module = sys.modules[package.__name__ + "." + modname]
for _, obj in inspect.getmembers(module):
if (
inspect.isclass(obj)
and issubclass(obj, of_class)
and obj is not of_class
):
subclasses.add(obj)
if ispkg:
res = _get_subclasses(of_class, module)
subclasses.update(res)
return subclasses
@interfaces
def test_feature_attributes(interface):
"""Test that all common derived classes define the FeatureAttributes."""
klass = getattr(kasa.interfaces, interface)
package = sys.modules["kasa"]
sub_classes = _get_subclasses(klass, package)
feat_attributes: set[str] = set()
attribute_names = [
k
for k, v in vars(klass).items()
if (callable(v) and not inspect.isclass(v)) or isinstance(v, property)
]
for attr_name in attribute_names:
attribute = getattr(klass, attr_name)
if _get_feature_attribute(attribute):
feat_attributes.add(attr_name)
for sub_class in sub_classes:
for attr_name in feat_attributes:
attribute = getattr(sub_class, attr_name)
fa = _get_feature_attribute(attribute)
assert fa, f"{attr_name} is not a defined module feature for {sub_class}"
@led @led
async def test_led_module(dev: Device, mocker: MockerFixture): async def test_led_module(dev: Device, mocker: MockerFixture):
"""Test fan speed feature.""" """Test fan speed feature."""