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

View File

@ -364,6 +364,7 @@ class Device(ABC):
"set_color_temp": (Module.Light, ["set_color_temp"]),
"valid_temperature_range": (Module.Light, ["valid_temperature_range"]),
"has_effects": (Module.Light, ["has_effects"]),
"_deprecated_set_light_state": (Module.Light, ["has_effects"]),
# led attributes
"led": (Module.Led, ["led"]),
"set_led": (Module.Led, ["set_led"]),
@ -376,6 +377,9 @@ class Device(ABC):
"effect_list": (Module.LightEffect, ["_deprecated_effect_list", "effect_list"]),
"set_effect": (Module.LightEffect, ["set_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):

View File

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

View File

@ -3,13 +3,24 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import NamedTuple, Optional
from pydantic.v1 import BaseModel
from dataclasses import dataclass
from typing import NamedTuple
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):
"""Color temperature range."""
@ -25,23 +36,6 @@ class HSV(NamedTuple):
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):
"""Base class for TP-Link Light."""
@ -133,3 +127,7 @@ class Light(Module, ABC):
:param int brightness: brightness in percent
: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 ..deviceconfig import DeviceConfig
from ..interfaces.light import HSV, ColorTempRange, LightPreset
from ..interfaces.light import HSV, ColorTempRange
from ..module import Module
from ..protocol import BaseProtocol
from .iotdevice import IotDevice, KasaException, requires_update
@ -21,6 +21,7 @@ from .modules import (
Countdown,
Emeter,
Light,
LightPreset,
Schedule,
Time,
Usage,
@ -178,7 +179,7 @@ class IotBulb(IotDevice):
Bulb configuration presets can be accessed using the :func:`presets` property:
>>> 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`
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.IotCountdown, Countdown(self, "countdown"))
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
@requires_update
@ -320,7 +322,7 @@ class IotBulb(IotDevice):
# TODO: add warning and refer to use 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
) -> dict:
"""Set the light state."""
@ -400,7 +402,7 @@ class IotBulb(IotDevice):
self._raise_for_invalid_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
@requires_update
@ -436,7 +438,7 @@ class IotBulb(IotDevice):
if brightness is not None:
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):
if not isinstance(value, int) or not (0 <= value <= 100):
@ -467,7 +469,7 @@ class IotBulb(IotDevice):
self._raise_for_invalid_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
@requires_update
@ -481,14 +483,14 @@ class IotBulb(IotDevice):
: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:
"""Turn the bulb on.
: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
@requires_update
@ -505,28 +507,6 @@ class IotBulb(IotDevice):
"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
def max_device_response_size(self) -> int:
"""Returns the maximum response size the device can safely construct."""

View File

@ -312,11 +312,13 @@ class IotDevice(Device):
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:
await self._initialize_features()
self._set_sys_info(self._last_update["system"]["get_sysinfo"])
async def _initialize_modules(self):
"""Initialize modules not added in init."""

View File

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

View File

@ -2,12 +2,13 @@
from __future__ import annotations
from dataclasses import asdict
from typing import TYPE_CHECKING, cast
from ...device_type import DeviceType
from ...exceptions import KasaException
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 ..iotmodule import IotModule
@ -198,3 +199,23 @@ class Light(IotModule, LightInterface):
return await bulb._set_color_temp(
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")
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset")
# IOT only Modules
IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient")

View File

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

View File

@ -2,8 +2,10 @@
from __future__ import annotations
from dataclasses import asdict
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 ...module import Module
from ..smartmodule import SmartModule
@ -124,3 +126,14 @@ class Light(SmartModule, LightInterface):
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
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()
for feat in module._module_features.values():
self._add_feature(feat)
for child in self._children.values():
await child._initialize_features()

View File

@ -157,6 +157,8 @@ class FakeSmartTransport(BaseTransport):
elif child_method == "set_device_info":
info.update(child_params)
return {"error_code": 0}
elif child_method == "set_preset_rules":
return self._set_child_preset_rules(info, child_params)
elif (
# FIXTURE_MISSING is for service calls not in place when
# 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_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):
method = request_dict["method"]
params = request_dict["params"]
@ -276,6 +302,10 @@ class FakeSmartTransport(BaseTransport):
elif method == "set_led_info":
self._set_led_info(info, params)
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_":
target_method = f"get_{method[4:]}"
info[target_method].update(params)

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from voluptuous import (
All,
@ -7,7 +9,7 @@ from voluptuous import (
Schema,
)
from kasa import Device, DeviceType, KasaException, LightPreset, Module
from kasa import Device, DeviceType, IotLightPreset, KasaException, Module
from kasa.iot import IotBulb, IotDimmer
from .conftest import (
@ -85,7 +87,7 @@ async def test_hsv(dev: Device, turn_on):
@color_bulb_iot
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)
set_light_state.assert_called_with(
@ -158,7 +160,7 @@ async def test_try_set_colortemp(dev: Device, turn_on):
@variable_temp_iot
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)
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
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)
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
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)
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:
pytest.skip("Some strips do not support presets")
data = {
data: dict[str, int | None] = {
"index": 0,
"brightness": 10,
"hue": 0,
"saturation": 0,
"color_temp": 0,
}
preset = LightPreset(**data)
preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type]
assert preset.index == 0
assert preset.brightness == 10
@ -318,7 +320,7 @@ async def test_modify_preset(dev: IotBulb, mocker):
with pytest.raises(KasaException):
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"),
[
(
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},
),
(
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},
),
],

View File

@ -1,8 +1,9 @@
import pytest
from pytest_mock import MockerFixture
from kasa import Device, Module
from kasa import Device, LightState, Module
from kasa.tests.device_fixtures import (
bulb_iot,
dimmable_iot,
dimmer_iot,
lightstrip_iot,
@ -33,6 +34,12 @@ dimmable_smart = parametrize(
)
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
async def test_led_module(dev: Device, mocker: MockerFixture):
@ -130,3 +137,80 @@ async def test_light_brightness(dev: Device):
with pytest.raises(ValueError):
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."""
from __future__ import annotations
import importlib
import inspect
import pkgutil
@ -11,6 +13,7 @@ import pytest
import kasa
from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module
from kasa.iot import IotDevice
from kasa.iot.modules import IotLightPreset
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, "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,
)