diff --git a/docs/tutorial.py b/docs/tutorial.py index 8757c5e87..fb4a62736 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -99,5 +99,5 @@ 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 """ diff --git a/kasa/__init__.py b/kasa/__init__.py index 3a6f06e8d..ac10c12f8 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -35,7 +35,7 @@ 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 @@ "BaseProtocol", "IotProtocol", "SmartProtocol", - "LightPreset", + "LightState", "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", @@ -75,6 +75,7 @@ ] from . import iot +from .iot.modules.lightpreset import IotLightPreset deprecated_names = ["TPLinkSmartHomeProtocol"] deprecated_smart_devices = { @@ -84,7 +85,7 @@ "SmartLightStrip": iot.IotLightStrip, "SmartStrip": iot.IotStrip, "SmartDimmer": iot.IotDimmer, - "SmartBulbPreset": LightPreset, + "SmartBulbPreset": IotLightPreset, } deprecated_exceptions = { "SmartDeviceException": KasaException, @@ -124,7 +125,7 @@ def __getattr__(name): SmartLightStrip = iot.IotLightStrip SmartStrip = iot.IotStrip SmartDimmer = iot.IotDimmer - SmartBulbPreset = LightPreset + SmartBulbPreset = IotLightPreset SmartDeviceException = KasaException UnsupportedDeviceException = UnsupportedDeviceError diff --git a/kasa/device.py b/kasa/device.py index 052abc4ce..7156a2194 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -364,6 +364,7 @@ def _get_replacing_attr(self, module_name: ModuleName, *attrs): "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 @@ def _get_replacing_attr(self, module_name: ModuleName, *attrs): "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): diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index d8d089c5c..31b9bc33d 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -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", ] diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 3a8805c10..f121d9c69 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -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 @@ async def set_brightness( :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.""" diff --git a/kasa/interfaces/lightpreset.py b/kasa/interfaces/lightpreset.py new file mode 100644 index 000000000..84a374dbc --- /dev/null +++ b/kasa/interfaces/lightpreset.py @@ -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.""" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index da95ceb87..cca1e7922 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -11,7 +11,7 @@ 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 @@ 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 @@ async def _initialize_modules(self): self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) - 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 @@ async def get_light_state(self) -> dict[str, dict]: # 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 @@ async def _set_hsv( 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 @@ async def _set_color_temp( 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 @@ async def _set_brightness( 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 @@ async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict: :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 @@ async def set_alias(self, alias: str) -> None: "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.""" diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index f3ac5321c..25e3b44d5 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -312,11 +312,13 @@ async def update(self, update_children: bool = True): 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.""" diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 2d6f6a01e..6fd63a706 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -8,6 +8,7 @@ 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 @@ "Led", "Light", "LightEffect", + "LightPreset", + "IotLightPreset", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 833709df5..6bbb8894f 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -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 @@ async def set_color_temp( 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) diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py new file mode 100644 index 000000000..49eca3b83 --- /dev/null +++ b/kasa/iot/modules/lightpreset.py @@ -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)) diff --git a/kasa/module.py b/kasa/module.py index b2be82894..a2a9c931a 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -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") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 688d4a6e5..ada52f91f 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -18,6 +18,7 @@ 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 @@ "Led", "Brightness", "Fan", + "LightPreset", "Firmware", "Cloud", "Light", diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 88d6486bc..9a07d3e2b 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -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 @@ async def set_brightness( 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) diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py new file mode 100644 index 000000000..e0a775aff --- /dev/null +++ b/kasa/smart/modules/lightpreset.py @@ -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} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 55de9c04b..3250c98e0 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -338,7 +338,6 @@ async def _initialize_features(self): module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) - for child in self._children.values(): await child._initialize_features() diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 693410b4e..b36c254de 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -157,6 +157,8 @@ def _handle_control_child(self, params: dict): 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 @@ def _set_led_info(self, info, params): 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 @@ def _send_request(self, request_dict: dict): 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) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 2930db57a..97ae85a34 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from voluptuous import ( All, @@ -7,7 +9,7 @@ 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}, ), ], diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index ca34d304f..520303079 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -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 = 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 + ) diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index d8f28d1bc..354507be6 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -1,5 +1,7 @@ """Tests for all devices.""" +from __future__ import annotations + import importlib import inspect import pkgutil @@ -11,6 +13,7 @@ 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, + )