From 063977d60d760643b3963986120d880db7957e59 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sun, 12 May 2024 13:18:15 +0100 Subject: [PATCH 1/3] Make Light and Brightness a common module --- kasa/interfaces/__init__.py | 2 + kasa/interfaces/brightness.py | 48 +++++++ kasa/interfaces/fan.py | 4 +- kasa/interfaces/light.py | 22 ++- kasa/iot/iotbulb.py | 42 +++--- kasa/iot/iotdevice.py | 6 + kasa/iot/iotdimmer.py | 29 ++-- kasa/iot/iotlightstrip.py | 4 + kasa/iot/iotplug.py | 4 + kasa/iot/iotstrip.py | 4 + kasa/iot/modules/__init__.py | 4 + kasa/iot/modules/brightness.py | 42 ++++++ kasa/iot/modules/light.py | 137 ++++++++++++++++++ kasa/module.py | 10 +- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/brightness.py | 40 ++---- kasa/smart/modules/light.py | 126 ++++++++++++++++ kasa/smart/smartdevice.py | 144 ++----------------- kasa/tests/device_fixtures.py | 12 +- kasa/tests/smart/features/test_brightness.py | 4 +- kasa/tests/smart/modules/test_fan.py | 16 +-- kasa/tests/test_bulb.py | 97 +++++++------ kasa/tests/test_common_modules.py | 34 ++++- kasa/tests/test_dimmer.py | 20 +-- kasa/tests/test_discovery.py | 8 +- kasa/tests/test_lightstrip.py | 16 +-- kasa/tests/test_smartdevice.py | 23 --- 27 files changed, 575 insertions(+), 325 deletions(-) create mode 100644 kasa/interfaces/brightness.py create mode 100644 kasa/iot/modules/brightness.py create mode 100644 kasa/iot/modules/light.py create mode 100644 kasa/smart/modules/light.py diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index d8d089c5c..227fb782c 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -1,11 +1,13 @@ """Package for interfaces.""" +from .brightness import Brightness from .fan import Fan from .led import Led from .light import Light, LightPreset from .lighteffect import LightEffect __all__ = [ + "Brightness", "Fan", "Led", "Light", diff --git a/kasa/interfaces/brightness.py b/kasa/interfaces/brightness.py new file mode 100644 index 000000000..3cb23a909 --- /dev/null +++ b/kasa/interfaces/brightness.py @@ -0,0 +1,48 @@ +"""Module for base light effect module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..feature import Feature +from ..module import Module + + +class Brightness(Module, ABC): + """Base interface to represent a Brightness module.""" + + BRIGHTNESS_MIN = 0 + BRIGHTNESS_MAX = 100 + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + id="brightness", + name="Brightness", + container=self, + attribute_getter="brightness", + attribute_setter="set_brightness", + minimum_value=self.BRIGHTNESS_MIN, + maximum_value=self.BRIGHTNESS_MAX, + type=Feature.Type.Number, + category=Feature.Category.Primary, + ) + ) + + @property + @abstractmethod + def brightness(self) -> int: + """Return current brightness in percentage.""" + + @abstractmethod + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> None: + """Set the brightness in percentage. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ diff --git a/kasa/interfaces/fan.py b/kasa/interfaces/fan.py index 767fe89f1..89d8d82be 100644 --- a/kasa/interfaces/fan.py +++ b/kasa/interfaces/fan.py @@ -4,10 +4,10 @@ from abc import ABC, abstractmethod -from ..device import Device +from ..module import Module -class Fan(Device, ABC): +class Fan(Module, ABC): """Interface for a Fan.""" @property diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 141be1fdf..e956c805c 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -7,7 +7,7 @@ from pydantic.v1 import BaseModel -from ..device import Device +from ..module import Module class ColorTempRange(NamedTuple): @@ -42,12 +42,13 @@ class LightPreset(BaseModel): mode: Optional[int] # noqa: UP007 -class Light(Device, ABC): +class Light(Module, ABC): """Base class for TP-Link Light.""" - def _raise_for_invalid_brightness(self, value): - if not isinstance(value, int) or not (0 <= value <= 100): - raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") + @property + @abstractmethod + def is_dimmable(self) -> bool: + """Whether the light supports brightness changes.""" @property @abstractmethod @@ -98,7 +99,7 @@ async def set_hsv( value: int | None = None, *, transition: int | None = None, - ) -> dict: + ) -> None: """Set new HSV. Note, transition is not supported and will be ignored. @@ -112,7 +113,7 @@ async def set_hsv( @abstractmethod async def set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None - ) -> dict: + ) -> None: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -124,7 +125,7 @@ async def set_color_temp( @abstractmethod async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> dict: + ) -> None: """Set the brightness in percentage. Note, transition is not supported and will be ignored. @@ -132,8 +133,3 @@ async def set_brightness( :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - - @property - @abstractmethod - def presets(self) -> list[LightPreset]: - """Return a list of available bulb setting presets.""" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index f6135fd18..0de853a09 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -12,11 +12,21 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..feature import Feature -from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset +from ..interfaces.light import HSV, ColorTempRange, LightPreset from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update -from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage +from .modules import ( + Antitheft, + Brightness, + Cloud, + Countdown, + Emeter, + Light, + Schedule, + Time, + Usage, +) class BehaviorMode(str, Enum): @@ -88,7 +98,7 @@ class TurnOnBehaviors(BaseModel): _LOGGER = logging.getLogger(__name__) -class IotBulb(IotDevice, Light): +class IotBulb(IotDevice): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -199,6 +209,10 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb + + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() self.add_module( Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule") ) @@ -210,25 +224,13 @@ def __init__( self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) + if bool(self.sys_info["is_dimmable"]): # pragma: no branch + self.add_module(Module.Light, Light(self, "light")) + self.add_module(Module.Brightness, Brightness(self, "brightness")) async def _initialize_features(self): await super()._initialize_features() - if bool(self.sys_info["is_dimmable"]): # pragma: no branch - self._add_feature( - Feature( - device=self, - id="brightness", - name="Brightness", - attribute_getter="brightness", - attribute_setter="set_brightness", - minimum_value=1, - maximum_value=100, - type=Feature.Type.Number, - category=Feature.Category.Primary, - ) - ) - if self.is_variable_color_temp: self._add_feature( Feature( @@ -458,6 +460,10 @@ async def set_color_temp( return await self.set_light_state(light_state, transition=transition) + def _raise_for_invalid_brightness(self, value): + if not isinstance(value, int) or not (0 <= value <= 100): + raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") + @property # type: ignore @requires_update def brightness(self) -> int: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index e4c1bb13a..f3ac5321c 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -307,6 +307,9 @@ async def update(self, update_children: bool = True): self._last_update = response self._set_sys_info(response["system"]["get_sysinfo"]) + if not self._modules: + await self._initialize_modules() + await self._modular_update(req) if not self._features: @@ -314,6 +317,9 @@ async def update(self, update_children: bool = True): self._set_sys_info(self._last_update["system"]["get_sysinfo"]) + async def _initialize_modules(self): + """Initialize modules not added in init.""" + async def _initialize_features(self): self._add_feature( Feature( diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index fed9e7e79..747c35704 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -7,12 +7,11 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature from ..module import Module from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug -from .modules import AmbientLight, Motion +from .modules import AmbientLight, Brightness, Light, Motion class ButtonAction(Enum): @@ -80,29 +79,19 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Dimmer + + async def _initialize_modules(self): + """Initialize modules.""" + await super()._initialize_modules() # TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be figured out what's the best approach to detect support self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) - - async def _initialize_features(self): - await super()._initialize_features() - + self.add_module(Module.Light, Light(self, "light")) + self.add_module(Module.Brightness, Brightness(self, "brightness")) if "brightness" in self.sys_info: # pragma: no branch - self._add_feature( - Feature( - device=self, - id="brightness", - name="Brightness", - attribute_getter="brightness", - attribute_setter="set_brightness", - minimum_value=1, - maximum_value=100, - unit="%", - type=Feature.Type.Number, - category=Feature.Category.Primary, - ) - ) + self.add_module(Module.Light, Light(self, "light")) + self.add_module(Module.Brightness, Brightness(self, "brightness")) @property # type: ignore @requires_update diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 7cdbe43ba..6bc562583 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -56,6 +56,10 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip + + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() self.add_module( Module.LightEffect, LightEffect(self, "smartlife.iot.lighting_effect"), diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 6aace4f8a..072261783 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -53,6 +53,10 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug + + async def _initialize_modules(self): + """Initialize modules.""" + await super()._initialize_modules() self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotUsage, Usage(self, "schedule")) self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 4aa966e1f..c4dcc57f5 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -255,6 +255,10 @@ def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: self._set_sys_info(parent.sys_info) self._device_type = DeviceType.StripSocket self.protocol = parent.protocol # Must use the same connection as the parent + + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() self.add_module("time", Time(self, "time")) async def update(self, update_children: bool = True): diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index e0febfd41..5333ba226 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -2,10 +2,12 @@ from .ambientlight import AmbientLight from .antitheft import Antitheft +from .brightness import Brightness from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter from .led import Led +from .light import Light from .lighteffect import LightEffect from .motion import Motion from .rulemodule import Rule, RuleModule @@ -16,10 +18,12 @@ __all__ = [ "AmbientLight", "Antitheft", + "Brightness", "Cloud", "Countdown", "Emeter", "Led", + "Light", "LightEffect", "Motion", "Rule", diff --git a/kasa/iot/modules/brightness.py b/kasa/iot/modules/brightness.py new file mode 100644 index 000000000..9547c03cc --- /dev/null +++ b/kasa/iot/modules/brightness.py @@ -0,0 +1,42 @@ +"""Implementation of brightness module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...interfaces import Brightness as BrightnessInterface +from ..iotmodule import IotModule + +if TYPE_CHECKING: + from ..iotbulb import IotBulb + from ..iotdimmer import IotDimmer + + +BRIGHTNESS_MIN = 0 +BRIGHTNESS_MAX = 100 + + +class Brightness(IotModule, BrightnessInterface): + """Implementation of brightness module.""" + + _device: IotBulb | IotDimmer + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Brightness is contained in the main device info response. + return {} + + @property # type: ignore + def brightness(self) -> int: + """Return the current brightness in percentage.""" + return self._device.brightness + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> None: + """Set the brightness in percentage. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + await self._device.set_brightness(brightness, transition=transition) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py new file mode 100644 index 000000000..22084e498 --- /dev/null +++ b/kasa/iot/modules/light.py @@ -0,0 +1,137 @@ +"""Implementation of brightness module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from ...exceptions import KasaException +from ...interfaces.light import HSV, ColorTempRange +from ...interfaces.light import Light as LightInterface +from ..iotmodule import IotModule + +if TYPE_CHECKING: + from ..iotbulb import IotBulb + from ..iotdimmer import IotDimmer + + +BRIGHTNESS_MIN = 0 +BRIGHTNESS_MAX = 100 + + +class Light(IotModule, LightInterface): + """Implementation of brightness module.""" + + _device: IotBulb | IotDimmer + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Brightness is contained in the main device info response. + return {} + + def _get_bulb_device(self) -> IotBulb | None: + if self._device.is_bulb or self._device.is_light_strip: + return cast("IotBulb", self._device) + return None + + @property # type: ignore + def is_dimmable(self) -> int: + """Whether the bulb supports brightness changes.""" + return self._device.is_dimmable + + @property # type: ignore + def brightness(self) -> int: + """Return the current brightness in percentage.""" + return self._device.brightness + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> None: + """Set the brightness in percentage. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + return await self._device.set_brightness(brightness, transition=transition) + + @property + def is_color(self) -> bool: + """Whether the light supports color changes.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb.is_color + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb.is_variable_color_temp + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb.has_effects + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + raise KasaException("Light does not support color.") + return bulb.hsv + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> None: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + raise KasaException("Light does not support color.") + await bulb.set_hsv(hue, saturation, value, transition=transition) + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return bulb.valid_temperature_range + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return bulb.color_temp + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: int | None = None + ) -> None: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + await bulb.set_color_temp(temp, brightness=brightness, transition=transition) diff --git a/kasa/module.py b/kasa/module.py index 55eeea185..106f31450 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -15,9 +15,8 @@ from .modulemapping import ModuleName if TYPE_CHECKING: + from . import interfaces from .device import Device - from .interfaces.led import Led - from .interfaces.lighteffect import LightEffect from .iot import modules as iot from .smart import modules as smart @@ -34,8 +33,10 @@ class Module(ABC): """ # Common Modules - LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffect") - Led: Final[ModuleName[Led]] = ModuleName("Led") + LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") + Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") + Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") + Brightness: Final[ModuleName[interfaces.Brightness]] = ModuleName("Brightness") # IOT only Modules IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") @@ -52,7 +53,6 @@ class Module(ABC): Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm") AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") - Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") ChildDevice: Final[ModuleName[smart.ChildDevice]] = ModuleName("ChildDevice") Cloud: Final[ModuleName[smart.Cloud]] = ModuleName("Cloud") Color: Final[ModuleName[smart.Color]] = ModuleName("Color") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index e119e0675..b295bcb20 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -16,6 +16,7 @@ from .frostprotection import FrostProtection from .humiditysensor import HumiditySensor from .led import Led +from .light import Light from .lighteffect import LightEffect from .lighttransition import LightTransition from .reportmode import ReportMode @@ -41,6 +42,7 @@ "Fan", "Firmware", "Cloud", + "Light", "LightEffect", "LightTransition", "ColorTemperature", diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index b0b58c077..6099de23c 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -4,39 +4,18 @@ from typing import TYPE_CHECKING -from ...feature import Feature +from ...interfaces.brightness import Brightness as BrightnessInterface from ..smartmodule import SmartModule if TYPE_CHECKING: - from ..smartdevice import SmartDevice + pass -BRIGHTNESS_MIN = 1 -BRIGHTNESS_MAX = 100 - - -class Brightness(SmartModule): +class Brightness(SmartModule, BrightnessInterface): """Implementation of brightness module.""" REQUIRED_COMPONENT = "brightness" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) - self._add_feature( - Feature( - device, - id="brightness", - name="Brightness", - container=self, - attribute_getter="brightness", - attribute_setter="set_brightness", - minimum_value=BRIGHTNESS_MIN, - maximum_value=BRIGHTNESS_MAX, - type=Feature.Type.Number, - category=Feature.Category.Primary, - ) - ) - def query(self) -> dict: """Query to execute during the update cycle.""" # Brightness is contained in the main device info response. @@ -47,16 +26,21 @@ def brightness(self): """Return current brightness.""" return self.data["brightness"] - async def set_brightness(self, brightness: int): - """Set the brightness.""" + async def set_brightness(self, brightness: int, *, transition: int | None = None): + """Set the brightness. A brightness value of 0 will turn off the light. + + Note, transition is not supported and will be ignored. + """ if not isinstance(brightness, int) or not ( - BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX + self.BRIGHTNESS_MIN <= brightness <= self.BRIGHTNESS_MAX ): raise ValueError( f"Invalid brightness value: {brightness} " - f"(valid range: {BRIGHTNESS_MIN}-{BRIGHTNESS_MAX}%)" + f"(valid range: {self.BRIGHTNESS_MIN}-{self.BRIGHTNESS_MAX}%)" ) + if brightness == 0: + return await self._device.turn_off() return await self.call("set_device_info", {"brightness": brightness}) async def _check_supported(self): diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py new file mode 100644 index 000000000..31720486d --- /dev/null +++ b/kasa/smart/modules/light.py @@ -0,0 +1,126 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...exceptions import KasaException +from ...interfaces.light import HSV, ColorTempRange +from ...interfaces.light import Light as LightInterface +from ...module import Module +from ..smartmodule import SmartModule + + +class Light(SmartModule, LightInterface): + """Implementation of a light.""" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def is_color(self) -> bool: + """Whether the bulb supports color changes.""" + return Module.Color in self._device.modules + + @property + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + return Module.Brightness in self._device.modules + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + return Module.ColorTemperature in self._device.modules + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if not self.is_variable_color_temp: + raise KasaException("Color temperature not supported") + + return self._device.modules[Module.ColorTemperature].valid_temperature_range + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if not self.is_color: + raise KasaException("Bulb does not support color.") + + return self._device.modules[Module.Color].hsv + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if not self.is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + + return self._device.modules[Module.ColorTemperature].color_temp + + @property + def brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return self._device.modules[Module.Brightness].brightness + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> None: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value between 1 and 100 + :param int transition: transition in milliseconds. + """ + if not self.is_color: + raise KasaException("Bulb does not support color.") + + await self._device.modules[Module.Color].set_hsv(hue, saturation, value) + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: int | None = None + ) -> None: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + if not self.is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + await self._device.modules[Module.ColorTemperature].set_color_temp(temp) + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> None: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + await self._device.modules[Module.Brightness].set_brightness(brightness) + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return Module.LightEffect in self._device.modules diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e7b45c8e2..dc35b433e 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -14,8 +14,7 @@ from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature -from ..interfaces.fan import Fan -from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset +from ..interfaces.light import LightPreset from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol @@ -23,6 +22,7 @@ Cloud, DeviceModule, Firmware, + Light, Time, ) from .smartmodule import SmartModule @@ -39,7 +39,7 @@ # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. -class SmartDevice(Light, Fan, Device): +class SmartDevice(Device): """Base class to represent a SMART protocol based device.""" def __init__( @@ -231,6 +231,13 @@ async def _initialize_modules(self): if await module._check_supported(): self._modules[module.name] = module + if ( + Module.Brightness in self._modules + or Module.Color in self._modules + or Module.ColorTemperature in self._modules + ): + self._modules[Light.__name__] = Light(self, "light") + async def _initialize_features(self): """Initialize device features.""" self._add_feature( @@ -639,138 +646,7 @@ def _get_device_type_from_components( _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug - # Fan interface methods - - @property - def is_fan(self) -> bool: - """Return True if the device is a fan.""" - return Module.Fan in self.modules - - @property - def fan_speed_level(self) -> int: - """Return fan speed level.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - return self.modules[Module.Fan].fan_speed_level - - async def set_fan_speed_level(self, level: int): - """Set fan speed level.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - await self.modules[Module.Fan].set_fan_speed_level(level) - - # Bulb interface methods - - @property - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - return Module.Color in self.modules - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - return Module.Brightness in self.modules - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - return Module.ColorTemperature in self.modules - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if not self.is_variable_color_temp: - raise KasaException("Color temperature not supported") - - return self.modules[Module.ColorTemperature].valid_temperature_range - - @property - def hsv(self) -> HSV: - """Return the current HSV state of the bulb. - - :return: hue, saturation and value (degrees, %, %) - """ - if not self.is_color: - raise KasaException("Bulb does not support color.") - - return self.modules[Module.Color].hsv - - @property - def color_temp(self) -> int: - """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: - raise KasaException("Bulb does not support colortemp.") - - return self.modules[Module.ColorTemperature].color_temp - - @property - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return self.modules[Module.Brightness].brightness - - async def set_hsv( - self, - hue: int, - saturation: int, - value: int | None = None, - *, - transition: int | None = None, - ) -> dict: - """Set new HSV. - - Note, transition is not supported and will be ignored. - - :param int hue: hue in degrees - :param int saturation: saturation in percentage [0,100] - :param int value: value between 1 and 100 - :param int transition: transition in milliseconds. - """ - if not self.is_color: - raise KasaException("Bulb does not support color.") - - return await self.modules[Module.Color].set_hsv(hue, saturation, value) - - async def set_color_temp( - self, temp: int, *, brightness=None, transition: int | None = None - ) -> dict: - """Set the color temperature of the device in kelvin. - - Note, transition is not supported and will be ignored. - - :param int temp: The new color temperature, in Kelvin - :param int transition: transition in milliseconds. - """ - if not self.is_variable_color_temp: - raise KasaException("Bulb does not support colortemp.") - return await self.modules[Module.ColorTemperature].set_color_temp(temp) - - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> dict: - """Set the brightness in percentage. - - Note, transition is not supported and will be ignored. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return await self.modules[Module.Brightness].set_brightness(brightness) - @property def presets(self) -> list[LightPreset]: """Return a list of available bulb setting presets.""" return [] - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return Module.LightEffect in self.modules diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 826465e5e..e8fbeeece 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -203,14 +203,14 @@ def parametrize( "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} ) strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) -dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) -lightstrip = parametrize( +dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) +lightstrip_iot = parametrize( "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} ) # bulb types -dimmable = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) -non_dimmable = parametrize( +dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) +non_dimmable_iot = parametrize( "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} ) variable_temp = parametrize( @@ -292,12 +292,12 @@ def parametrize( def check_categories(): """Check that every fixture file is categorized.""" categorized_fixtures = set( - dimmer.args[1] + dimmer_iot.args[1] + strip.args[1] + plug.args[1] + bulb.args[1] + wallswitch.args[1] - + lightstrip.args[1] + + lightstrip_iot.args[1] + bulb_smart.args[1] + dimmers_smart.args[1] + hubs_smart.args[1] diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index 3c00a4d11..9e2901902 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -2,7 +2,7 @@ from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.tests.conftest import dimmable, parametrize +from kasa.tests.conftest import dimmable_iot, parametrize brightness = parametrize("brightness smart", component_filter="brightness") @@ -32,7 +32,7 @@ async def test_brightness_component(dev: SmartDevice): await feature.set_value(feature.maximum_value + 10) -@dimmable +@dimmable_iot async def test_brightness_dimmable(dev: IotDevice): """Test brightness feature.""" assert isinstance(dev, IotDevice) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 9597471b6..37889bbd0 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -52,7 +52,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): @fan -async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): +async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) fan = dev.modules.get(Module.Fan) @@ -60,21 +60,21 @@ async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): device = fan._device assert device.is_fan - await device.set_fan_speed_level(1) + await fan.set_fan_speed_level(1) await dev.update() - assert device.fan_speed_level == 1 + assert fan.fan_speed_level == 1 assert device.is_on - await device.set_fan_speed_level(4) + await fan.set_fan_speed_level(4) await dev.update() - assert device.fan_speed_level == 4 + assert fan.fan_speed_level == 4 - await device.set_fan_speed_level(0) + await fan.set_fan_speed_level(0) await dev.update() assert not device.is_on with pytest.raises(ValueError): - await device.set_fan_speed_level(-1) + await fan.set_fan_speed_level(-1) with pytest.raises(ValueError): - await device.set_fan_speed_level(5) + await fan.set_fan_speed_level(5) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 19400c836..5cfa25daa 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,19 +7,18 @@ Schema, ) -from kasa import Device, DeviceType, KasaException, Light, LightPreset +from kasa import Device, DeviceType, KasaException, LightPreset, Module from kasa.iot import IotBulb, IotDimmer -from kasa.smart import SmartDevice from .conftest import ( bulb, bulb_iot, color_bulb, color_bulb_iot, - dimmable, + dimmable_iot, handle_turn_on, non_color_bulb, - non_dimmable, + non_dimmable_iot, non_variable_temp, turn_on, variable_temp, @@ -65,19 +64,20 @@ async def test_get_light_state(dev: IotBulb): @color_bulb @turn_on async def test_hsv(dev: Device, turn_on): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - assert dev.is_color + assert light.is_color - hue, saturation, brightness = dev.hsv + hue, saturation, brightness = light.hsv assert 0 <= hue <= 360 assert 0 <= saturation <= 100 assert 0 <= brightness <= 100 - await dev.set_hsv(hue=1, saturation=1, value=1) + await light.set_hsv(hue=1, saturation=1, value=1) await dev.update() - hue, saturation, brightness = dev.hsv + hue, saturation, brightness = light.hsv assert hue == 1 assert saturation == 1 assert brightness == 1 @@ -96,57 +96,64 @@ async def test_set_hsv_transition(dev: IotBulb, mocker): @color_bulb @turn_on -async def test_invalid_hsv(dev: Light, turn_on): +async def test_invalid_hsv(dev: Device, turn_on): + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - assert dev.is_color + assert light.is_color for invalid_hue in [-1, 361, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type] + await light.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type] for invalid_saturation in [-1, 101, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type] + await light.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type] for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type] + await light.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type] @color_bulb @pytest.mark.skip("requires color feature") async def test_color_state_information(dev: Device): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light assert "HSV" in dev.state_information - assert dev.state_information["HSV"] == dev.hsv + assert dev.state_information["HSV"] == light.hsv @non_color_bulb -async def test_hsv_on_non_color(dev: Light): - assert not dev.is_color +async def test_hsv_on_non_color(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert not light.is_color with pytest.raises(KasaException): - await dev.set_hsv(0, 0, 0) + await light.set_hsv(0, 0, 0) with pytest.raises(KasaException): - print(dev.hsv) + print(light.hsv) @variable_temp @pytest.mark.skip("requires colortemp module") async def test_variable_temp_state_information(dev: Device): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light assert "Color temperature" in dev.state_information - assert dev.state_information["Color temperature"] == dev.color_temp + assert dev.state_information["Color temperature"] == light.color_temp @variable_temp @turn_on async def test_try_set_colortemp(dev: Device, turn_on): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - await dev.set_color_temp(2700) + await light.set_color_temp(2700) await dev.update() - assert dev.color_temp == 2700 + assert light.color_temp == 2700 @variable_temp_iot @@ -166,34 +173,40 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): @variable_temp_smart -async def test_smart_temp_range(dev: SmartDevice): - assert dev.valid_temperature_range +async def test_smart_temp_range(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert light.valid_temperature_range @variable_temp -async def test_out_of_range_temperature(dev: Light): +async def test_out_of_range_temperature(dev: Device): + light = dev.modules.get(Module.Light) + assert light with pytest.raises(ValueError): - await dev.set_color_temp(1000) + await light.set_color_temp(1000) with pytest.raises(ValueError): - await dev.set_color_temp(10000) + await light.set_color_temp(10000) @non_variable_temp -async def test_non_variable_temp(dev: Light): +async def test_non_variable_temp(dev: Device): + light = dev.modules.get(Module.Light) + assert light with pytest.raises(KasaException): - await dev.set_color_temp(2700) + await light.set_color_temp(2700) with pytest.raises(KasaException): - print(dev.valid_temperature_range) + print(light.valid_temperature_range) with pytest.raises(KasaException): - print(dev.color_temp) + print(light.color_temp) -@dimmable +@dimmable_iot @turn_on -async def test_dimmable_brightness(dev: Device, turn_on): - assert isinstance(dev, (Light, IotDimmer)) +async def test_dimmable_brightness(dev: IotBulb, turn_on): + assert isinstance(dev, (IotBulb, IotDimmer)) await handle_turn_on(dev, turn_on) assert dev.is_dimmable @@ -229,8 +242,8 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): set_light_state.assert_called_with({"brightness": 10}, transition=1000) -@dimmable -async def test_invalid_brightness(dev: Light): +@dimmable_iot +async def test_invalid_brightness(dev: IotBulb): assert dev.is_dimmable with pytest.raises(ValueError): @@ -240,8 +253,8 @@ async def test_invalid_brightness(dev: Light): await dev.set_brightness(-100) -@non_dimmable -async def test_non_dimmable(dev: Light): +@non_dimmable_iot +async def test_non_dimmable(dev: IotBulb): assert not dev.is_dimmable with pytest.raises(KasaException): @@ -380,7 +393,7 @@ async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker): @bulb -def test_device_type_bulb(dev): +def test_device_type_bulb(dev: Device): if dev.is_light_strip: pytest.skip("bulb has also lightstrips to test the api") assert dev.device_type == DeviceType.Bulb diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 8f7def957..a1885ac70 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -3,7 +3,9 @@ from kasa import Device, Module from kasa.tests.device_fixtures import ( - lightstrip, + dimmable_iot, + dimmer_iot, + lightstrip_iot, parametrize, parametrize_combine, plug_iot, @@ -17,7 +19,12 @@ light_effect_smart = parametrize( "has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} ) -light_effect = parametrize_combine([light_effect_smart, lightstrip]) +light_effect = parametrize_combine([light_effect_smart, lightstrip_iot]) + +dimmable_smart = parametrize( + "dimmable smart", component_filter="brightness", protocol_filter={"SMART"} +) +dimmable_iot = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) @led @@ -93,3 +100,26 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): with pytest.raises(ValueError): await light_effect_module.set_effect("foobar") assert call.call_count == 4 + + +@dimmable_iot +async def test_light_brightness(dev: Device): + """Test brightness setter and getter.""" + assert isinstance(dev, Device) + brightness = dev.modules.get(Module.Brightness) + assert brightness + + # Test getting the value + feature = brightness._module_features["brightness"] + assert feature.minimum_value == 0 + assert feature.maximum_value == 100 + + await brightness.set_brightness(10) + await dev.update() + assert brightness.brightness == 10 + + with pytest.raises(ValueError): + await brightness.set_brightness(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await brightness.set_brightness(feature.maximum_value + 10) diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 6399ca4f6..06150d394 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -3,10 +3,10 @@ from kasa import DeviceType from kasa.iot import IotDimmer -from .conftest import dimmer, handle_turn_on, turn_on +from .conftest import dimmer_iot, handle_turn_on, turn_on -@dimmer +@dimmer_iot @turn_on async def test_set_brightness(dev, turn_on): await handle_turn_on(dev, turn_on) @@ -22,7 +22,7 @@ async def test_set_brightness(dev, turn_on): assert dev.is_on == turn_on -@dimmer +@dimmer_iot @turn_on async def test_set_brightness_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -44,7 +44,7 @@ async def test_set_brightness_transition(dev, turn_on, mocker): assert dev.brightness == 1 -@dimmer +@dimmer_iot async def test_set_brightness_invalid(dev): for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): @@ -55,7 +55,7 @@ async def test_set_brightness_invalid(dev): await dev.set_brightness(1, transition=invalid_transition) -@dimmer +@dimmer_iot async def test_turn_on_transition(dev, mocker): query_helper = mocker.spy(IotDimmer, "_query_helper") original_brightness = dev.brightness @@ -72,7 +72,7 @@ async def test_turn_on_transition(dev, mocker): assert dev.brightness == original_brightness -@dimmer +@dimmer_iot async def test_turn_off_transition(dev, mocker): await handle_turn_on(dev, True) query_helper = mocker.spy(IotDimmer, "_query_helper") @@ -90,7 +90,7 @@ async def test_turn_off_transition(dev, mocker): ) -@dimmer +@dimmer_iot @turn_on async def test_set_dimmer_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -108,7 +108,7 @@ async def test_set_dimmer_transition(dev, turn_on, mocker): assert dev.brightness == 99 -@dimmer +@dimmer_iot @turn_on async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -127,7 +127,7 @@ async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): ) -@dimmer +@dimmer_iot async def test_set_dimmer_transition_invalid(dev): for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): @@ -138,6 +138,6 @@ async def test_set_dimmer_transition_invalid(dev): await dev.set_dimmer_transition(1, invalid_transition) -@dimmer +@dimmer_iot def test_device_type_dimmer(dev): assert dev.device_type == DeviceType.Dimmer diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index eb0391444..2dea2004d 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -26,8 +26,8 @@ from .conftest import ( bulb_iot, - dimmer, - lightstrip, + dimmer_iot, + lightstrip_iot, new_discovery, plug_iot, strip_iot, @@ -86,14 +86,14 @@ async def test_type_detection_strip(dev: Device): assert d.device_type == DeviceType.Strip -@dimmer +@dimmer_iot async def test_type_detection_dimmer(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_dimmer assert d.device_type == DeviceType.Dimmer -@lightstrip +@lightstrip_iot async def test_type_detection_lightstrip(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_light_strip diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index f51f1805c..41fdcde15 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -3,24 +3,24 @@ from kasa import DeviceType from kasa.iot import IotLightStrip -from .conftest import lightstrip +from .conftest import lightstrip_iot -@lightstrip +@lightstrip_iot async def test_lightstrip_length(dev: IotLightStrip): assert dev.is_light_strip assert dev.device_type == DeviceType.LightStrip assert dev.length == dev.sys_info["length"] -@lightstrip +@lightstrip_iot async def test_lightstrip_effect(dev: IotLightStrip): assert isinstance(dev.effect, dict) for k in ["brightness", "custom", "enable", "id", "name"]: assert k in dev.effect -@lightstrip +@lightstrip_iot async def test_effects_lightstrip_set_effect(dev: IotLightStrip): with pytest.raises(ValueError): await dev.set_effect("Not real") @@ -30,7 +30,7 @@ async def test_effects_lightstrip_set_effect(dev: IotLightStrip): assert dev.effect["name"] == "Candy Cane" -@lightstrip +@lightstrip_iot @pytest.mark.parametrize("brightness", [100, 50]) async def test_effects_lightstrip_set_effect_brightness( dev: IotLightStrip, brightness, mocker @@ -48,7 +48,7 @@ async def test_effects_lightstrip_set_effect_brightness( assert payload["brightness"] == brightness -@lightstrip +@lightstrip_iot @pytest.mark.parametrize("transition", [500, 1000]) async def test_effects_lightstrip_set_effect_transition( dev: IotLightStrip, transition, mocker @@ -66,12 +66,12 @@ async def test_effects_lightstrip_set_effect_transition( assert payload["transition"] == transition -@lightstrip +@lightstrip_iot async def test_effects_lightstrip_has_effects(dev: IotLightStrip): assert dev.has_effects is True assert dev.effect_list -@lightstrip +@lightstrip_iot def test_device_type_lightstrip(dev): assert dev.device_type == DeviceType.LightStrip diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index ed9e57212..c4a4685a3 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -14,7 +14,6 @@ from kasa.smart import SmartDevice from .conftest import ( - bulb_smart, device_smart, get_device_for_fixture_protocol, ) @@ -159,28 +158,6 @@ async def test_get_modules(): assert module is None -@bulb_smart -async def test_smartdevice_brightness(dev: SmartDevice): - """Test brightness setter and getter.""" - assert isinstance(dev, SmartDevice) - assert "brightness" in dev._components - - # Test getting the value - feature = dev.features["brightness"] - assert feature.minimum_value == 1 - assert feature.maximum_value == 100 - - await dev.set_brightness(10) - await dev.update() - assert dev.brightness == 10 - - with pytest.raises(ValueError): - await dev.set_brightness(feature.minimum_value - 10) - - with pytest.raises(ValueError): - await dev.set_brightness(feature.maximum_value + 10) - - @device_smart async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture): """Test is_cloud_connected property.""" From 60232368cca6671f27d670b39f090f0723124a7e Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Mon, 13 May 2024 15:29:43 +0100 Subject: [PATCH 2/3] Remove common brightness interface --- kasa/interfaces/__init__.py | 2 - kasa/interfaces/brightness.py | 48 --------------- kasa/interfaces/light.py | 6 +- kasa/iot/iotbulb.py | 24 +------- kasa/iot/iotdimmer.py | 6 +- kasa/iot/modules/__init__.py | 2 - kasa/iot/modules/brightness.py | 42 ------------- kasa/iot/modules/light.py | 61 +++++++++++++++++-- kasa/module.py | 2 +- kasa/smart/modules/brightness.py | 34 ++++++++--- kasa/smart/modules/light.py | 12 ++-- kasa/smart/smartdevice.py | 7 ++- kasa/tests/smart/modules/test_contact.py | 2 +- kasa/tests/smart/modules/test_humidity.py | 2 +- kasa/tests/smart/modules/test_light_effect.py | 2 +- kasa/tests/smart/modules/test_temperature.py | 4 +- .../smart/modules/test_temperaturecontrol.py | 2 +- kasa/tests/smart/modules/test_waterleak.py | 2 +- kasa/tests/test_common_modules.py | 22 +++---- 19 files changed, 117 insertions(+), 165 deletions(-) delete mode 100644 kasa/interfaces/brightness.py delete mode 100644 kasa/iot/modules/brightness.py diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index 227fb782c..d8d089c5c 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -1,13 +1,11 @@ """Package for interfaces.""" -from .brightness import Brightness from .fan import Fan from .led import Led from .light import Light, LightPreset from .lighteffect import LightEffect __all__ = [ - "Brightness", "Fan", "Led", "Light", diff --git a/kasa/interfaces/brightness.py b/kasa/interfaces/brightness.py deleted file mode 100644 index 3cb23a909..000000000 --- a/kasa/interfaces/brightness.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Module for base light effect module.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - -from ..feature import Feature -from ..module import Module - - -class Brightness(Module, ABC): - """Base interface to represent a Brightness module.""" - - BRIGHTNESS_MIN = 0 - BRIGHTNESS_MAX = 100 - - def _initialize_features(self): - """Initialize features.""" - device = self._device - self._add_feature( - Feature( - device, - id="brightness", - name="Brightness", - container=self, - attribute_getter="brightness", - attribute_setter="set_brightness", - minimum_value=self.BRIGHTNESS_MIN, - maximum_value=self.BRIGHTNESS_MAX, - type=Feature.Type.Number, - category=Feature.Category.Primary, - ) - ) - - @property - @abstractmethod - def brightness(self) -> int: - """Return current brightness in percentage.""" - - @abstractmethod - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> None: - """Set the brightness in percentage. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index e956c805c..3a8805c10 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -99,7 +99,7 @@ async def set_hsv( value: int | None = None, *, transition: int | None = None, - ) -> None: + ) -> dict: """Set new HSV. Note, transition is not supported and will be ignored. @@ -113,7 +113,7 @@ async def set_hsv( @abstractmethod async def set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None - ) -> None: + ) -> dict: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -125,7 +125,7 @@ async def set_color_temp( @abstractmethod async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> None: + ) -> dict: """Set the brightness in percentage. Note, transition is not supported and will be ignored. diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 0de853a09..e2d860432 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -11,14 +11,12 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature from ..interfaces.light import HSV, ColorTempRange, LightPreset from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update from .modules import ( Antitheft, - Brightness, Cloud, Countdown, Emeter, @@ -224,27 +222,7 @@ async def _initialize_modules(self): self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) - if bool(self.sys_info["is_dimmable"]): # pragma: no branch - self.add_module(Module.Light, Light(self, "light")) - self.add_module(Module.Brightness, Brightness(self, "brightness")) - - async def _initialize_features(self): - await super()._initialize_features() - - if self.is_variable_color_temp: - self._add_feature( - Feature( - device=self, - id="color_temperature", - name="Color temperature", - container=self, - attribute_getter="color_temp", - attribute_setter="set_color_temp", - range_getter="valid_temperature_range", - category=Feature.Category.Primary, - type=Feature.Type.Number, - ) - ) + self.add_module(Module.Light, Light(self, "light")) @property # type: ignore @requires_update diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 747c35704..d6f49c246 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -11,7 +11,7 @@ from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug -from .modules import AmbientLight, Brightness, Light, Motion +from .modules import AmbientLight, Light, Motion class ButtonAction(Enum): @@ -88,10 +88,6 @@ async def _initialize_modules(self): self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) self.add_module(Module.Light, Light(self, "light")) - self.add_module(Module.Brightness, Brightness(self, "brightness")) - if "brightness" in self.sys_info: # pragma: no branch - self.add_module(Module.Light, Light(self, "light")) - self.add_module(Module.Brightness, Brightness(self, "brightness")) @property # type: ignore @requires_update diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 5333ba226..2d6f6a01e 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -2,7 +2,6 @@ from .ambientlight import AmbientLight from .antitheft import Antitheft -from .brightness import Brightness from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter @@ -18,7 +17,6 @@ __all__ = [ "AmbientLight", "Antitheft", - "Brightness", "Cloud", "Countdown", "Emeter", diff --git a/kasa/iot/modules/brightness.py b/kasa/iot/modules/brightness.py deleted file mode 100644 index 9547c03cc..000000000 --- a/kasa/iot/modules/brightness.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Implementation of brightness module.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from ...interfaces import Brightness as BrightnessInterface -from ..iotmodule import IotModule - -if TYPE_CHECKING: - from ..iotbulb import IotBulb - from ..iotdimmer import IotDimmer - - -BRIGHTNESS_MIN = 0 -BRIGHTNESS_MAX = 100 - - -class Brightness(IotModule, BrightnessInterface): - """Implementation of brightness module.""" - - _device: IotBulb | IotDimmer - - def query(self) -> dict: - """Query to execute during the update cycle.""" - # Brightness is contained in the main device info response. - return {} - - @property # type: ignore - def brightness(self) -> int: - """Return the current brightness in percentage.""" - return self._device.brightness - - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> None: - """Set the brightness in percentage. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ - await self._device.set_brightness(brightness, transition=transition) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 22084e498..63d8e5a49 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, cast from ...exceptions import KasaException +from ...feature import Feature from ...interfaces.light import HSV, ColorTempRange from ...interfaces.light import Light as LightInterface from ..iotmodule import IotModule @@ -23,6 +24,54 @@ class Light(IotModule, LightInterface): _device: IotBulb | IotDimmer + def _initialize_features(self): + """Initialize features.""" + super()._initialize_features() + device = self._device + + if self._device.is_dimmable: + self._add_feature( + Feature( + device, + id="brightness", + name="Brightness", + container=self, + attribute_getter="brightness", + attribute_setter="set_brightness", + minimum_value=BRIGHTNESS_MIN, + maximum_value=BRIGHTNESS_MAX, + type=Feature.Type.Number, + category=Feature.Category.Primary, + ) + ) + if self._device.is_variable_color_temp: + self._add_feature( + Feature( + device=self, + id="color_temperature", + name="Color temperature", + container=self, + attribute_getter="color_temp", + attribute_setter="set_color_temp", + range_getter="valid_temperature_range", + category=Feature.Category.Primary, + type=Feature.Type.Number, + ) + ) + if self._device.is_color: + self._add_feature( + Feature( + device, + "hsv", + "HSV", + container=self, + attribute_getter="hsv", + attribute_setter="set_hsv", + # TODO proper type for setting hsv + type=Feature.Type.Unknown, + ) + ) + def query(self) -> dict: """Query to execute during the update cycle.""" # Brightness is contained in the main device info response. @@ -45,7 +94,7 @@ def brightness(self) -> int: async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> None: + ) -> dict: """Set the brightness in percentage. :param int brightness: brightness in percent @@ -91,7 +140,7 @@ async def set_hsv( value: int | None = None, *, transition: int | None = None, - ) -> None: + ) -> dict: """Set new HSV. Note, transition is not supported and will be ignored. @@ -103,7 +152,7 @@ async def set_hsv( """ if (bulb := self._get_bulb_device()) is None or not bulb.is_color: raise KasaException("Light does not support color.") - await bulb.set_hsv(hue, saturation, value, transition=transition) + return await bulb.set_hsv(hue, saturation, value, transition=transition) @property def valid_temperature_range(self) -> ColorTempRange: @@ -124,7 +173,7 @@ def color_temp(self) -> int: async def set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None - ) -> None: + ) -> dict: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -134,4 +183,6 @@ async def set_color_temp( """ if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: raise KasaException("Light does not support colortemp.") - await bulb.set_color_temp(temp, brightness=brightness, transition=transition) + return await bulb.set_color_temp( + temp, brightness=brightness, transition=transition + ) diff --git a/kasa/module.py b/kasa/module.py index 106f31450..9b541ce04 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -36,7 +36,6 @@ class Module(ABC): LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") - Brightness: Final[ModuleName[interfaces.Brightness]] = ModuleName("Brightness") # IOT only Modules IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") @@ -53,6 +52,7 @@ class Module(ABC): Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm") AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") + Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") ChildDevice: Final[ModuleName[smart.ChildDevice]] = ModuleName("ChildDevice") Cloud: Final[ModuleName[smart.Cloud]] = ModuleName("Cloud") Color: Final[ModuleName[smart.Color]] = ModuleName("Color") diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index 6099de23c..fbd908083 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -2,20 +2,38 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from ...interfaces.brightness import Brightness as BrightnessInterface +from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - pass +BRIGHTNESS_MIN = 0 +BRIGHTNESS_MAX = 100 -class Brightness(SmartModule, BrightnessInterface): +class Brightness(SmartModule): """Implementation of brightness module.""" REQUIRED_COMPONENT = "brightness" + def _initialize_features(self): + """Initialize features.""" + super()._initialize_features() + + device = self._device + self._add_feature( + Feature( + device, + id="brightness", + name="Brightness", + container=self, + attribute_getter="brightness", + attribute_setter="set_brightness", + minimum_value=BRIGHTNESS_MIN, + maximum_value=BRIGHTNESS_MAX, + type=Feature.Type.Number, + category=Feature.Category.Primary, + ) + ) + def query(self) -> dict: """Query to execute during the update cycle.""" # Brightness is contained in the main device info response. @@ -32,11 +50,11 @@ async def set_brightness(self, brightness: int, *, transition: int | None = None Note, transition is not supported and will be ignored. """ if not isinstance(brightness, int) or not ( - self.BRIGHTNESS_MIN <= brightness <= self.BRIGHTNESS_MAX + BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX ): raise ValueError( f"Invalid brightness value: {brightness} " - f"(valid range: {self.BRIGHTNESS_MIN}-{self.BRIGHTNESS_MAX}%)" + f"(valid range: {BRIGHTNESS_MIN}-{BRIGHTNESS_MAX}%)" ) if brightness == 0: diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 31720486d..88d6486bc 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -76,7 +76,7 @@ async def set_hsv( value: int | None = None, *, transition: int | None = None, - ) -> None: + ) -> dict: """Set new HSV. Note, transition is not supported and will be ignored. @@ -89,11 +89,11 @@ async def set_hsv( if not self.is_color: raise KasaException("Bulb does not support color.") - await self._device.modules[Module.Color].set_hsv(hue, saturation, value) + return await self._device.modules[Module.Color].set_hsv(hue, saturation, value) async def set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None - ) -> None: + ) -> dict: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -103,11 +103,11 @@ async def set_color_temp( """ if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - await self._device.modules[Module.ColorTemperature].set_color_temp(temp) + return await self._device.modules[Module.ColorTemperature].set_color_temp(temp) async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> None: + ) -> dict: """Set the brightness in percentage. Note, transition is not supported and will be ignored. @@ -118,7 +118,7 @@ async def set_brightness( if not self.is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") - await self._device.modules[Module.Brightness].set_brightness(brightness) + return await self._device.modules[Module.Brightness].set_brightness(brightness) @property def has_effects(self) -> bool: diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index dc35b433e..e1939c70b 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -325,8 +325,11 @@ async def _initialize_features(self): ) ) - for module in self._modules.values(): - module._initialize_features() + for module in self.modules.values(): + # Check if module features have already been initialized. + # i.e. when _exposes_child_modules is true + if not module._module_features: + module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py index 88677c58f..11440871e 100644 --- a/kasa/tests/smart/modules/test_contact.py +++ b/kasa/tests/smart/modules/test_contact.py @@ -23,6 +23,6 @@ async def test_contact_features(dev: SmartDevice, feature, type): prop = getattr(contact, feature) assert isinstance(prop, type) - feat = contact._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_humidity.py b/kasa/tests/smart/modules/test_humidity.py index bf746f2b8..790393e5d 100644 --- a/kasa/tests/smart/modules/test_humidity.py +++ b/kasa/tests/smart/modules/test_humidity.py @@ -23,6 +23,6 @@ async def test_humidity_features(dev, feature, type): prop = getattr(humidity, feature) assert isinstance(prop, type) - feat = humidity._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index 56c3f0960..ed691e664 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -20,7 +20,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture): light_effect = dev.modules.get(Module.LightEffect) assert isinstance(light_effect, LightEffect) - feature = light_effect._module_features["light_effect"] + feature = dev.features["light_effect"] assert feature.type == Feature.Type.Choice call = mocker.spy(light_effect, "call") diff --git a/kasa/tests/smart/modules/test_temperature.py b/kasa/tests/smart/modules/test_temperature.py index a7d20dac6..c9685b9d7 100644 --- a/kasa/tests/smart/modules/test_temperature.py +++ b/kasa/tests/smart/modules/test_temperature.py @@ -29,7 +29,7 @@ async def test_temperature_features(dev, feature, type): prop = getattr(temp_module, feature) assert isinstance(prop, type) - feat = temp_module._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) @@ -42,6 +42,6 @@ async def test_temperature_warning(dev): assert hasattr(temp_module, "temperature_warning") assert isinstance(temp_module.temperature_warning, bool) - feat = temp_module._module_features["temperature_warning"] + feat = dev.features["temperature_warning"] assert feat.value == temp_module.temperature_warning assert isinstance(feat.value, bool) diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 4154cbf89..16e01ed2b 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -28,7 +28,7 @@ async def test_temperature_control_features(dev, feature, type): prop = getattr(temp_module, feature) assert isinstance(prop, type) - feat = temp_module._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_waterleak.py b/kasa/tests/smart/modules/test_waterleak.py index aa589e447..615361934 100644 --- a/kasa/tests/smart/modules/test_waterleak.py +++ b/kasa/tests/smart/modules/test_waterleak.py @@ -25,7 +25,7 @@ async def test_waterleak_properties(dev, feature, prop_name, type): prop = getattr(waterleak, prop_name) assert isinstance(prop, type) - feat = waterleak._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index a1885ac70..b07d8d988 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -24,7 +24,7 @@ dimmable_smart = parametrize( "dimmable smart", component_filter="brightness", protocol_filter={"SMART"} ) -dimmable_iot = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) +dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) @led @@ -32,7 +32,7 @@ async def test_led_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" led_module = dev.modules.get(Module.Led) assert led_module - feat = led_module._module_features["led"] + feat = dev.features["led"] call = mocker.spy(led_module, "call") await led_module.set_led(True) @@ -59,7 +59,7 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" light_effect_module = dev.modules[Module.LightEffect] assert light_effect_module - feat = light_effect_module._module_features["light_effect"] + feat = dev.features["light_effect"] call = mocker.spy(light_effect_module, "call") effect_list = light_effect_module.effect_list @@ -102,24 +102,24 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): assert call.call_count == 4 -@dimmable_iot +@dimmable async def test_light_brightness(dev: Device): """Test brightness setter and getter.""" assert isinstance(dev, Device) - brightness = dev.modules.get(Module.Brightness) - assert brightness + light = dev.modules.get(Module.Light) + assert light # Test getting the value - feature = brightness._module_features["brightness"] + feature = dev.features["brightness"] assert feature.minimum_value == 0 assert feature.maximum_value == 100 - await brightness.set_brightness(10) + await light.set_brightness(10) await dev.update() - assert brightness.brightness == 10 + assert light.brightness == 10 with pytest.raises(ValueError): - await brightness.set_brightness(feature.minimum_value - 10) + await light.set_brightness(feature.minimum_value - 10) with pytest.raises(ValueError): - await brightness.set_brightness(feature.maximum_value + 10) + await light.set_brightness(feature.maximum_value + 10) From f27b7931ee18f591b715b76edb24f6f1a4c3a27d Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Mon, 13 May 2024 16:40:33 +0100 Subject: [PATCH 3/3] Fix tests --- kasa/iot/modules/light.py | 8 ++++---- kasa/tests/smart/features/test_brightness.py | 2 +- kasa/tests/smart/modules/test_fan.py | 4 ++-- kasa/tests/smart/modules/test_firmware.py | 2 +- kasa/tests/test_feature.py | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 63d8e5a49..89243a1b5 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -47,7 +47,7 @@ def _initialize_features(self): if self._device.is_variable_color_temp: self._add_feature( Feature( - device=self, + device=device, id="color_temperature", name="Color temperature", container=self, @@ -61,9 +61,9 @@ def _initialize_features(self): if self._device.is_color: self._add_feature( Feature( - device, - "hsv", - "HSV", + device=device, + id="hsv", + name="HSV", container=self, attribute_getter="hsv", attribute_setter="set_hsv", diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index 9e2901902..e3c3c5303 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -16,7 +16,7 @@ async def test_brightness_component(dev: SmartDevice): assert "brightness" in dev._components # Test getting the value - feature = brightness._module_features["brightness"] + feature = dev.features["brightness"] assert isinstance(feature.value, int) assert feature.value > 1 and feature.value <= 100 diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 37889bbd0..b9627d9fa 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -14,7 +14,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): fan = dev.modules.get(Module.Fan) assert fan - level_feature = fan._module_features["fan_speed_level"] + level_feature = dev.features["fan_speed_level"] assert ( level_feature.minimum_value <= level_feature.value @@ -38,7 +38,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" fan = dev.modules.get(Module.Fan) assert fan - sleep_feature = fan._module_features["fan_sleep_mode"] + sleep_feature = dev.features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) call = mocker.spy(fan, "call") diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index 8f329f708..b592041f4 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/kasa/tests/smart/modules/test_firmware.py @@ -43,7 +43,7 @@ async def test_firmware_features( prop = getattr(fw, prop_name) assert isinstance(prop, type) - feat = fw._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index 101a21c0a..0fb7156d2 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -1,5 +1,6 @@ import logging import sys +from unittest.mock import patch import pytest from pytest_mock import MockerFixture @@ -180,11 +181,10 @@ async def _test_feature(feat, query_mock): async def _test_features(dev): exceptions = [] - query = mocker.patch.object(dev.protocol, "query") for feat in dev.features.values(): - query.reset_mock() try: - await _test_feature(feat, query) + with patch.object(feat.device.protocol, "query") as query: + await _test_feature(feat, query) # we allow our own exceptions to avoid mocking valid responses except KasaException: pass