Make Light and Fan a common module interface (#911)

This commit is contained in:
Steven B 2024-05-13 17:34:44 +01:00 committed by GitHub
parent d7b00336f4
commit 33d839866e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 544 additions and 342 deletions

View File

@ -4,10 +4,10 @@ from __future__ import annotations
from abc import ABC, abstractmethod 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.""" """Interface for a Fan."""
@property @property

View File

@ -7,7 +7,7 @@ from typing import NamedTuple, Optional
from pydantic.v1 import BaseModel from pydantic.v1 import BaseModel
from ..device import Device from ..module import Module
class ColorTempRange(NamedTuple): class ColorTempRange(NamedTuple):
@ -42,12 +42,13 @@ class LightPreset(BaseModel):
mode: Optional[int] # noqa: UP007 mode: Optional[int] # noqa: UP007
class Light(Device, ABC): class Light(Module, ABC):
"""Base class for TP-Link Light.""" """Base class for TP-Link Light."""
def _raise_for_invalid_brightness(self, value): @property
if not isinstance(value, int) or not (0 <= value <= 100): @abstractmethod
raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") def is_dimmable(self) -> bool:
"""Whether the light supports brightness changes."""
@property @property
@abstractmethod @abstractmethod
@ -132,8 +133,3 @@ class Light(Device, ABC):
:param int brightness: brightness in percent :param int brightness: brightness in percent
:param int transition: transition in milliseconds. :param int transition: transition in milliseconds.
""" """
@property
@abstractmethod
def presets(self) -> list[LightPreset]:
"""Return a list of available bulb setting presets."""

View File

@ -11,12 +11,20 @@ from pydantic.v1 import BaseModel, Field, root_validator
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..feature import Feature from ..interfaces.light import HSV, ColorTempRange, LightPreset
from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset
from ..module import Module from ..module import Module
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .iotdevice import IotDevice, KasaException, requires_update 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): class BehaviorMode(str, Enum):
@ -88,7 +96,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"}
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class IotBulb(IotDevice, Light): class IotBulb(IotDevice):
r"""Representation of a TP-Link Smart Bulb. r"""Representation of a TP-Link Smart Bulb.
To initialize, you have to await :func:`update()` at least once. To initialize, you have to await :func:`update()` at least once.
@ -199,6 +207,10 @@ class IotBulb(IotDevice, Light):
) -> None: ) -> None:
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Bulb self._device_type = DeviceType.Bulb
async def _initialize_modules(self):
"""Initialize modules not added in init."""
await super()._initialize_modules()
self.add_module( self.add_module(
Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule") 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.IotEmeter, Emeter(self, self.emeter_type))
self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCountdown, Countdown(self, "countdown"))
self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud"))
self.add_module(Module.Light, Light(self, "light"))
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,
)
)
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -458,6 +438,10 @@ class IotBulb(IotDevice, Light):
return await self.set_light_state(light_state, transition=transition) 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 @property # type: ignore
@requires_update @requires_update
def brightness(self) -> int: def brightness(self) -> int:

View File

@ -307,6 +307,9 @@ class IotDevice(Device):
self._last_update = response self._last_update = response
self._set_sys_info(response["system"]["get_sysinfo"]) self._set_sys_info(response["system"]["get_sysinfo"])
if not self._modules:
await self._initialize_modules()
await self._modular_update(req) await self._modular_update(req)
if not self._features: if not self._features:
@ -314,6 +317,9 @@ class IotDevice(Device):
self._set_sys_info(self._last_update["system"]["get_sysinfo"]) 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): async def _initialize_features(self):
self._add_feature( self._add_feature(
Feature( Feature(

View File

@ -7,12 +7,11 @@ from typing import Any
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
from ..feature import Feature
from ..module import Module from ..module import Module
from ..protocol import BaseProtocol from ..protocol import BaseProtocol
from .iotdevice import KasaException, requires_update from .iotdevice import KasaException, requires_update
from .iotplug import IotPlug from .iotplug import IotPlug
from .modules import AmbientLight, Motion from .modules import AmbientLight, Light, Motion
class ButtonAction(Enum): class ButtonAction(Enum):
@ -80,29 +79,15 @@ class IotDimmer(IotPlug):
) -> None: ) -> None:
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Dimmer 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 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 # 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.IotMotion, Motion(self, "smartlife.iot.PIR"))
self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS"))
self.add_module(Module.Light, Light(self, "light"))
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,
)
)
@property # type: ignore @property # type: ignore
@requires_update @requires_update

View File

@ -56,6 +56,10 @@ class IotLightStrip(IotBulb):
) -> None: ) -> None:
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.LightStrip self._device_type = DeviceType.LightStrip
async def _initialize_modules(self):
"""Initialize modules not added in init."""
await super()._initialize_modules()
self.add_module( self.add_module(
Module.LightEffect, Module.LightEffect,
LightEffect(self, "smartlife.iot.lighting_effect"), LightEffect(self, "smartlife.iot.lighting_effect"),

View File

@ -53,6 +53,10 @@ class IotPlug(IotDevice):
) -> None: ) -> None:
super().__init__(host=host, config=config, protocol=protocol) super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Plug 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.IotSchedule, Schedule(self, "schedule"))
self.add_module(Module.IotUsage, Usage(self, "schedule")) self.add_module(Module.IotUsage, Usage(self, "schedule"))
self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft"))

View File

@ -255,6 +255,10 @@ class IotStripPlug(IotPlug):
self._set_sys_info(parent.sys_info) self._set_sys_info(parent.sys_info)
self._device_type = DeviceType.StripSocket self._device_type = DeviceType.StripSocket
self.protocol = parent.protocol # Must use the same connection as the parent 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")) self.add_module("time", Time(self, "time"))
async def update(self, update_children: bool = True): async def update(self, update_children: bool = True):

View File

@ -6,6 +6,7 @@ from .cloud import Cloud
from .countdown import Countdown from .countdown import Countdown
from .emeter import Emeter from .emeter import Emeter
from .led import Led from .led import Led
from .light import Light
from .lighteffect import LightEffect from .lighteffect import LightEffect
from .motion import Motion from .motion import Motion
from .rulemodule import Rule, RuleModule from .rulemodule import Rule, RuleModule
@ -20,6 +21,7 @@ __all__ = [
"Countdown", "Countdown",
"Emeter", "Emeter",
"Led", "Led",
"Light",
"LightEffect", "LightEffect",
"Motion", "Motion",
"Rule", "Rule",

188
kasa/iot/modules/light.py Normal file
View File

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

View File

@ -15,9 +15,8 @@ from .feature import Feature
from .modulemapping import ModuleName from .modulemapping import ModuleName
if TYPE_CHECKING: if TYPE_CHECKING:
from . import interfaces
from .device import Device from .device import Device
from .interfaces.led import Led
from .interfaces.lighteffect import LightEffect
from .iot import modules as iot from .iot import modules as iot
from .smart import modules as smart from .smart import modules as smart
@ -34,8 +33,9 @@ class Module(ABC):
""" """
# Common Modules # Common Modules
LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffect") LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect")
Led: Final[ModuleName[Led]] = ModuleName("Led") Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
# IOT only Modules # IOT only Modules
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")

View File

@ -16,6 +16,7 @@ from .firmware import Firmware
from .frostprotection import FrostProtection from .frostprotection import FrostProtection
from .humiditysensor import HumiditySensor from .humiditysensor import HumiditySensor
from .led import Led from .led import Led
from .light import Light
from .lighteffect import LightEffect from .lighteffect import LightEffect
from .lighttransition import LightTransition from .lighttransition import LightTransition
from .reportmode import ReportMode from .reportmode import ReportMode
@ -41,6 +42,7 @@ __all__ = [
"Fan", "Fan",
"Firmware", "Firmware",
"Cloud", "Cloud",
"Light",
"LightEffect", "LightEffect",
"LightTransition", "LightTransition",
"ColorTemperature", "ColorTemperature",

View File

@ -2,16 +2,10 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from ...feature import Feature from ...feature import Feature
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
if TYPE_CHECKING: BRIGHTNESS_MIN = 0
from ..smartdevice import SmartDevice
BRIGHTNESS_MIN = 1
BRIGHTNESS_MAX = 100 BRIGHTNESS_MAX = 100
@ -20,8 +14,11 @@ class Brightness(SmartModule):
REQUIRED_COMPONENT = "brightness" REQUIRED_COMPONENT = "brightness"
def __init__(self, device: SmartDevice, module: str): def _initialize_features(self):
super().__init__(device, module) """Initialize features."""
super()._initialize_features()
device = self._device
self._add_feature( self._add_feature(
Feature( Feature(
device, device,
@ -47,8 +44,11 @@ class Brightness(SmartModule):
"""Return current brightness.""" """Return current brightness."""
return self.data["brightness"] return self.data["brightness"]
async def set_brightness(self, brightness: int): async def set_brightness(self, brightness: int, *, transition: int | None = None):
"""Set the brightness.""" """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 ( if not isinstance(brightness, int) or not (
BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX
): ):
@ -57,6 +57,8 @@ class Brightness(SmartModule):
f"(valid range: {BRIGHTNESS_MIN}-{BRIGHTNESS_MAX}%)" 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}) return await self.call("set_device_info", {"brightness": brightness})
async def _check_supported(self): async def _check_supported(self):

126
kasa/smart/modules/light.py Normal file
View File

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

View File

@ -14,8 +14,7 @@ from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus from ..emeterstatus import EmeterStatus
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
from ..feature import Feature from ..feature import Feature
from ..interfaces.fan import Fan from ..interfaces.light import LightPreset
from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset
from ..module import Module from ..module import Module
from ..modulemapping import ModuleMapping, ModuleName from ..modulemapping import ModuleMapping, ModuleName
from ..smartprotocol import SmartProtocol from ..smartprotocol import SmartProtocol
@ -23,6 +22,7 @@ from .modules import (
Cloud, Cloud,
DeviceModule, DeviceModule,
Firmware, Firmware,
Light,
Time, Time,
) )
from .smartmodule import SmartModule 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 # Device must go last as the other interfaces also inherit Device
# and python needs a consistent method resolution order. # 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.""" """Base class to represent a SMART protocol based device."""
def __init__( def __init__(
@ -231,6 +231,13 @@ class SmartDevice(Light, Fan, Device):
if await module._check_supported(): if await module._check_supported():
self._modules[module.name] = module 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): async def _initialize_features(self):
"""Initialize device features.""" """Initialize device features."""
self._add_feature( self._add_feature(
@ -318,8 +325,11 @@ class SmartDevice(Light, Fan, Device):
) )
) )
for module in self._modules.values(): for module in self.modules.values():
module._initialize_features() # 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(): for feat in module._module_features.values():
self._add_feature(feat) self._add_feature(feat)
@ -639,138 +649,7 @@ class SmartDevice(Light, Fan, Device):
_LOGGER.warning("Unknown device type, falling back to plug") _LOGGER.warning("Unknown device type, falling back to plug")
return DeviceType.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 @property
def presets(self) -> list[LightPreset]: def presets(self) -> list[LightPreset]:
"""Return a list of available bulb setting presets.""" """Return a list of available bulb setting presets."""
return [] return []
@property
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
return Module.LightEffect in self.modules

View File

@ -203,14 +203,14 @@ wallswitch_iot = parametrize(
"wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"}
) )
strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"})
dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"})
lightstrip = parametrize( lightstrip_iot = parametrize(
"lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"}
) )
# bulb types # bulb types
dimmable = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"})
non_dimmable = parametrize( non_dimmable_iot = parametrize(
"non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"}
) )
variable_temp = parametrize( variable_temp = parametrize(
@ -292,12 +292,12 @@ device_iot = parametrize(
def check_categories(): def check_categories():
"""Check that every fixture file is categorized.""" """Check that every fixture file is categorized."""
categorized_fixtures = set( categorized_fixtures = set(
dimmer.args[1] dimmer_iot.args[1]
+ strip.args[1] + strip.args[1]
+ plug.args[1] + plug.args[1]
+ bulb.args[1] + bulb.args[1]
+ wallswitch.args[1] + wallswitch.args[1]
+ lightstrip.args[1] + lightstrip_iot.args[1]
+ bulb_smart.args[1] + bulb_smart.args[1]
+ dimmers_smart.args[1] + dimmers_smart.args[1]
+ hubs_smart.args[1] + hubs_smart.args[1]

View File

@ -2,7 +2,7 @@ import pytest
from kasa.iot import IotDevice from kasa.iot import IotDevice
from kasa.smart import SmartDevice 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") brightness = parametrize("brightness smart", component_filter="brightness")
@ -16,7 +16,7 @@ async def test_brightness_component(dev: SmartDevice):
assert "brightness" in dev._components assert "brightness" in dev._components
# Test getting the value # Test getting the value
feature = brightness._module_features["brightness"] feature = dev.features["brightness"]
assert isinstance(feature.value, int) assert isinstance(feature.value, int)
assert feature.value > 1 and feature.value <= 100 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) await feature.set_value(feature.maximum_value + 10)
@dimmable @dimmable_iot
async def test_brightness_dimmable(dev: IotDevice): async def test_brightness_dimmable(dev: IotDevice):
"""Test brightness feature.""" """Test brightness feature."""
assert isinstance(dev, IotDevice) assert isinstance(dev, IotDevice)

View File

@ -23,6 +23,6 @@ async def test_contact_features(dev: SmartDevice, feature, type):
prop = getattr(contact, feature) prop = getattr(contact, feature)
assert isinstance(prop, type) assert isinstance(prop, type)
feat = contact._module_features[feature] feat = dev.features[feature]
assert feat.value == prop assert feat.value == prop
assert isinstance(feat.value, type) assert isinstance(feat.value, type)

View File

@ -14,7 +14,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture):
fan = dev.modules.get(Module.Fan) fan = dev.modules.get(Module.Fan)
assert fan assert fan
level_feature = fan._module_features["fan_speed_level"] level_feature = dev.features["fan_speed_level"]
assert ( assert (
level_feature.minimum_value level_feature.minimum_value
<= level_feature.value <= level_feature.value
@ -38,7 +38,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture):
"""Test sleep mode feature.""" """Test sleep mode feature."""
fan = dev.modules.get(Module.Fan) fan = dev.modules.get(Module.Fan)
assert fan assert fan
sleep_feature = fan._module_features["fan_sleep_mode"] sleep_feature = dev.features["fan_sleep_mode"]
assert isinstance(sleep_feature.value, bool) assert isinstance(sleep_feature.value, bool)
call = mocker.spy(fan, "call") call = mocker.spy(fan, "call")
@ -52,7 +52,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture):
@fan @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.""" """Test fan speed on device interface."""
assert isinstance(dev, SmartDevice) assert isinstance(dev, SmartDevice)
fan = dev.modules.get(Module.Fan) fan = dev.modules.get(Module.Fan)
@ -60,21 +60,21 @@ async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture):
device = fan._device device = fan._device
assert device.is_fan assert device.is_fan
await device.set_fan_speed_level(1) await fan.set_fan_speed_level(1)
await dev.update() await dev.update()
assert device.fan_speed_level == 1 assert fan.fan_speed_level == 1
assert device.is_on assert device.is_on
await device.set_fan_speed_level(4) await fan.set_fan_speed_level(4)
await dev.update() 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() await dev.update()
assert not device.is_on assert not device.is_on
with pytest.raises(ValueError): with pytest.raises(ValueError):
await device.set_fan_speed_level(-1) await fan.set_fan_speed_level(-1)
with pytest.raises(ValueError): with pytest.raises(ValueError):
await device.set_fan_speed_level(5) await fan.set_fan_speed_level(5)

View File

@ -43,7 +43,7 @@ async def test_firmware_features(
prop = getattr(fw, prop_name) prop = getattr(fw, prop_name)
assert isinstance(prop, type) assert isinstance(prop, type)
feat = fw._module_features[feature] feat = dev.features[feature]
assert feat.value == prop assert feat.value == prop
assert isinstance(feat.value, type) assert isinstance(feat.value, type)

View File

@ -23,6 +23,6 @@ async def test_humidity_features(dev, feature, type):
prop = getattr(humidity, feature) prop = getattr(humidity, feature)
assert isinstance(prop, type) assert isinstance(prop, type)
feat = humidity._module_features[feature] feat = dev.features[feature]
assert feat.value == prop assert feat.value == prop
assert isinstance(feat.value, type) assert isinstance(feat.value, type)

View File

@ -20,7 +20,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture):
light_effect = dev.modules.get(Module.LightEffect) light_effect = dev.modules.get(Module.LightEffect)
assert isinstance(light_effect, LightEffect) assert isinstance(light_effect, LightEffect)
feature = light_effect._module_features["light_effect"] feature = dev.features["light_effect"]
assert feature.type == Feature.Type.Choice assert feature.type == Feature.Type.Choice
call = mocker.spy(light_effect, "call") call = mocker.spy(light_effect, "call")

View File

@ -29,7 +29,7 @@ async def test_temperature_features(dev, feature, type):
prop = getattr(temp_module, feature) prop = getattr(temp_module, feature)
assert isinstance(prop, type) assert isinstance(prop, type)
feat = temp_module._module_features[feature] feat = dev.features[feature]
assert feat.value == prop assert feat.value == prop
assert isinstance(feat.value, type) assert isinstance(feat.value, type)
@ -42,6 +42,6 @@ async def test_temperature_warning(dev):
assert hasattr(temp_module, "temperature_warning") assert hasattr(temp_module, "temperature_warning")
assert isinstance(temp_module.temperature_warning, bool) 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 feat.value == temp_module.temperature_warning
assert isinstance(feat.value, bool) assert isinstance(feat.value, bool)

View File

@ -28,7 +28,7 @@ async def test_temperature_control_features(dev, feature, type):
prop = getattr(temp_module, feature) prop = getattr(temp_module, feature)
assert isinstance(prop, type) assert isinstance(prop, type)
feat = temp_module._module_features[feature] feat = dev.features[feature]
assert feat.value == prop assert feat.value == prop
assert isinstance(feat.value, type) assert isinstance(feat.value, type)

View File

@ -25,7 +25,7 @@ async def test_waterleak_properties(dev, feature, prop_name, type):
prop = getattr(waterleak, prop_name) prop = getattr(waterleak, prop_name)
assert isinstance(prop, type) assert isinstance(prop, type)
feat = waterleak._module_features[feature] feat = dev.features[feature]
assert feat.value == prop assert feat.value == prop
assert isinstance(feat.value, type) assert isinstance(feat.value, type)

View File

@ -7,19 +7,18 @@ from voluptuous import (
Schema, 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.iot import IotBulb, IotDimmer
from kasa.smart import SmartDevice
from .conftest import ( from .conftest import (
bulb, bulb,
bulb_iot, bulb_iot,
color_bulb, color_bulb,
color_bulb_iot, color_bulb_iot,
dimmable, dimmable_iot,
handle_turn_on, handle_turn_on,
non_color_bulb, non_color_bulb,
non_dimmable, non_dimmable_iot,
non_variable_temp, non_variable_temp,
turn_on, turn_on,
variable_temp, variable_temp,
@ -65,19 +64,20 @@ async def test_get_light_state(dev: IotBulb):
@color_bulb @color_bulb
@turn_on @turn_on
async def test_hsv(dev: Device, 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) 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 <= hue <= 360
assert 0 <= saturation <= 100 assert 0 <= saturation <= 100
assert 0 <= brightness <= 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() await dev.update()
hue, saturation, brightness = dev.hsv hue, saturation, brightness = light.hsv
assert hue == 1 assert hue == 1
assert saturation == 1 assert saturation == 1
assert brightness == 1 assert brightness == 1
@ -96,57 +96,64 @@ async def test_set_hsv_transition(dev: IotBulb, mocker):
@color_bulb @color_bulb
@turn_on @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) await handle_turn_on(dev, turn_on)
assert dev.is_color assert light.is_color
for invalid_hue in [-1, 361, 0.5]: for invalid_hue in [-1, 361, 0.5]:
with pytest.raises(ValueError): 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]: for invalid_saturation in [-1, 101, 0.5]:
with pytest.raises(ValueError): 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]: for invalid_brightness in [-1, 101, 0.5]:
with pytest.raises(ValueError): 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 @color_bulb
@pytest.mark.skip("requires color feature") @pytest.mark.skip("requires color feature")
async def test_color_state_information(dev: Device): 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 "HSV" in dev.state_information
assert dev.state_information["HSV"] == dev.hsv assert dev.state_information["HSV"] == light.hsv
@non_color_bulb @non_color_bulb
async def test_hsv_on_non_color(dev: Light): async def test_hsv_on_non_color(dev: Device):
assert not dev.is_color light = dev.modules.get(Module.Light)
assert light
assert not light.is_color
with pytest.raises(KasaException): with pytest.raises(KasaException):
await dev.set_hsv(0, 0, 0) await light.set_hsv(0, 0, 0)
with pytest.raises(KasaException): with pytest.raises(KasaException):
print(dev.hsv) print(light.hsv)
@variable_temp @variable_temp
@pytest.mark.skip("requires colortemp module") @pytest.mark.skip("requires colortemp module")
async def test_variable_temp_state_information(dev: Device): 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 "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 @variable_temp
@turn_on @turn_on
async def test_try_set_colortemp(dev: Device, 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 handle_turn_on(dev, turn_on)
await dev.set_color_temp(2700) await light.set_color_temp(2700)
await dev.update() await dev.update()
assert dev.color_temp == 2700 assert light.color_temp == 2700
@variable_temp_iot @variable_temp_iot
@ -166,34 +173,40 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog):
@variable_temp_smart @variable_temp_smart
async def test_smart_temp_range(dev: SmartDevice): async def test_smart_temp_range(dev: Device):
assert dev.valid_temperature_range light = dev.modules.get(Module.Light)
assert light
assert light.valid_temperature_range
@variable_temp @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): with pytest.raises(ValueError):
await dev.set_color_temp(1000) await light.set_color_temp(1000)
with pytest.raises(ValueError): with pytest.raises(ValueError):
await dev.set_color_temp(10000) await light.set_color_temp(10000)
@non_variable_temp @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): with pytest.raises(KasaException):
await dev.set_color_temp(2700) await light.set_color_temp(2700)
with pytest.raises(KasaException): with pytest.raises(KasaException):
print(dev.valid_temperature_range) print(light.valid_temperature_range)
with pytest.raises(KasaException): with pytest.raises(KasaException):
print(dev.color_temp) print(light.color_temp)
@dimmable @dimmable_iot
@turn_on @turn_on
async def test_dimmable_brightness(dev: Device, turn_on): async def test_dimmable_brightness(dev: IotBulb, turn_on):
assert isinstance(dev, (Light, IotDimmer)) assert isinstance(dev, (IotBulb, IotDimmer))
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
assert dev.is_dimmable 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) set_light_state.assert_called_with({"brightness": 10}, transition=1000)
@dimmable @dimmable_iot
async def test_invalid_brightness(dev: Light): async def test_invalid_brightness(dev: IotBulb):
assert dev.is_dimmable assert dev.is_dimmable
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -240,8 +253,8 @@ async def test_invalid_brightness(dev: Light):
await dev.set_brightness(-100) await dev.set_brightness(-100)
@non_dimmable @non_dimmable_iot
async def test_non_dimmable(dev: Light): async def test_non_dimmable(dev: IotBulb):
assert not dev.is_dimmable assert not dev.is_dimmable
with pytest.raises(KasaException): with pytest.raises(KasaException):
@ -380,7 +393,7 @@ SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend(
@bulb @bulb
def test_device_type_bulb(dev): def test_device_type_bulb(dev: Device):
if dev.is_light_strip: if dev.is_light_strip:
pytest.skip("bulb has also lightstrips to test the api") pytest.skip("bulb has also lightstrips to test the api")
assert dev.device_type == DeviceType.Bulb assert dev.device_type == DeviceType.Bulb

View File

@ -3,7 +3,9 @@ from pytest_mock import MockerFixture
from kasa import Device, Module from kasa import Device, Module
from kasa.tests.device_fixtures import ( from kasa.tests.device_fixtures import (
lightstrip, dimmable_iot,
dimmer_iot,
lightstrip_iot,
parametrize, parametrize,
parametrize_combine, parametrize_combine,
plug_iot, plug_iot,
@ -17,7 +19,12 @@ led = parametrize_combine([led_smart, plug_iot])
light_effect_smart = parametrize( light_effect_smart = parametrize(
"has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} "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 @led
@ -25,7 +32,7 @@ async def test_led_module(dev: Device, mocker: MockerFixture):
"""Test fan speed feature.""" """Test fan speed feature."""
led_module = dev.modules.get(Module.Led) led_module = dev.modules.get(Module.Led)
assert led_module assert led_module
feat = led_module._module_features["led"] feat = dev.features["led"]
call = mocker.spy(led_module, "call") call = mocker.spy(led_module, "call")
await led_module.set_led(True) await led_module.set_led(True)
@ -52,7 +59,7 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture):
"""Test fan speed feature.""" """Test fan speed feature."""
light_effect_module = dev.modules[Module.LightEffect] light_effect_module = dev.modules[Module.LightEffect]
assert light_effect_module assert light_effect_module
feat = light_effect_module._module_features["light_effect"] feat = dev.features["light_effect"]
call = mocker.spy(light_effect_module, "call") call = mocker.spy(light_effect_module, "call")
effect_list = light_effect_module.effect_list 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): with pytest.raises(ValueError):
await light_effect_module.set_effect("foobar") await light_effect_module.set_effect("foobar")
assert call.call_count == 4 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)

View File

@ -3,10 +3,10 @@ import pytest
from kasa import DeviceType from kasa import DeviceType
from kasa.iot import IotDimmer 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 @turn_on
async def test_set_brightness(dev, turn_on): async def test_set_brightness(dev, turn_on):
await handle_turn_on(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 assert dev.is_on == turn_on
@dimmer @dimmer_iot
@turn_on @turn_on
async def test_set_brightness_transition(dev, turn_on, mocker): async def test_set_brightness_transition(dev, turn_on, mocker):
await handle_turn_on(dev, turn_on) 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 assert dev.brightness == 1
@dimmer @dimmer_iot
async def test_set_brightness_invalid(dev): async def test_set_brightness_invalid(dev):
for invalid_brightness in [-1, 101, 0.5]: for invalid_brightness in [-1, 101, 0.5]:
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -55,7 +55,7 @@ async def test_set_brightness_invalid(dev):
await dev.set_brightness(1, transition=invalid_transition) await dev.set_brightness(1, transition=invalid_transition)
@dimmer @dimmer_iot
async def test_turn_on_transition(dev, mocker): async def test_turn_on_transition(dev, mocker):
query_helper = mocker.spy(IotDimmer, "_query_helper") query_helper = mocker.spy(IotDimmer, "_query_helper")
original_brightness = dev.brightness original_brightness = dev.brightness
@ -72,7 +72,7 @@ async def test_turn_on_transition(dev, mocker):
assert dev.brightness == original_brightness assert dev.brightness == original_brightness
@dimmer @dimmer_iot
async def test_turn_off_transition(dev, mocker): async def test_turn_off_transition(dev, mocker):
await handle_turn_on(dev, True) await handle_turn_on(dev, True)
query_helper = mocker.spy(IotDimmer, "_query_helper") query_helper = mocker.spy(IotDimmer, "_query_helper")
@ -90,7 +90,7 @@ async def test_turn_off_transition(dev, mocker):
) )
@dimmer @dimmer_iot
@turn_on @turn_on
async def test_set_dimmer_transition(dev, turn_on, mocker): async def test_set_dimmer_transition(dev, turn_on, mocker):
await handle_turn_on(dev, turn_on) 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 assert dev.brightness == 99
@dimmer @dimmer_iot
@turn_on @turn_on
async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): async def test_set_dimmer_transition_to_off(dev, turn_on, mocker):
await handle_turn_on(dev, turn_on) 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): async def test_set_dimmer_transition_invalid(dev):
for invalid_brightness in [-1, 101, 0.5]: for invalid_brightness in [-1, 101, 0.5]:
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -138,6 +138,6 @@ async def test_set_dimmer_transition_invalid(dev):
await dev.set_dimmer_transition(1, invalid_transition) await dev.set_dimmer_transition(1, invalid_transition)
@dimmer @dimmer_iot
def test_device_type_dimmer(dev): def test_device_type_dimmer(dev):
assert dev.device_type == DeviceType.Dimmer assert dev.device_type == DeviceType.Dimmer

View File

@ -26,8 +26,8 @@ from kasa.xortransport import XorEncryption
from .conftest import ( from .conftest import (
bulb_iot, bulb_iot,
dimmer, dimmer_iot,
lightstrip, lightstrip_iot,
new_discovery, new_discovery,
plug_iot, plug_iot,
strip_iot, strip_iot,
@ -86,14 +86,14 @@ async def test_type_detection_strip(dev: Device):
assert d.device_type == DeviceType.Strip assert d.device_type == DeviceType.Strip
@dimmer @dimmer_iot
async def test_type_detection_dimmer(dev: Device): async def test_type_detection_dimmer(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_dimmer assert d.is_dimmer
assert d.device_type == DeviceType.Dimmer assert d.device_type == DeviceType.Dimmer
@lightstrip @lightstrip_iot
async def test_type_detection_lightstrip(dev: Device): async def test_type_detection_lightstrip(dev: Device):
d = Discover._get_device_class(dev._last_update)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_light_strip assert d.is_light_strip

View File

@ -1,5 +1,6 @@
import logging import logging
import sys import sys
from unittest.mock import patch
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@ -180,11 +181,10 @@ async def test_feature_setters(dev: Device, mocker: MockerFixture):
async def _test_features(dev): async def _test_features(dev):
exceptions = [] exceptions = []
query = mocker.patch.object(dev.protocol, "query")
for feat in dev.features.values(): for feat in dev.features.values():
query.reset_mock()
try: 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 # we allow our own exceptions to avoid mocking valid responses
except KasaException: except KasaException:
pass pass

View File

@ -3,24 +3,24 @@ import pytest
from kasa import DeviceType from kasa import DeviceType
from kasa.iot import IotLightStrip from kasa.iot import IotLightStrip
from .conftest import lightstrip from .conftest import lightstrip_iot
@lightstrip @lightstrip_iot
async def test_lightstrip_length(dev: IotLightStrip): async def test_lightstrip_length(dev: IotLightStrip):
assert dev.is_light_strip assert dev.is_light_strip
assert dev.device_type == DeviceType.LightStrip assert dev.device_type == DeviceType.LightStrip
assert dev.length == dev.sys_info["length"] assert dev.length == dev.sys_info["length"]
@lightstrip @lightstrip_iot
async def test_lightstrip_effect(dev: IotLightStrip): async def test_lightstrip_effect(dev: IotLightStrip):
assert isinstance(dev.effect, dict) assert isinstance(dev.effect, dict)
for k in ["brightness", "custom", "enable", "id", "name"]: for k in ["brightness", "custom", "enable", "id", "name"]:
assert k in dev.effect assert k in dev.effect
@lightstrip @lightstrip_iot
async def test_effects_lightstrip_set_effect(dev: IotLightStrip): async def test_effects_lightstrip_set_effect(dev: IotLightStrip):
with pytest.raises(ValueError): with pytest.raises(ValueError):
await dev.set_effect("Not real") 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" assert dev.effect["name"] == "Candy Cane"
@lightstrip @lightstrip_iot
@pytest.mark.parametrize("brightness", [100, 50]) @pytest.mark.parametrize("brightness", [100, 50])
async def test_effects_lightstrip_set_effect_brightness( async def test_effects_lightstrip_set_effect_brightness(
dev: IotLightStrip, brightness, mocker dev: IotLightStrip, brightness, mocker
@ -48,7 +48,7 @@ async def test_effects_lightstrip_set_effect_brightness(
assert payload["brightness"] == brightness assert payload["brightness"] == brightness
@lightstrip @lightstrip_iot
@pytest.mark.parametrize("transition", [500, 1000]) @pytest.mark.parametrize("transition", [500, 1000])
async def test_effects_lightstrip_set_effect_transition( async def test_effects_lightstrip_set_effect_transition(
dev: IotLightStrip, transition, mocker dev: IotLightStrip, transition, mocker
@ -66,12 +66,12 @@ async def test_effects_lightstrip_set_effect_transition(
assert payload["transition"] == transition assert payload["transition"] == transition
@lightstrip @lightstrip_iot
async def test_effects_lightstrip_has_effects(dev: IotLightStrip): async def test_effects_lightstrip_has_effects(dev: IotLightStrip):
assert dev.has_effects is True assert dev.has_effects is True
assert dev.effect_list assert dev.effect_list
@lightstrip @lightstrip_iot
def test_device_type_lightstrip(dev): def test_device_type_lightstrip(dev):
assert dev.device_type == DeviceType.LightStrip assert dev.device_type == DeviceType.LightStrip

View File

@ -14,7 +14,6 @@ from kasa.exceptions import SmartErrorCode
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from .conftest import ( from .conftest import (
bulb_smart,
device_smart, device_smart,
get_device_for_fixture_protocol, get_device_for_fixture_protocol,
) )
@ -159,28 +158,6 @@ async def test_get_modules():
assert module is None 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 @device_smart
async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture): async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture):
"""Test is_cloud_connected property.""" """Test is_cloud_connected property."""