Add light presets common module to devices. (#907)

Adds light preset common module for switching to presets and saving presets.
Deprecates the `presets` attribute and `save_preset` method from the `bulb` 
interface in favour of the modular approach.  Allows setting preset for `iot` 
which was not previously supported.
This commit is contained in:
Steven B 2024-05-19 11:20:18 +01:00 committed by GitHub
parent 1ba5c73279
commit 273c541fcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 612 additions and 73 deletions

View File

@ -99,5 +99,5 @@ False
True True
>>> for feat in dev.features.values(): >>> for feat in dev.features.values():
>>> print(f"{feat.name}: {feat.value}") >>> print(f"{feat.name}: {feat.value}")
Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nSmooth transition on: 2\nSmooth transition off: 2\nTime: 2024-02-23 02:40:15+01:00 Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nTime: 2024-02-23 02:40:15+01:00
""" """

View File

@ -35,7 +35,7 @@ from kasa.exceptions import (
UnsupportedDeviceError, UnsupportedDeviceError,
) )
from kasa.feature import Feature from kasa.feature import Feature
from kasa.interfaces.light import Light, LightPreset from kasa.interfaces.light import Light, LightState
from kasa.iotprotocol import ( from kasa.iotprotocol import (
IotProtocol, IotProtocol,
_deprecated_TPLinkSmartHomeProtocol, # noqa: F401 _deprecated_TPLinkSmartHomeProtocol, # noqa: F401
@ -52,7 +52,7 @@ __all__ = [
"BaseProtocol", "BaseProtocol",
"IotProtocol", "IotProtocol",
"SmartProtocol", "SmartProtocol",
"LightPreset", "LightState",
"TurnOnBehaviors", "TurnOnBehaviors",
"TurnOnBehavior", "TurnOnBehavior",
"DeviceType", "DeviceType",
@ -75,6 +75,7 @@ __all__ = [
] ]
from . import iot from . import iot
from .iot.modules.lightpreset import IotLightPreset
deprecated_names = ["TPLinkSmartHomeProtocol"] deprecated_names = ["TPLinkSmartHomeProtocol"]
deprecated_smart_devices = { deprecated_smart_devices = {
@ -84,7 +85,7 @@ deprecated_smart_devices = {
"SmartLightStrip": iot.IotLightStrip, "SmartLightStrip": iot.IotLightStrip,
"SmartStrip": iot.IotStrip, "SmartStrip": iot.IotStrip,
"SmartDimmer": iot.IotDimmer, "SmartDimmer": iot.IotDimmer,
"SmartBulbPreset": LightPreset, "SmartBulbPreset": IotLightPreset,
} }
deprecated_exceptions = { deprecated_exceptions = {
"SmartDeviceException": KasaException, "SmartDeviceException": KasaException,
@ -124,7 +125,7 @@ if TYPE_CHECKING:
SmartLightStrip = iot.IotLightStrip SmartLightStrip = iot.IotLightStrip
SmartStrip = iot.IotStrip SmartStrip = iot.IotStrip
SmartDimmer = iot.IotDimmer SmartDimmer = iot.IotDimmer
SmartBulbPreset = LightPreset SmartBulbPreset = IotLightPreset
SmartDeviceException = KasaException SmartDeviceException = KasaException
UnsupportedDeviceException = UnsupportedDeviceError UnsupportedDeviceException = UnsupportedDeviceError

View File

@ -364,6 +364,7 @@ class Device(ABC):
"set_color_temp": (Module.Light, ["set_color_temp"]), "set_color_temp": (Module.Light, ["set_color_temp"]),
"valid_temperature_range": (Module.Light, ["valid_temperature_range"]), "valid_temperature_range": (Module.Light, ["valid_temperature_range"]),
"has_effects": (Module.Light, ["has_effects"]), "has_effects": (Module.Light, ["has_effects"]),
"_deprecated_set_light_state": (Module.Light, ["has_effects"]),
# led attributes # led attributes
"led": (Module.Led, ["led"]), "led": (Module.Led, ["led"]),
"set_led": (Module.Led, ["set_led"]), "set_led": (Module.Led, ["set_led"]),
@ -376,6 +377,9 @@ class Device(ABC):
"effect_list": (Module.LightEffect, ["_deprecated_effect_list", "effect_list"]), "effect_list": (Module.LightEffect, ["_deprecated_effect_list", "effect_list"]),
"set_effect": (Module.LightEffect, ["set_effect"]), "set_effect": (Module.LightEffect, ["set_effect"]),
"set_custom_effect": (Module.LightEffect, ["set_custom_effect"]), "set_custom_effect": (Module.LightEffect, ["set_custom_effect"]),
# light preset attributes
"presets": (Module.LightPreset, ["_deprecated_presets", "preset_states_list"]),
"save_preset": (Module.LightPreset, ["_deprecated_save_preset"]),
} }
def __getattr__(self, name): def __getattr__(self, name):

View File

@ -2,13 +2,15 @@
from .fan import Fan from .fan import Fan
from .led import Led from .led import Led
from .light import Light, LightPreset from .light import Light, LightState
from .lighteffect import LightEffect from .lighteffect import LightEffect
from .lightpreset import LightPreset
__all__ = [ __all__ = [
"Fan", "Fan",
"Led", "Led",
"Light", "Light",
"LightEffect", "LightEffect",
"LightState",
"LightPreset", "LightPreset",
] ]

View File

@ -3,13 +3,24 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import NamedTuple, Optional from dataclasses import dataclass
from typing import NamedTuple
from pydantic.v1 import BaseModel
from ..module import Module from ..module import Module
@dataclass
class LightState:
"""Class for smart light preset info."""
light_on: bool | None = None
brightness: int | None = None
hue: int | None = None
saturation: int | None = None
color_temp: int | None = None
transition: bool | None = None
class ColorTempRange(NamedTuple): class ColorTempRange(NamedTuple):
"""Color temperature range.""" """Color temperature range."""
@ -25,23 +36,6 @@ class HSV(NamedTuple):
value: int value: int
class LightPreset(BaseModel):
"""Light configuration preset."""
index: int
brightness: int
# These are not available for effect mode presets on light strips
hue: Optional[int] # noqa: UP007
saturation: Optional[int] # noqa: UP007
color_temp: Optional[int] # noqa: UP007
# Variables for effect mode presets
custom: Optional[int] # noqa: UP007
id: Optional[str] # noqa: UP007
mode: Optional[int] # noqa: UP007
class Light(Module, ABC): class Light(Module, ABC):
"""Base class for TP-Link Light.""" """Base class for TP-Link Light."""
@ -133,3 +127,7 @@ class Light(Module, 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.
""" """
@abstractmethod
async def set_state(self, state: LightState) -> dict:
"""Set the light state."""

View File

@ -0,0 +1,76 @@
"""Module for LightPreset base class."""
from __future__ import annotations
from abc import abstractmethod
from typing import Sequence
from ..feature import Feature
from ..module import Module
from .light import LightState
class LightPreset(Module):
"""Base interface for light preset module."""
PRESET_NOT_SET = "Not set"
def _initialize_features(self):
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device,
id="light_preset",
name="Light preset",
container=self,
attribute_getter="preset",
attribute_setter="set_preset",
category=Feature.Category.Config,
type=Feature.Type.Choice,
choices_getter="preset_list",
)
)
@property
@abstractmethod
def preset_list(self) -> list[str]:
"""Return list of preset names.
Example:
['Off', 'Preset 1', 'Preset 2', ...]
"""
@property
@abstractmethod
def preset_states_list(self) -> Sequence[LightState]:
"""Return list of preset states.
Example:
['Off', 'Preset 1', 'Preset 2', ...]
"""
@property
@abstractmethod
def preset(self) -> str:
"""Return current preset name."""
@abstractmethod
async def set_preset(
self,
preset_name: str,
) -> None:
"""Set a light preset for the device."""
@abstractmethod
async def save_preset(
self,
preset_name: str,
preset_info: LightState,
) -> None:
"""Update the preset with *preset_name* with the new *preset_info*."""
@property
@abstractmethod
def has_save_preset(self) -> bool:
"""Return True if the device supports updating presets."""

View File

@ -11,7 +11,7 @@ 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 ..interfaces.light import HSV, ColorTempRange, LightPreset from ..interfaces.light import HSV, ColorTempRange
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
@ -21,6 +21,7 @@ from .modules import (
Countdown, Countdown,
Emeter, Emeter,
Light, Light,
LightPreset,
Schedule, Schedule,
Time, Time,
Usage, Usage,
@ -178,7 +179,7 @@ class IotBulb(IotDevice):
Bulb configuration presets can be accessed using the :func:`presets` property: Bulb configuration presets can be accessed using the :func:`presets` property:
>>> bulb.presets >>> bulb.presets
[LightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), LightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] [IotLightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), IotLightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), IotLightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), IotLightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)]
To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset` To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset`
instance to :func:`save_preset` method: instance to :func:`save_preset` method:
@ -222,7 +223,8 @@ class IotBulb(IotDevice):
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")) self.add_module(Module.Light, Light(self, self.LIGHT_SERVICE))
self.add_module(Module.LightPreset, LightPreset(self, self.LIGHT_SERVICE))
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -320,7 +322,7 @@ class IotBulb(IotDevice):
# TODO: add warning and refer to use light.state? # TODO: add warning and refer to use light.state?
return await self._query_helper(self.LIGHT_SERVICE, "get_light_state") return await self._query_helper(self.LIGHT_SERVICE, "get_light_state")
async def set_light_state( async def _set_light_state(
self, state: dict, *, transition: int | None = None self, state: dict, *, transition: int | None = None
) -> dict: ) -> dict:
"""Set the light state.""" """Set the light state."""
@ -400,7 +402,7 @@ class IotBulb(IotDevice):
self._raise_for_invalid_brightness(value) self._raise_for_invalid_brightness(value)
light_state["brightness"] = value light_state["brightness"] = value
return await self.set_light_state(light_state, transition=transition) return await self._set_light_state(light_state, transition=transition)
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -436,7 +438,7 @@ class IotBulb(IotDevice):
if brightness is not None: if brightness is not None:
light_state["brightness"] = brightness light_state["brightness"] = brightness
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): def _raise_for_invalid_brightness(self, value):
if not isinstance(value, int) or not (0 <= value <= 100): if not isinstance(value, int) or not (0 <= value <= 100):
@ -467,7 +469,7 @@ class IotBulb(IotDevice):
self._raise_for_invalid_brightness(brightness) self._raise_for_invalid_brightness(brightness)
light_state = {"brightness": brightness} light_state = {"brightness": brightness}
return await self.set_light_state(light_state, transition=transition) return await self._set_light_state(light_state, transition=transition)
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -481,14 +483,14 @@ class IotBulb(IotDevice):
:param int transition: transition in milliseconds. :param int transition: transition in milliseconds.
""" """
return await self.set_light_state({"on_off": 0}, transition=transition) return await self._set_light_state({"on_off": 0}, transition=transition)
async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict: async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict:
"""Turn the bulb on. """Turn the bulb on.
:param int transition: transition in milliseconds. :param int transition: transition in milliseconds.
""" """
return await self.set_light_state({"on_off": 1}, transition=transition) return await self._set_light_state({"on_off": 1}, transition=transition)
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -505,28 +507,6 @@ class IotBulb(IotDevice):
"smartlife.iot.common.system", "set_dev_alias", {"alias": alias} "smartlife.iot.common.system", "set_dev_alias", {"alias": alias}
) )
@property # type: ignore
@requires_update
def presets(self) -> list[LightPreset]:
"""Return a list of available bulb setting presets."""
return [LightPreset(**vals) for vals in self.sys_info["preferred_state"]]
async def save_preset(self, preset: LightPreset):
"""Save a setting preset.
You can either construct a preset object manually, or pass an existing one
obtained using :func:`presets`.
"""
if len(self.presets) == 0:
raise KasaException("Device does not supported saving presets")
if preset.index >= len(self.presets):
raise KasaException("Invalid preset index")
return await self._query_helper(
self.LIGHT_SERVICE, "set_preferred_state", preset.dict(exclude_none=True)
)
@property @property
def max_device_response_size(self) -> int: def max_device_response_size(self) -> int:
"""Returns the maximum response size the device can safely construct.""" """Returns the maximum response size the device can safely construct."""

View File

@ -312,11 +312,13 @@ class IotDevice(Device):
await self._modular_update(req) await self._modular_update(req)
self._set_sys_info(self._last_update["system"]["get_sysinfo"])
for module in self._modules.values():
module._post_update_hook()
if not self._features: if not self._features:
await self._initialize_features() await self._initialize_features()
self._set_sys_info(self._last_update["system"]["get_sysinfo"])
async def _initialize_modules(self): async def _initialize_modules(self):
"""Initialize modules not added in init.""" """Initialize modules not added in init."""

View File

@ -8,6 +8,7 @@ from .emeter import Emeter
from .led import Led from .led import Led
from .light import Light from .light import Light
from .lighteffect import LightEffect from .lighteffect import LightEffect
from .lightpreset import IotLightPreset, LightPreset
from .motion import Motion from .motion import Motion
from .rulemodule import Rule, RuleModule from .rulemodule import Rule, RuleModule
from .schedule import Schedule from .schedule import Schedule
@ -23,6 +24,8 @@ __all__ = [
"Led", "Led",
"Light", "Light",
"LightEffect", "LightEffect",
"LightPreset",
"IotLightPreset",
"Motion", "Motion",
"Rule", "Rule",
"RuleModule", "RuleModule",

View File

@ -2,12 +2,13 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from ...device_type import DeviceType from ...device_type import DeviceType
from ...exceptions import KasaException from ...exceptions import KasaException
from ...feature import Feature from ...feature import Feature
from ...interfaces.light import HSV, ColorTempRange from ...interfaces.light import HSV, ColorTempRange, LightState
from ...interfaces.light import Light as LightInterface from ...interfaces.light import Light as LightInterface
from ..iotmodule import IotModule from ..iotmodule import IotModule
@ -198,3 +199,23 @@ class Light(IotModule, LightInterface):
return await bulb._set_color_temp( return await bulb._set_color_temp(
temp, brightness=brightness, transition=transition temp, brightness=brightness, transition=transition
) )
async def set_state(self, state: LightState) -> dict:
"""Set the light state."""
if (bulb := self._get_bulb_device()) is None:
return await self.set_brightness(state.brightness or 0)
else:
transition = state.transition
state_dict = asdict(state)
state_dict = {k: v for k, v in state_dict.items() if v is not None}
state_dict["on_off"] = 1 if state.light_on is None else int(state.light_on)
return await bulb._set_light_state(state_dict, transition=transition)
async def _deprecated_set_light_state(
self, state: dict, *, transition: int | None = None
) -> dict:
"""Set the light state."""
if (bulb := self._get_bulb_device()) is None:
raise KasaException("Device does not support set_light_state")
else:
return await bulb._set_light_state(state, transition=transition)

View File

@ -0,0 +1,151 @@
"""Light preset module."""
from __future__ import annotations
from dataclasses import asdict
from typing import TYPE_CHECKING, Optional, Sequence
from pydantic.v1 import BaseModel, Field
from ...exceptions import KasaException
from ...interfaces import LightPreset as LightPresetInterface
from ...interfaces import LightState
from ...module import Module
from ..iotmodule import IotModule
if TYPE_CHECKING:
pass
class IotLightPreset(BaseModel, LightState):
"""Light configuration preset."""
index: int = Field(kw_only=True)
brightness: int = Field(kw_only=True)
# These are not available for effect mode presets on light strips
hue: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007
saturation: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007
color_temp: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007
# Variables for effect mode presets
custom: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007
id: Optional[str] = Field(kw_only=True, default=None) # noqa: UP007
mode: Optional[int] = Field(kw_only=True, default=None) # noqa: UP007
class LightPreset(IotModule, LightPresetInterface):
"""Class for setting light presets."""
_presets: dict[str, IotLightPreset]
_preset_list: list[str]
def _post_update_hook(self):
"""Update the internal presets."""
self._presets = {
f"Light preset {index+1}": IotLightPreset(**vals)
for index, vals in enumerate(self.data["preferred_state"])
}
self._preset_list = [self.PRESET_NOT_SET]
self._preset_list.extend(self._presets.keys())
@property
def preset_list(self) -> list[str]:
"""Return built-in effects list.
Example:
['Off', 'Preset 1', 'Preset 2', ...]
"""
return self._preset_list
@property
def preset_states_list(self) -> Sequence[IotLightPreset]:
"""Return built-in effects list.
Example:
['Off', 'Preset 1', 'Preset 2', ...]
"""
return list(self._presets.values())
@property
def preset(self) -> str:
"""Return current preset name."""
light = self._device.modules[Module.Light]
brightness = light.brightness
color_temp = light.color_temp if light.is_variable_color_temp else None
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
for preset_name, preset in self._presets.items():
if (
preset.brightness == brightness
and (
preset.color_temp == color_temp or not light.is_variable_color_temp
)
and (preset.hue == h or not light.is_color)
and (preset.saturation == s or not light.is_color)
):
return preset_name
return self.PRESET_NOT_SET
async def set_preset(
self,
preset_name: str,
) -> None:
"""Set a light preset for the device."""
light = self._device.modules[Module.Light]
if preset_name == self.PRESET_NOT_SET:
if light.is_color:
preset = LightState(hue=0, saturation=0, brightness=100)
else:
preset = LightState(brightness=100)
elif (preset := self._presets.get(preset_name)) is None: # type: ignore[assignment]
raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}")
await light.set_state(preset)
@property
def has_save_preset(self) -> bool:
"""Return True if the device supports updating presets."""
return True
async def save_preset(
self,
preset_name: str,
preset_state: LightState,
) -> None:
"""Update the preset with preset_name with the new preset_info."""
if len(self._presets) == 0:
raise KasaException("Device does not supported saving presets")
if preset_name not in self._presets:
raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}")
index = list(self._presets.keys()).index(preset_name)
state = asdict(preset_state)
state = {k: v for k, v in state.items() if v is not None}
state["index"] = index
return await self.call("set_preferred_state", state)
def query(self):
"""Return the base query."""
return {}
@property # type: ignore
def _deprecated_presets(self) -> list[IotLightPreset]:
"""Return a list of available bulb setting presets."""
return [
IotLightPreset(**vals) for vals in self._device.sys_info["preferred_state"]
]
async def _deprecated_save_preset(self, preset: IotLightPreset):
"""Save a setting preset.
You can either construct a preset object manually, or pass an existing one
obtained using :func:`presets`.
"""
if len(self._presets) == 0:
raise KasaException("Device does not supported saving presets")
if preset.index >= len(self._presets):
raise KasaException("Invalid preset index")
return await self.call("set_preferred_state", preset.dict(exclude_none=True))

View File

@ -36,6 +36,7 @@ class Module(ABC):
LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect")
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset")
# IOT only Modules # IOT only Modules
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")

View File

@ -18,6 +18,7 @@ from .humiditysensor import HumiditySensor
from .led import Led from .led import Led
from .light import Light from .light import Light
from .lighteffect import LightEffect from .lighteffect import LightEffect
from .lightpreset import LightPreset
from .lightstripeffect import LightStripEffect from .lightstripeffect import LightStripEffect
from .lighttransition import LightTransition from .lighttransition import LightTransition
from .reportmode import ReportMode from .reportmode import ReportMode
@ -41,6 +42,7 @@ __all__ = [
"Led", "Led",
"Brightness", "Brightness",
"Fan", "Fan",
"LightPreset",
"Firmware", "Firmware",
"Cloud", "Cloud",
"Light", "Light",

View File

@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict
from ...exceptions import KasaException from ...exceptions import KasaException
from ...interfaces.light import HSV, ColorTempRange from ...interfaces.light import HSV, ColorTempRange, LightState
from ...interfaces.light import Light as LightInterface from ...interfaces.light import Light as LightInterface
from ...module import Module from ...module import Module
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
@ -124,3 +126,14 @@ class Light(SmartModule, LightInterface):
def has_effects(self) -> bool: def has_effects(self) -> bool:
"""Return True if the device supports effects.""" """Return True if the device supports effects."""
return Module.LightEffect in self._device.modules return Module.LightEffect in self._device.modules
async def set_state(self, state: LightState) -> dict:
"""Set the light state."""
state_dict = asdict(state)
# brightness of 0 turns off the light, it's not a valid brightness
if state.brightness and state.brightness == 0:
state_dict["device_on"] = False
del state_dict["brightness"]
params = {k: v for k, v in state_dict.items() if v is not None}
return await self.call("set_device_info", params)

View File

@ -0,0 +1,142 @@
"""Module for light effects."""
from __future__ import annotations
from dataclasses import asdict
from typing import TYPE_CHECKING, Sequence
from ...interfaces import LightPreset as LightPresetInterface
from ...interfaces import LightState
from ..smartmodule import SmartModule
if TYPE_CHECKING:
from ..smartdevice import SmartDevice
class LightPreset(SmartModule, LightPresetInterface):
"""Implementation of light presets."""
REQUIRED_COMPONENT = "preset"
QUERY_GETTER_NAME = "get_preset_rules"
SYS_INFO_STATE_KEY = "preset_state"
_presets: dict[str, LightState]
_preset_list: list[str]
def __init__(self, device: SmartDevice, module: str):
super().__init__(device, module)
self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info
self._brightness_only: bool = False
def _post_update_hook(self):
"""Update the internal presets."""
index = 0
self._presets = {}
state_key = "states" if not self._state_in_sysinfo else self.SYS_INFO_STATE_KEY
if preset_states := self.data.get(state_key):
for preset_state in preset_states:
color_temp = preset_state.get("color_temp")
hue = preset_state.get("hue")
saturation = preset_state.get("saturation")
self._presets[f"Light preset {index + 1}"] = LightState(
brightness=preset_state["brightness"],
color_temp=color_temp,
hue=hue,
saturation=saturation,
)
if color_temp is None and hue is None and saturation is None:
self._brightness_only = True
index = index + 1
elif preset_brightnesses := self.data.get("brightness"):
self._brightness_only = True
for preset_brightness in preset_brightnesses:
self._presets[f"Brightness preset {index + 1}"] = LightState(
brightness=preset_brightness,
)
index = index + 1
self._preset_list = [self.PRESET_NOT_SET]
self._preset_list.extend(self._presets.keys())
@property
def preset_list(self) -> list[str]:
"""Return built-in effects list.
Example:
['Off', 'Light preset 1', 'Light preset 2', ...]
"""
return self._preset_list
@property
def preset_states_list(self) -> Sequence[LightState]:
"""Return built-in effects list.
Example:
['Off', 'Preset 1', 'Preset 2', ...]
"""
return list(self._presets.values())
@property
def preset(self) -> str:
"""Return current preset name."""
light = self._device.modules[SmartModule.Light]
brightness = light.brightness
color_temp = light.color_temp if light.is_variable_color_temp else None
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
for preset_name, preset in self._presets.items():
if (
preset.brightness == brightness
and (
preset.color_temp == color_temp or not light.is_variable_color_temp
)
and preset.hue == h
and preset.saturation == s
):
return preset_name
return self.PRESET_NOT_SET
async def set_preset(
self,
preset_name: str,
) -> None:
"""Set a light preset for the device."""
light = self._device.modules[SmartModule.Light]
if preset_name == self.PRESET_NOT_SET:
if light.is_color:
preset = LightState(hue=0, saturation=0, brightness=100)
else:
preset = LightState(brightness=100)
elif (preset := self._presets.get(preset_name)) is None: # type: ignore[assignment]
raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}")
await self._device.modules[SmartModule.Light].set_state(preset)
async def save_preset(
self,
preset_name: str,
preset_state: LightState,
) -> None:
"""Update the preset with preset_name with the new preset_info."""
if preset_name not in self._presets:
raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}")
index = list(self._presets.keys()).index(preset_name)
if self._brightness_only:
bright_list = [state.brightness for state in self._presets.values()]
bright_list[index] = preset_state.brightness
await self.call("set_preset_rules", {"brightness": bright_list})
else:
state_params = asdict(preset_state)
new_info = {k: v for k, v in state_params.items() if v is not None}
await self.call("edit_preset_rules", {"index": index, "state": new_info})
@property
def has_save_preset(self) -> bool:
"""Return True if the device supports updating presets."""
return True
def query(self) -> dict:
"""Query to execute during the update cycle."""
if self._state_in_sysinfo: # Child lights can have states in the child info
return {}
return {self.QUERY_GETTER_NAME: None}

View File

@ -338,7 +338,6 @@ class SmartDevice(Device):
module._initialize_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)
for child in self._children.values(): for child in self._children.values():
await child._initialize_features() await child._initialize_features()

View File

@ -157,6 +157,8 @@ class FakeSmartTransport(BaseTransport):
elif child_method == "set_device_info": elif child_method == "set_device_info":
info.update(child_params) info.update(child_params)
return {"error_code": 0} return {"error_code": 0}
elif child_method == "set_preset_rules":
return self._set_child_preset_rules(info, child_params)
elif ( elif (
# FIXTURE_MISSING is for service calls not in place when # FIXTURE_MISSING is for service calls not in place when
# SMART fixtures started to be generated # SMART fixtures started to be generated
@ -205,6 +207,30 @@ class FakeSmartTransport(BaseTransport):
info["get_led_info"]["led_status"] = params["led_rule"] != "never" info["get_led_info"]["led_status"] = params["led_rule"] != "never"
info["get_led_info"]["led_rule"] = params["led_rule"] info["get_led_info"]["led_rule"] = params["led_rule"]
def _set_preset_rules(self, info, params):
"""Set or remove values as per the device behaviour."""
if "brightness" not in info["get_preset_rules"]:
return {"error_code": SmartErrorCode.PARAMS_ERROR}
info["get_preset_rules"]["brightness"] = params["brightness"]
return {"error_code": 0}
def _set_child_preset_rules(self, info, params):
"""Set or remove values as per the device behaviour."""
# So far the only child device with light preset (KS240) has the
# data available to read in the device_info. If a child device
# appears that doesn't have this this will need to be extended.
if "preset_state" not in info:
return {"error_code": SmartErrorCode.PARAMS_ERROR}
info["preset_state"] = [{"brightness": b} for b in params["brightness"]]
return {"error_code": 0}
def _edit_preset_rules(self, info, params):
"""Set or remove values as per the device behaviour."""
if "states" not in info["get_preset_rules"] is None:
return {"error_code": SmartErrorCode.PARAMS_ERROR}
info["get_preset_rules"]["states"][params["index"]] = params["state"]
return {"error_code": 0}
def _send_request(self, request_dict: dict): def _send_request(self, request_dict: dict):
method = request_dict["method"] method = request_dict["method"]
params = request_dict["params"] params = request_dict["params"]
@ -276,6 +302,10 @@ class FakeSmartTransport(BaseTransport):
elif method == "set_led_info": elif method == "set_led_info":
self._set_led_info(info, params) self._set_led_info(info, params)
return {"error_code": 0} return {"error_code": 0}
elif method == "set_preset_rules":
return self._set_preset_rules(info, params)
elif method == "edit_preset_rules":
return self._edit_preset_rules(info, params)
elif method[:4] == "set_": elif method[:4] == "set_":
target_method = f"get_{method[4:]}" target_method = f"get_{method[4:]}"
info[target_method].update(params) info[target_method].update(params)

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import pytest import pytest
from voluptuous import ( from voluptuous import (
All, All,
@ -7,7 +9,7 @@ from voluptuous import (
Schema, Schema,
) )
from kasa import Device, DeviceType, KasaException, LightPreset, Module from kasa import Device, DeviceType, IotLightPreset, KasaException, Module
from kasa.iot import IotBulb, IotDimmer from kasa.iot import IotBulb, IotDimmer
from .conftest import ( from .conftest import (
@ -85,7 +87,7 @@ async def test_hsv(dev: Device, turn_on):
@color_bulb_iot @color_bulb_iot
async def test_set_hsv_transition(dev: IotBulb, mocker): async def test_set_hsv_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
await dev.set_hsv(10, 10, 100, transition=1000) await dev.set_hsv(10, 10, 100, transition=1000)
set_light_state.assert_called_with( set_light_state.assert_called_with(
@ -158,7 +160,7 @@ async def test_try_set_colortemp(dev: Device, turn_on):
@variable_temp_iot @variable_temp_iot
async def test_set_color_temp_transition(dev: IotBulb, mocker): async def test_set_color_temp_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
await dev.set_color_temp(2700, transition=100) await dev.set_color_temp(2700, transition=100)
set_light_state.assert_called_with({"color_temp": 2700}, transition=100) set_light_state.assert_called_with({"color_temp": 2700}, transition=100)
@ -224,7 +226,7 @@ async def test_dimmable_brightness(dev: IotBulb, turn_on):
@bulb_iot @bulb_iot
async def test_turn_on_transition(dev: IotBulb, mocker): async def test_turn_on_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
await dev.turn_on(transition=1000) await dev.turn_on(transition=1000)
set_light_state.assert_called_with({"on_off": 1}, transition=1000) set_light_state.assert_called_with({"on_off": 1}, transition=1000)
@ -236,7 +238,7 @@ async def test_turn_on_transition(dev: IotBulb, mocker):
@bulb_iot @bulb_iot
async def test_dimmable_brightness_transition(dev: IotBulb, mocker): async def test_dimmable_brightness_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state") set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
await dev.set_brightness(10, transition=1000) await dev.set_brightness(10, transition=1000)
set_light_state.assert_called_with({"brightness": 10}, transition=1000) set_light_state.assert_called_with({"brightness": 10}, transition=1000)
@ -297,14 +299,14 @@ async def test_modify_preset(dev: IotBulb, mocker):
if not dev.presets: if not dev.presets:
pytest.skip("Some strips do not support presets") pytest.skip("Some strips do not support presets")
data = { data: dict[str, int | None] = {
"index": 0, "index": 0,
"brightness": 10, "brightness": 10,
"hue": 0, "hue": 0,
"saturation": 0, "saturation": 0,
"color_temp": 0, "color_temp": 0,
} }
preset = LightPreset(**data) preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type]
assert preset.index == 0 assert preset.index == 0
assert preset.brightness == 10 assert preset.brightness == 10
@ -318,7 +320,7 @@ async def test_modify_preset(dev: IotBulb, mocker):
with pytest.raises(KasaException): with pytest.raises(KasaException):
await dev.save_preset( await dev.save_preset(
LightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg]
) )
@ -327,11 +329,11 @@ async def test_modify_preset(dev: IotBulb, mocker):
("preset", "payload"), ("preset", "payload"),
[ [
( (
LightPreset(index=0, hue=0, brightness=1, saturation=0), IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg]
{"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, {"index": 0, "hue": 0, "brightness": 1, "saturation": 0},
), ),
( (
LightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg]
{"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0},
), ),
], ],

View File

@ -1,8 +1,9 @@
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import Device, Module from kasa import Device, LightState, Module
from kasa.tests.device_fixtures import ( from kasa.tests.device_fixtures import (
bulb_iot,
dimmable_iot, dimmable_iot,
dimmer_iot, dimmer_iot,
lightstrip_iot, lightstrip_iot,
@ -33,6 +34,12 @@ dimmable_smart = parametrize(
) )
dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot])
light_preset_smart = parametrize(
"has light preset smart", component_filter="preset", protocol_filter={"SMART"}
)
light_preset = parametrize_combine([light_preset_smart, bulb_iot])
@led @led
async def test_led_module(dev: Device, mocker: MockerFixture): async def test_led_module(dev: Device, mocker: MockerFixture):
@ -130,3 +137,80 @@ async def test_light_brightness(dev: Device):
with pytest.raises(ValueError): with pytest.raises(ValueError):
await light.set_brightness(feature.maximum_value + 10) await light.set_brightness(feature.maximum_value + 10)
@light_preset
async def test_light_preset_module(dev: Device, mocker: MockerFixture):
"""Test light preset module."""
preset_mod = dev.modules[Module.LightPreset]
assert preset_mod
light_mod = dev.modules[Module.Light]
assert light_mod
feat = dev.features["light_preset"]
call = mocker.spy(light_mod, "set_state")
preset_list = preset_mod.preset_list
assert "Not set" in preset_list
assert preset_list.index("Not set") == 0
assert preset_list == feat.choices
assert preset_mod.has_save_preset is True
await light_mod.set_brightness(33) # Value that should not be a preset
assert call.call_count == 0
await dev.update()
assert preset_mod.preset == "Not set"
assert feat.value == "Not set"
if len(preset_list) == 1:
return
second_preset = preset_list[1]
await preset_mod.set_preset(second_preset)
assert call.call_count == 1
await dev.update()
assert preset_mod.preset == second_preset
assert feat.value == second_preset
last_preset = preset_list[len(preset_list) - 1]
await preset_mod.set_preset(last_preset)
assert call.call_count == 2
await dev.update()
assert preset_mod.preset == last_preset
assert feat.value == last_preset
# Test feature set
await feat.set_value(second_preset)
assert call.call_count == 3
await dev.update()
assert preset_mod.preset == second_preset
assert feat.value == second_preset
with pytest.raises(ValueError):
await preset_mod.set_preset("foobar")
assert call.call_count == 3
@light_preset
async def test_light_preset_save(dev: Device, mocker: MockerFixture):
"""Test saving a new preset value."""
preset_mod = dev.modules[Module.LightPreset]
assert preset_mod
preset_list = preset_mod.preset_list
if len(preset_list) == 1:
return
second_preset = preset_list[1]
if preset_mod.preset_states_list[0].hue is None:
new_preset = LightState(brightness=52)
else:
new_preset = LightState(brightness=52, color_temp=3000, hue=20, saturation=30)
await preset_mod.save_preset(second_preset, new_preset)
await dev.update()
new_preset_state = preset_mod.preset_states_list[0]
assert (
new_preset_state.brightness == new_preset.brightness
and new_preset_state.hue == new_preset.hue
and new_preset_state.saturation == new_preset.saturation
and new_preset_state.color_temp == new_preset.color_temp
)

View File

@ -1,5 +1,7 @@
"""Tests for all devices.""" """Tests for all devices."""
from __future__ import annotations
import importlib import importlib
import inspect import inspect
import pkgutil import pkgutil
@ -11,6 +13,7 @@ import pytest
import kasa import kasa
from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module
from kasa.iot import IotDevice from kasa.iot import IotDevice
from kasa.iot.modules import IotLightPreset
from kasa.smart import SmartChildDevice, SmartDevice from kasa.smart import SmartChildDevice, SmartDevice
@ -238,3 +241,28 @@ async def test_deprecated_other_attributes(dev: Device):
await _test_attribute(dev, "led", bool(led_module), "Led") await _test_attribute(dev, "led", bool(led_module), "Led")
await _test_attribute(dev, "set_led", bool(led_module), "Led", True) await _test_attribute(dev, "set_led", bool(led_module), "Led", True)
async def test_deprecated_light_preset_attributes(dev: Device):
preset = dev.modules.get(Module.LightPreset)
exc: type[AttributeError] | type[KasaException] | None = (
AttributeError if not preset else None
)
await _test_attribute(dev, "presets", bool(preset), "LightPreset", will_raise=exc)
exc = None
# deprecated save_preset not implemented for smart devices as it's unlikely anyone
# has an existing reliance on this for the newer devices.
if not preset or isinstance(dev, SmartDevice):
exc = AttributeError
elif len(preset.preset_states_list) == 0:
exc = KasaException
await _test_attribute(
dev,
"save_preset",
bool(preset),
"LightPreset",
IotLightPreset(index=0, hue=100, brightness=100, saturation=0, color_temp=0), # type: ignore[call-arg]
will_raise=exc,
)