From 33d839866ec1d1d83a24399fcc0f723e4deb2c43 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 13 May 2024 17:34:44 +0100 Subject: [PATCH] Make Light and Fan a common module interface (#911) --- kasa/interfaces/fan.py | 4 +- kasa/interfaces/light.py | 16 +- kasa/iot/iotbulb.py | 58 ++---- kasa/iot/iotdevice.py | 6 + kasa/iot/iotdimmer.py | 27 +-- kasa/iot/iotlightstrip.py | 4 + kasa/iot/iotplug.py | 4 + kasa/iot/iotstrip.py | 4 + kasa/iot/modules/__init__.py | 2 + kasa/iot/modules/light.py | 188 ++++++++++++++++++ kasa/module.py | 8 +- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/brightness.py | 24 ++- kasa/smart/modules/light.py | 126 ++++++++++++ kasa/smart/smartdevice.py | 151 ++------------ kasa/tests/device_fixtures.py | 12 +- kasa/tests/smart/features/test_brightness.py | 6 +- kasa/tests/smart/modules/test_contact.py | 2 +- kasa/tests/smart/modules/test_fan.py | 20 +- kasa/tests/smart/modules/test_firmware.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_bulb.py | 97 +++++---- kasa/tests/test_common_modules.py | 38 +++- kasa/tests/test_dimmer.py | 20 +- kasa/tests/test_discovery.py | 8 +- kasa/tests/test_feature.py | 6 +- kasa/tests/test_lightstrip.py | 16 +- kasa/tests/test_smartdevice.py | 23 --- 32 files changed, 544 insertions(+), 342 deletions(-) create mode 100644 kasa/iot/modules/light.py create mode 100644 kasa/smart/modules/light.py diff --git a/kasa/interfaces/fan.py b/kasa/interfaces/fan.py index 767fe89f..89d8d82b 100644 --- a/kasa/interfaces/fan.py +++ b/kasa/interfaces/fan.py @@ -4,10 +4,10 @@ from __future__ import annotations 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 141be1fd..3a8805c1 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -7,7 +7,7 @@ from typing import NamedTuple, Optional 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 @@ -132,8 +133,3 @@ class Light(Device, ABC): :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 f6135fd1..e2d86043 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -11,12 +11,20 @@ from pydantic.v1 import BaseModel, Field, root_validator 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, + Cloud, + Countdown, + Emeter, + Light, + Schedule, + Time, + Usage, +) class BehaviorMode(str, Enum): @@ -88,7 +96,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"} _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 +207,10 @@ class IotBulb(IotDevice, Light): ) -> 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,39 +222,7 @@ class IotBulb(IotDevice, Light): 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")) - - 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( - 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 @@ -458,6 +438,10 @@ class IotBulb(IotDevice, Light): 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 e4c1bb13..f3ac5321 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -307,6 +307,9 @@ class IotDevice(Device): 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 @@ class IotDevice(Device): 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 fed9e7e7..d6f49c24 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -7,12 +7,11 @@ from typing import Any 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, Light, Motion class ButtonAction(Enum): @@ -80,29 +79,15 @@ class IotDimmer(IotPlug): ) -> 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() - - 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")) @property # type: ignore @requires_update diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 7cdbe43b..6bc56258 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -56,6 +56,10 @@ class IotLightStrip(IotBulb): ) -> 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 6aace4f8..07226178 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -53,6 +53,10 @@ class IotPlug(IotDevice): ) -> 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 4aa966e1..c4dcc57f 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -255,6 +255,10 @@ class IotStripPlug(IotPlug): 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 e0febfd4..2d6f6a01 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -6,6 +6,7 @@ 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 @@ -20,6 +21,7 @@ __all__ = [ "Countdown", "Emeter", "Led", + "Light", "LightEffect", "Motion", "Rule", diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py new file mode 100644 index 00000000..89243a1b --- /dev/null +++ b/kasa/iot/modules/light.py @@ -0,0 +1,188 @@ +"""Implementation of brightness module.""" + +from __future__ import annotations + +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 + +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 _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=device, + 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=device, + id="hsv", + name="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. + 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 + ) -> dict: + """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, + ) -> 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 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.") + return 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 + ) -> 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 (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return await bulb.set_color_temp( + temp, brightness=brightness, transition=transition + ) diff --git a/kasa/module.py b/kasa/module.py index 55eeea18..9b541ce0 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -15,9 +15,8 @@ from .feature import Feature 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,9 @@ 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") # IOT only Modules IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index e119e067..b295bcb2 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -16,6 +16,7 @@ from .firmware import Firmware 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 @@ __all__ = [ "Fan", "Firmware", "Cloud", + "Light", "LightEffect", "LightTransition", "ColorTemperature", diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index b0b58c07..fbd90808 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -2,16 +2,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - -BRIGHTNESS_MIN = 1 +BRIGHTNESS_MIN = 0 BRIGHTNESS_MAX = 100 @@ -20,8 +14,11 @@ class Brightness(SmartModule): REQUIRED_COMPONENT = "brightness" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features.""" + super()._initialize_features() + + device = self._device self._add_feature( Feature( device, @@ -47,8 +44,11 @@ class Brightness(SmartModule): """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 ): @@ -57,6 +57,8 @@ class Brightness(SmartModule): f"(valid range: {BRIGHTNESS_MIN}-{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 00000000..88d6486b --- /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, + ) -> 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._device.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._device.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._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 e7b45c8e..e1939c70 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -14,8 +14,7 @@ from ..deviceconfig import DeviceConfig 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 @@ from .modules import ( Cloud, DeviceModule, Firmware, + Light, Time, ) from .smartmodule import SmartModule @@ -39,7 +39,7 @@ WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] # 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 @@ class SmartDevice(Light, Fan, Device): 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( @@ -318,8 +325,11 @@ class SmartDevice(Light, Fan, Device): ) ) - 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) @@ -639,138 +649,7 @@ class SmartDevice(Light, Fan, Device): _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 826465e5..e8fbeeec 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -203,14 +203,14 @@ wallswitch_iot = 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 @@ device_iot = 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 3c00a4d1..e3c3c530 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -2,7 +2,7 @@ import pytest 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") @@ -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 @@ -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_contact.py b/kasa/tests/smart/modules/test_contact.py index 88677c58..11440871 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_fan.py b/kasa/tests/smart/modules/test_fan.py index 9597471b..b9627d9f 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") @@ -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/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index 8f329f70..b592041f 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/smart/modules/test_humidity.py b/kasa/tests/smart/modules/test_humidity.py index bf746f2b..790393e5 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 56c3f096..ed691e66 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 a7d20dac..c9685b9d 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 4154cbf8..16e01ed2 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 aa589e44..61536193 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_bulb.py b/kasa/tests/test_bulb.py index 19400c83..5cfa25da 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,19 +7,18 @@ from voluptuous import ( 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 @@ SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend( @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 8f7def95..b07d8d98 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -3,7 +3,9 @@ from pytest_mock import MockerFixture 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 @@ led = parametrize_combine([led_smart, plug_iot]) 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 = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) @led @@ -25,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) @@ -52,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 @@ -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 +async def test_light_brightness(dev: Device): + """Test brightness setter and getter.""" + assert isinstance(dev, Device) + light = dev.modules.get(Module.Light) + assert light + + # Test getting the value + feature = dev.features["brightness"] + assert feature.minimum_value == 0 + assert feature.maximum_value == 100 + + await light.set_brightness(10) + await dev.update() + assert light.brightness == 10 + + with pytest.raises(ValueError): + await light.set_brightness(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await light.set_brightness(feature.maximum_value + 10) diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 6399ca4f..06150d39 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -3,10 +3,10 @@ import pytest 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 eb039144..2dea2004 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -26,8 +26,8 @@ from kasa.xortransport import XorEncryption 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_feature.py b/kasa/tests/test_feature.py index 101a21c0..0fb7156d 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_setters(dev: Device, mocker: MockerFixture): 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 diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index f51f1805..41fdcde1 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -3,24 +3,24 @@ import pytest 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 ed9e5721..c4a4685a 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -14,7 +14,6 @@ from kasa.exceptions import SmartErrorCode 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."""