From e270fc06ceed09eac74d2e1d28ba5b8bdbc8846e Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Wed, 8 May 2024 19:16:56 +0100 Subject: [PATCH 1/5] Initial commit of light presets common module --- kasa/__init__.py | 9 +- kasa/device.py | 4 + kasa/interfaces/__init__.py | 4 +- kasa/interfaces/light.py | 38 ++++---- kasa/interfaces/lightpreset.py | 89 +++++++++++++++++ kasa/iot/iotbulb.py | 44 +++------ kasa/iot/iotdevice.py | 6 +- kasa/iot/modules/__init__.py | 3 + kasa/iot/modules/light.py | 23 ++++- kasa/iot/modules/lightpreset.py | 154 ++++++++++++++++++++++++++++++ kasa/module.py | 8 ++ kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/light.py | 15 ++- kasa/smart/modules/lightpreset.py | 142 +++++++++++++++++++++++++++ kasa/smart/smartchilddevice.py | 1 - kasa/smart/smartdevice.py | 9 ++ kasa/smartprotocol.py | 7 ++ kasa/tests/fakeprotocol_smart.py | 45 ++++++++- kasa/tests/test_bulb.py | 22 +++-- kasa/tests/test_common_modules.py | 75 ++++++++++++++- kasa/tests/test_device.py | 28 ++++++ kasa/tests/test_smartdevice.py | 3 + 22 files changed, 655 insertions(+), 76 deletions(-) create mode 100644 kasa/interfaces/lightpreset.py create mode 100644 kasa/iot/modules/lightpreset.py create mode 100644 kasa/smart/modules/lightpreset.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 8428154ed..1b645f84b 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..95e3781fa --- /dev/null +++ b/kasa/interfaces/lightpreset.py @@ -0,0 +1,89 @@ +"""Module for LightPreset base class.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Sequence + +from ..feature import Feature +from ..module import Module +from .light import LightState + + +class LightPresetInfo(ABC): + """Light configuration preset.""" + + brightness: int + + hue: int | None + saturation: int | None + color_temp: int | None + + +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 built-in effects list. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + + @property + @abstractmethod + def preset_states_list(self) -> Sequence[LightState]: + """Return built-in effects list. + + 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. + + The device doesn't store an active effect while not enabled so store locally. + """ + + @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 ffeac2801..7daad9c6f 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,9 +179,9 @@ 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)] + [LightState(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), LightState(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightState(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightState(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.smartbulb.LightPreset` + To modify an existing preset, pass :class:`~kasa.smartbulb.LightState` instance to :func:`save_preset` method: >>> preset = bulb.presets[0] @@ -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..dfcef3a2c 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._handle_update() + 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..fcb3f4966 --- /dev/null +++ b/kasa/iot/modules/lightpreset.py @@ -0,0 +1,154 @@ +"""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: + from ..iotdevice import IotDevice + + +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 __init__(self, device: IotDevice, module: str): + super().__init__(device, module) + + def _handle_update(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 9b541ce04..102512e55 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") @@ -109,6 +110,13 @@ def _initialize_features(self): # noqa: B027 This can be implemented if features depend on module query responses. """ + def _handle_update(self): # noqa: B027 + """Perform actions after a device update. + + This can be implemented if a module needs to perform actions after + the device has updated + """ + def _add_feature(self, feature: Feature): """Add module feature.""" id_ = feature.id 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..7bf3a4758 --- /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 _handle_update(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/smartchilddevice.py b/kasa/smart/smartchilddevice.py index d841d2d9d..3c3b0f292 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -41,7 +41,6 @@ async def create(cls, parent: SmartDevice, child_info, child_components): """Create a child device based on device info and component listing.""" child: SmartChildDevice = cls(parent, child_info, child_components) await child._initialize_modules() - await child._initialize_features() return child @property diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e42609954..f8bb0ed27 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -184,6 +184,13 @@ async def update(self, update_children: bool = True): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) + # Call handle update for modules that want to update internal data + for module in self._modules.values(): + module._handle_update() + for child in self._children.values(): + for child_module in child._modules.values(): + child_module._handle_update() + # We can first initialize the features after the first update. # We make here an assumption that every device has at least a single feature. if not self._features: @@ -331,6 +338,8 @@ 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() @property def is_cloud_connected(self) -> bool: diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 472d93202..2a3a48d3b 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -229,6 +229,13 @@ async def _handle_response_lists( iterate_list_pages=False, ) next_batch = response[method] + # In case the device returns empty lists avoid infinite looping + if not next_batch[response_list_name]: + _LOGGER.error( + f"Device {self._host} received empty " + + f"results query a partial list for method {method}" + ) + break response_result[response_list_name].extend(next_batch[response_list_name]) def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 233944509..183949a5e 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -153,6 +153,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 @@ -201,6 +203,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"] @@ -220,6 +246,13 @@ def _send_request(self, request_dict: dict): if (params and (start_index := params.get("start_index"))) else 0 ) + # Fixtures generated before _handle_response_lists was implemented + # could have incomplete lists. + if len(result[list_key]) < result["sum"]: + result["sum"] = len(result[list_key]) + pytest.fixtures_missing_methods.setdefault( + self.fixture_name, set() + ).add(f"{method} (incomplete '{list_key}' list)") result[list_key] = result[list_key][ start_index : start_index + self.list_return_size ] @@ -244,9 +277,11 @@ def _send_request(self, request_dict: dict): "method": method, } # Reduce warning spam by consolidating and reporting at the end of the run - if self.fixture_name not in pytest.fixtures_missing_methods: - pytest.fixtures_missing_methods[self.fixture_name] = set() - pytest.fixtures_missing_methods[self.fixture_name].add(method) + # if self.fixture_name not in pytest.fixtures_missing_methods: + # pytest.fixtures_missing_methods[self.fixture_name] = set() + pytest.fixtures_missing_methods.setdefault(self.fixture_name, set()).add( + method + ) return retval elif method in ["set_qs_info", "fw_download"]: return {"error_code": 0} @@ -259,6 +294,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..3018d06df 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,69 @@ 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 + + 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..a220d6d25 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.lightpreset 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, + ) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index c4a4685a3..88880e103 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -48,7 +48,10 @@ async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): """Test the initial update cycle.""" # As the fixture data is already initialized, we reset the state for testing dev._components_raw = None + dev._components = {} + dev._modules = {} dev._features = {} + dev._children = {} negotiate = mocker.spy(dev, "_negotiate") initialize_modules = mocker.spy(dev, "_initialize_modules") From 6c304864997dc548282e128a5871d7d08c5cd01d Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sun, 19 May 2024 10:32:50 +0100 Subject: [PATCH 2/5] Update post review --- kasa/interfaces/lightpreset.py | 23 +++++------------------ kasa/tests/test_common_modules.py | 11 +++++++++++ kasa/tests/test_device.py | 2 +- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/kasa/interfaces/lightpreset.py b/kasa/interfaces/lightpreset.py index 95e3781fa..84a374dbc 100644 --- a/kasa/interfaces/lightpreset.py +++ b/kasa/interfaces/lightpreset.py @@ -2,7 +2,7 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod from typing import Sequence from ..feature import Feature @@ -10,16 +10,6 @@ from .light import LightState -class LightPresetInfo(ABC): - """Light configuration preset.""" - - brightness: int - - hue: int | None - saturation: int | None - color_temp: int | None - - class LightPreset(Module): """Base interface for light preset module.""" @@ -45,7 +35,7 @@ def _initialize_features(self): @property @abstractmethod def preset_list(self) -> list[str]: - """Return built-in effects list. + """Return list of preset names. Example: ['Off', 'Preset 1', 'Preset 2', ...] @@ -54,7 +44,7 @@ def preset_list(self) -> list[str]: @property @abstractmethod def preset_states_list(self) -> Sequence[LightState]: - """Return built-in effects list. + """Return list of preset states. Example: ['Off', 'Preset 1', 'Preset 2', ...] @@ -70,10 +60,7 @@ async def set_preset( self, preset_name: str, ) -> None: - """Set a light preset for the device. - - The device doesn't store an active effect while not enabled so store locally. - """ + """Set a light preset for the device.""" @abstractmethod async def save_preset( @@ -81,7 +68,7 @@ async def save_preset( preset_name: str, preset_info: LightState, ) -> None: - """Update the preset with preset_name with the new preset_info.""" + """Update the preset with *preset_name* with the new *preset_info*.""" @property @abstractmethod diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 3018d06df..520303079 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -190,6 +190,17 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture): 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: diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index a220d6d25..354507be6 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -13,7 +13,7 @@ import kasa from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module from kasa.iot import IotDevice -from kasa.iot.modules.lightpreset import IotLightPreset +from kasa.iot.modules import IotLightPreset from kasa.smart import SmartChildDevice, SmartDevice From bd25dd0ff7ac4db2401d52e7e1dc93b8f8255d87 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sun, 19 May 2024 10:48:59 +0100 Subject: [PATCH 3/5] Fix --- kasa/iot/iotdevice.py | 2 +- kasa/iot/modules/lightpreset.py | 7 ++----- kasa/module.py | 7 ------- kasa/smart/modules/lightpreset.py | 2 +- 4 files changed, 4 insertions(+), 14 deletions(-) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index dfcef3a2c..25e3b44d5 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -314,7 +314,7 @@ async def update(self, update_children: bool = True): self._set_sys_info(self._last_update["system"]["get_sysinfo"]) for module in self._modules.values(): - module._handle_update() + module._post_update_hook() if not self._features: await self._initialize_features() diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index fcb3f4966..49eca3b83 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -14,7 +14,7 @@ from ..iotmodule import IotModule if TYPE_CHECKING: - from ..iotdevice import IotDevice + pass class IotLightPreset(BaseModel, LightState): @@ -40,10 +40,7 @@ class LightPreset(IotModule, LightPresetInterface): _presets: dict[str, IotLightPreset] _preset_list: list[str] - def __init__(self, device: IotDevice, module: str): - super().__init__(device, module) - - def _handle_update(self): + def _post_update_hook(self): """Update the internal presets.""" self._presets = { f"Light preset {index+1}": IotLightPreset(**vals) diff --git a/kasa/module.py b/kasa/module.py index 85b23d982..a2a9c931a 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -122,13 +122,6 @@ def _post_update_hook(self): # noqa: B027 *_initialize_features* on the first update. """ - def _handle_update(self): # noqa: B027 - """Perform actions after a device update. - - This can be implemented if a module needs to perform actions after - the device has updated - """ - def _add_feature(self, feature: Feature): """Add module feature.""" id_ = feature.id diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 7bf3a4758..e0a775aff 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -29,7 +29,7 @@ def __init__(self, device: SmartDevice, module: str): self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info self._brightness_only: bool = False - def _handle_update(self): + def _post_update_hook(self): """Update the internal presets.""" index = 0 self._presets = {} From 5c5761814c09ee806b9340f73c9a46483e25c1ed Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sun, 19 May 2024 10:57:10 +0100 Subject: [PATCH 4/5] Fix readme examples --- docs/tutorial.py | 2 +- kasa/iot/iotbulb.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/iot/iotbulb.py b/kasa/iot/iotbulb.py index 37c36af88..cca1e7922 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -179,7 +179,7 @@ class IotBulb(IotDevice): Bulb configuration presets can be accessed using the :func:`presets` property: >>> bulb.presets - [LightState(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), LightState(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightState(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightState(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: From caa646f379fe36c4842adb5757ec2c00157ccf66 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Sun, 19 May 2024 11:06:57 +0100 Subject: [PATCH 5/5] Fix duplicate add child features --- kasa/smart/smartdevice.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 2d7f87b56..3250c98e0 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -341,9 +341,6 @@ async def _initialize_features(self): for child in self._children.values(): await child._initialize_features() - for child in self._children.values(): - await child._initialize_features() - @property def is_cloud_connected(self) -> bool: """Returns if the device is connected to the cloud."""