From 6e0d0d6901944074ca12b77b318a02a0765804a4 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Thu, 2 May 2024 19:49:13 +0100 Subject: [PATCH 01/13] Create interfaces for remaining device types --- kasa/__init__.py | 4 +- kasa/bulb.py | 85 +++++--- kasa/dimmer.py | 146 ++++++++++++++ kasa/iot/iotbulb.py | 81 +++----- kasa/iot/iotdimmer.py | 30 +-- kasa/iot/iotlightstrip.py | 8 +- kasa/iot/iotplug.py | 12 +- kasa/plug.py | 32 ++- kasa/smart/modules/lighttransitionmodule.py | 11 ++ kasa/smart/smartdevice.py | 208 +++++++++++++++++--- kasa/tests/test_childdevice.py | 1 + 11 files changed, 477 insertions(+), 141 deletions(-) create mode 100644 kasa/dimmer.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 62d545025..ef0443f8b 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING from warnings import warn -from kasa.bulb import Bulb +from kasa.bulb import Bulb, BulbPreset from kasa.credentials import Credentials from kasa.device import Device from kasa.device_type import DeviceType @@ -26,6 +26,7 @@ DeviceFamilyType, EncryptType, ) +from kasa.dimmer import TurnOnBehavior, TurnOnBehaviors from kasa.discover import Discover from kasa.emeterstatus import EmeterStatus from kasa.exceptions import ( @@ -36,7 +37,6 @@ UnsupportedDeviceError, ) from kasa.feature import Feature -from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 diff --git a/kasa/bulb.py b/kasa/bulb.py index 01065dc09..131d951d3 100644 --- a/kasa/bulb.py +++ b/kasa/bulb.py @@ -1,4 +1,4 @@ -"""Module for Device base class.""" +"""Module for bulb and light base class.""" from __future__ import annotations @@ -7,7 +7,7 @@ from pydantic.v1 import BaseModel -from .device import Device +from .dimmer import Dimmer class ColorTempRange(NamedTuple): @@ -42,7 +42,7 @@ class BulbPreset(BaseModel): mode: Optional[int] # noqa: UP007 -class Bulb(Device, ABC): +class Bulb(Dimmer, ABC): """Base class for TP-Link Bulb.""" def _raise_for_invalid_brightness(self, value): @@ -54,11 +54,6 @@ def _raise_for_invalid_brightness(self, value): def is_color(self) -> bool: """Whether the bulb supports color changes.""" - @property - @abstractmethod - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - @property @abstractmethod def is_variable_color_temp(self) -> bool: @@ -90,11 +85,6 @@ def hsv(self) -> HSV: def color_temp(self) -> int: """Whether the bulb supports color temperature changes.""" - @property - @abstractmethod - def brightness(self) -> int: - """Return the current brightness in percentage.""" - @abstractmethod async def set_hsv( self, @@ -126,19 +116,70 @@ async def set_color_temp( :param int transition: transition in milliseconds. """ + @property @abstractmethod - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> dict: - """Set the brightness in percentage. + def presets(self) -> list[BulbPreset]: + """Return a list of available bulb setting presets.""" - Note, transition is not supported and will be ignored. - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. +class LightEffect(Bulb, ABC): + """Base interface to represent a device that supports light effects.""" + + @property + @abstractmethod + def is_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + + @property + @abstractmethod + def effect(self) -> dict | str: + """Return effect state or name.""" + + @property + @abstractmethod + def effect_list(self) -> list[str] | None: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + + @abstractmethod + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + + @abstractmethod + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set """ + +class LightStrip(LightEffect, ABC): + """Base interface to represent a LightStrip device.""" + @property @abstractmethod - def presets(self) -> list[BulbPreset]: - """Return a list of available bulb setting presets.""" + def length(self) -> int: + """Return length of the light strip.""" diff --git a/kasa/dimmer.py b/kasa/dimmer.py new file mode 100644 index 000000000..ca7f4f0db --- /dev/null +++ b/kasa/dimmer.py @@ -0,0 +1,146 @@ +"""Module for bulb and light base class.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Optional + +from pydantic.v1 import BaseModel, Field, root_validator + +from .device import Device + + +class BehaviorMode(str, Enum): + """Enum to present type of turn on behavior.""" + + #: Return to the last state known state. + Last = "last_status" + #: Use chosen preset. + Preset = "customize_preset" + + +class TurnOnBehavior(BaseModel): + """Model to present a single turn on behavior. + + :param int preset: the index number of wanted preset. + :param BehaviorMode mode: last status or preset mode. + If you are changing existing settings, you should not set this manually. + + To change the behavior, it is only necessary to change the :attr:`preset` field + to contain either the preset index, or ``None`` for the last known state. + """ + + #: Index of preset to use, or ``None`` for the last known state. + preset: Optional[int] = Field(alias="index", default=None) # noqa: UP007 + #: Wanted behavior + mode: BehaviorMode + + @root_validator + def _mode_based_on_preset(cls, values): + """Set the mode based on the preset value.""" + if values["preset"] is not None: + values["mode"] = BehaviorMode.Preset + else: + values["mode"] = BehaviorMode.Last + + return values + + class Config: + """Configuration to make the validator run when changing the values.""" + + validate_assignment = True + + +class TurnOnBehaviors(BaseModel): + """Model to contain turn on behaviors.""" + + #: The behavior when the bulb is turned on programmatically. + soft: TurnOnBehavior = Field(alias="soft_on") + #: The behavior when the bulb has been off from mains power. + hard: TurnOnBehavior = Field(alias="hard_on") + + +class FadeType(Enum): + """Fade on/off setting.""" + + FadeOn = "fade_on" + FadeOff = "fade_off" + + +class ButtonAction(Enum): + """Button action.""" + + NoAction = "none" + Instant = "instant_on_off" + Gentle = "gentle_on_off" + Preset = "customize_preset" + + +class ActionType(Enum): + """Button action.""" + + DoubleClick = "double_click_action" + LongPress = "long_press_action" + + +class Dimmer(Device, ABC): + """Base class for devices that are dimmers.""" + + @property + @abstractmethod + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + + @property + @abstractmethod + def brightness(self) -> int: + """Return the current brightness in percentage.""" + + @abstractmethod + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + + @abstractmethod + async def set_dimmer_transition(self, brightness: int, transition: int): + """Turn the bulb on to brightness percentage over transition milliseconds. + + A brightness value of 0 will turn off the dimmer. + """ + + @property + @abstractmethod + def is_transitions(self): + """Return True if the dimmer has transition settings.""" + + @abstractmethod + async def set_fade_time(self, fade_type: FadeType, time: int): + """Set time for fade in / fade out.""" + + @property + def is_on_behaviours(self): + """Return True if the device has turn on behaviour settings.""" + + @abstractmethod + async def get_behaviors(self): + """Return button behavior settings.""" + + @abstractmethod + async def get_turn_on_behavior(self) -> TurnOnBehaviors: + """Return the behavior for turning the bulb on.""" + + @abstractmethod + async def set_turn_on_behavior(self, behavior: TurnOnBehaviors): + """Set the behavior for turning the bulb on. + + If you do not want to manually construct the behavior object, + you should use :func:`get_turn_on_behavior` to get the current settings. + """ diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index d9456e969..2d57d9ebd 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -4,70 +4,17 @@ import logging import re -from enum import Enum -from typing import Optional, cast - -from pydantic.v1 import BaseModel, Field, root_validator +from typing import cast from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..dimmer import FadeType, TurnOnBehaviors from ..feature import Feature from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage - -class BehaviorMode(str, Enum): - """Enum to present type of turn on behavior.""" - - #: Return to the last state known state. - Last = "last_status" - #: Use chosen preset. - Preset = "customize_preset" - - -class TurnOnBehavior(BaseModel): - """Model to present a single turn on behavior. - - :param int preset: the index number of wanted preset. - :param BehaviorMode mode: last status or preset mode. - If you are changing existing settings, you should not set this manually. - - To change the behavior, it is only necessary to change the :attr:`preset` field - to contain either the preset index, or ``None`` for the last known state. - """ - - #: Index of preset to use, or ``None`` for the last known state. - preset: Optional[int] = Field(alias="index", default=None) # noqa: UP007 - #: Wanted behavior - mode: BehaviorMode - - @root_validator - def _mode_based_on_preset(cls, values): - """Set the mode based on the preset value.""" - if values["preset"] is not None: - values["mode"] = BehaviorMode.Preset - else: - values["mode"] = BehaviorMode.Last - - return values - - class Config: - """Configuration to make the validator run when changing the values.""" - - validate_assignment = True - - -class TurnOnBehaviors(BaseModel): - """Model to contain turn on behaviors.""" - - #: The behavior when the bulb is turned on programmatically. - soft: TurnOnBehavior = Field(alias="soft_on") - #: The behavior when the bulb has been off from mains power. - hard: TurnOnBehavior = Field(alias="hard_on") - - TPLINK_KELVIN = { "LB130": ColorTempRange(2500, 9000), "LB120": ColorTempRange(2700, 6500), @@ -328,6 +275,14 @@ async def set_turn_on_behavior(self, behavior: TurnOnBehaviors): self.LIGHT_SERVICE, "set_default_behavior", behavior.dict(by_alias=True) ) + @requires_update + async def get_behaviors(self): + """Return button behavior settings.""" + behaviors = await self._query_helper( + self.LIGHT_SERVICE, "get_default_behavior", {} + ) + return behaviors + async def get_light_state(self) -> dict[str, dict]: """Query the light state.""" # TODO: add warning and refer to use light.state? @@ -358,6 +313,22 @@ async def set_light_state( ) return light_state + @property + def is_transitions(self): + """Return True if the bulb has transition settings.""" + + async def set_fade_time(self, fade_type: FadeType, time: int): + """Set time for fade in / fade out.""" + # TODO will this actually work + await self.set_light_state({"on_off": self.is_on}, transition=time) + + async def set_dimmer_transition(self, brightness: int, transition: int): + """Turn the bulb on to brightness percentage over transition milliseconds. + + A brightness value of 0 will turn off the bulb. + """ + await self.set_brightness(brightness, transition) + @property # type: ignore @requires_update def hsv(self) -> HSV: diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 672b22656..4865a95cd 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -2,11 +2,11 @@ from __future__ import annotations -from enum import Enum from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..dimmer import ActionType, ButtonAction, FadeType from ..feature import Feature from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update @@ -14,29 +14,6 @@ from .modules import AmbientLight, Motion -class ButtonAction(Enum): - """Button action.""" - - NoAction = "none" - Instant = "instant_on_off" - Gentle = "gentle_on_off" - Preset = "customize_preset" - - -class ActionType(Enum): - """Button action.""" - - DoubleClick = "double_click_action" - LongPress = "long_press_action" - - -class FadeType(Enum): - """Fade on/off setting.""" - - FadeOn = "fade_on" - FadeOff = "fade_off" - - class IotDimmer(IotPlug): r"""Representation of a TP-Link Smart Dimmer. @@ -192,6 +169,11 @@ async def set_dimmer_transition(self, brightness: int, transition: int): {"brightness": brightness, "duration": transition}, ) + @property + def is_on_behaviours(self): + """Return True if the dimmer has turn on behaviour settings.""" + return True + @requires_update async def get_behaviors(self): """Return button behavior settings.""" diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 57b3282f7..014a6f3df 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ..bulb import LightStrip from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 @@ -10,7 +11,7 @@ from .iotdevice import KasaException, requires_update -class IotLightStrip(IotBulb): +class IotLightStrip(IotBulb, LightStrip): """Representation of a TP-Link Smart light strip. Light strips work similarly to bulbs, but use a different service for controlling, @@ -115,6 +116,11 @@ async def set_effect( await self.set_custom_effect(effect_dict) + @property + def is_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return True + @requires_update async def set_custom_effect( self, diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index ecf73e035..410fabdcb 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -7,6 +7,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..feature import Feature +from ..plug import Plug, WallSwitch from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update from .modules import Antitheft, Cloud, Schedule, Time, Usage @@ -14,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -class IotPlug(IotDevice): +class IotPlug(IotDevice, Plug): r"""Representation of a TP-Link Smart Plug. To initialize, you have to await :func:`update()` at least once. @@ -88,6 +89,13 @@ async def turn_off(self, **kwargs): """Turn the switch off.""" return await self._query_helper("system", "set_relay_state", {"state": 0}) + @property + @requires_update + def is_led(self) -> bool: + """Return True if the device supports led.""" + sys_info = self.sys_info + return "led_off" in sys_info + @property # type: ignore @requires_update def led(self) -> bool: @@ -102,7 +110,7 @@ async def set_led(self, state: bool): ) -class IotWallSwitch(IotPlug): +class IotWallSwitch(IotPlug, WallSwitch): """Representation of a TP-Link Smart Wall Switch.""" def __init__( diff --git a/kasa/plug.py b/kasa/plug.py index 00796d1c4..f73c6f2f3 100644 --- a/kasa/plug.py +++ b/kasa/plug.py @@ -1,12 +1,38 @@ -"""Module for a TAPO Plug.""" +"""Module for a TPlink device with Led.""" import logging -from abc import ABC +from abc import ABC, abstractmethod from .device import Device _LOGGER = logging.getLogger(__name__) -class Plug(Device, ABC): +class Led(Device, ABC): + """Base class to represent a device with an LED.""" + + @property + @abstractmethod + def is_led(self) -> bool: + """Return True if the device supports led.""" + + @property + @abstractmethod + def led(self) -> bool: + """Return the state of the led.""" + + @abstractmethod + async def set_led(self, state: bool): + """Set the state of the led (night mode).""" + + +class Plug(Led, ABC): """Base class to represent a Plug.""" + + +class WallSwitch(Plug, ABC): + """Base class to represent a Wall Switch.""" + + +class Strip(Plug, ABC): + """Base class to represent a Power strip.""" diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index 1cb7f48a6..7e639ba30 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING +from ...dimmer import FadeType from ...exceptions import KasaException from ...feature import Feature from ..smartmodule import SmartModule @@ -171,6 +172,16 @@ async def set_turn_off_transition(self, seconds: int): {"off_state": {**self._turn_on, "duration": seconds}}, ) + async def set_fade_time(self, fade_type: FadeType, time: int): + """Set time for fade in / fade out.""" + if self.supported_version == 1: + await self.set_enabled_v1(time > 0) + else: + if fade_type == FadeType.FadeOn: + await self.set_turn_on_transition(time) + else: + await self.set_turn_off_transition(time) + def query(self) -> dict: """Query to execute during the update cycle.""" # Some devices have the required info in the device info. diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e5df10bee..85afa89cf 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -8,14 +8,16 @@ from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport -from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange +from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange, LightStrip from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..dimmer import FadeType, TurnOnBehaviors from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..fan import Fan from ..feature import Feature +from ..plug import Plug, Strip from ..smartprotocol import SmartProtocol from .modules import ( Brightness, @@ -26,6 +28,9 @@ EnergyModule, FanModule, Firmware, + LedModule, + LightEffectModule, + LightTransitionModule, TimeModule, ) @@ -43,7 +48,7 @@ # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. -class SmartDevice(Bulb, Fan, Device): +class SmartDevice(LightStrip, Strip, Plug, Bulb, Fan, Device): """Base class to represent a SMART protocol based device.""" def __init__( @@ -648,6 +653,82 @@ async def set_fan_speed_level(self, level: int): raise KasaException("Device is not a Fan") await cast(FanModule, self.modules["FanModule"]).set_fan_speed_level(level) + # Dimmer methods + + @property + def is_dimmable(self) -> bool: + """Whether the device supports brightness changes.""" + return "Brightness" in self.modules + + @property + def brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return cast(Brightness, self.modules["Brightness"]).brightness + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + if not self.is_dimmable: # pragma: no cover + raise KasaException("Device is not dimmable.") + + return await cast(Brightness, self.modules["Brightness"]).set_brightness( + brightness + ) + + async def set_dimmer_transition(self, brightness: int, transition: int): + """Turn the bulb on to brightness percentage over transition milliseconds. + + A brightness value of 0 will turn off the dimmer. + """ + if not self.is_transitions and self.is_dimmable: # pragma: no cover + raise KasaException("Device is not dimmable.") + # TODO can add a transition here + return await cast(Brightness, self.modules["Brightness"]).set_brightness( + brightness + ) + + @property + def is_transitions(self): + """Return True if the dimmer has behaviour settings.""" + return "LightTransitionModule" in self.modules + + async def set_fade_time(self, fade_type: FadeType, time: int): + """Set time for fade in / fade out.""" + await cast( + LightTransitionModule, self.modules["LightTransitionModule"] + ).set_fade_time(fade_type, time) + + @property + def is_on_behaviours(self): + """Return True if the device has turn on behaviour settings.""" + return False or "DefaultStatesModule" in self.modules + + async def get_behaviors(self): + """Return button behavior settings.""" + raise KasaException("Device does not support on behaviours.") + + async def get_turn_on_behavior(self) -> TurnOnBehaviors: + """Return the behavior for turning the bulb on.""" + raise KasaException("Device does not support on behaviours.") + + async def set_turn_on_behavior(self, behavior: TurnOnBehaviors): + """Set the behavior for turning the bulb on. + + If you do not want to manually construct the behavior object, + you should use :func:`get_turn_on_behavior` to get the current settings. + """ + raise KasaException("Device does not support on behaviours.") + # Bulb interface methods @property @@ -655,11 +736,6 @@ def is_color(self) -> bool: """Whether the bulb supports color changes.""" return "ColorModule" in self.modules - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - return "Brightness" in self.modules - @property def is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" @@ -699,14 +775,6 @@ def color_temp(self) -> int: ColorTemperatureModule, self.modules["ColorTemperatureModule"] ).color_temp - @property - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return cast(Brightness, self.modules["Brightness"]).brightness - async def set_hsv( self, hue: int, @@ -747,23 +815,6 @@ async def set_color_temp( ColorTemperatureModule, self.modules["ColorTemperatureModule"] ).set_color_temp(temp) - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> dict: - """Set the brightness in percentage. - - Note, transition is not supported and will be ignored. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return await cast(Brightness, self.modules["Brightness"]).set_brightness( - brightness - ) - @property def presets(self) -> list[BulbPreset]: """Return a list of available bulb setting presets.""" @@ -773,3 +824,96 @@ def presets(self) -> list[BulbPreset]: def has_effects(self) -> bool: """Return True if the device supports effects.""" return "LightEffectModule" in self.modules + + # Plug / Wall Switch methods + + @property + def is_led(self) -> bool: + """Return True if device has a led.""" + return "LedModule" in self.modules + + @property + def is_plug(self) -> bool: + """Return True if device is a plug.""" + return self.is_led + + @property + def led(self) -> bool: + """Return the state of the led.""" + if not self.is_led: + raise KasaException("Device does not support led.") + return cast(LedModule, self.modules["LedModule"]).led + + async def set_led(self, state: bool): + """Set the state of the led (night mode).""" + if not self.is_led: + raise KasaException("Device does not support led.") + return await cast(LedModule, self.modules["LedModule"]).set_led(state) + + # Light Effect methods + + @property + def is_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return False + + @property + def effect(self) -> str: + """Return effect state or name.""" + if not self.has_effects: + raise KasaException("Device does not support effects.") + return cast(LightEffectModule, self.modules["LightEffectModule"]).effect + + @property + def effect_list(self) -> list[str] | None: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + if not self.has_effects: + raise KasaException("Device does not support effects.") + return cast(LightEffectModule, self.modules["LightEffectModule"]).effect_list + + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + if not self.has_effects: + raise KasaException("Device does not support effects.") + return await cast( + LightEffectModule, self.modules["LightEffectModule"] + ).set_effect(effect) + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + if not self.is_custom_effects: + raise KasaException("Device does not support setting custom effects.") + + # Light Strip methods + + @property + def length(self) -> int: + """Return length of the light strip.""" + return 1 diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 9e4b6fdb6..1bbf1e660 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -70,6 +70,7 @@ def _test_property_getters(): or name.startswith("valid_temperature_range") or name.startswith("hsv") or name.startswith("effect") + or name.startswith("led") ): continue try: From 46a4a2758701adfbf5d878d1e8833e39519203f8 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 3 May 2024 17:26:41 +0100 Subject: [PATCH 02/13] Simplify interfaces --- kasa/__init__.py | 1 - kasa/bulb.py | 33 +++-- kasa/device.py | 5 + kasa/dimmer.py | 146 -------------------- kasa/iot/iotbulb.py | 81 +++++++---- kasa/iot/iotdevice.py | 5 + kasa/iot/iotdimmer.py | 33 ++++- kasa/iot/iotlightstrip.py | 10 +- kasa/iot/iotplug.py | 6 +- kasa/iot/iotstrip.py | 3 +- kasa/plug.py | 36 +++-- kasa/smart/modules/lighttransitionmodule.py | 11 -- kasa/smart/smartdevice.py | 129 +++++------------ kasa/tests/fakeprotocol_smart.py | 8 +- kasa/tests/test_plug.py | 2 +- 15 files changed, 194 insertions(+), 315 deletions(-) delete mode 100644 kasa/dimmer.py diff --git a/kasa/__init__.py b/kasa/__init__.py index ef0443f8b..e0325ccc8 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -26,7 +26,6 @@ DeviceFamilyType, EncryptType, ) -from kasa.dimmer import TurnOnBehavior, TurnOnBehaviors from kasa.discover import Discover from kasa.emeterstatus import EmeterStatus from kasa.exceptions import ( diff --git a/kasa/bulb.py b/kasa/bulb.py index 131d951d3..158e37d92 100644 --- a/kasa/bulb.py +++ b/kasa/bulb.py @@ -1,4 +1,4 @@ -"""Module for bulb and light base class.""" +"""Module for Device base class.""" from __future__ import annotations @@ -7,7 +7,7 @@ from pydantic.v1 import BaseModel -from .dimmer import Dimmer +from .device import Device class ColorTempRange(NamedTuple): @@ -42,7 +42,7 @@ class BulbPreset(BaseModel): mode: Optional[int] # noqa: UP007 -class Bulb(Dimmer, ABC): +class Bulb(Device, ABC): """Base class for TP-Link Bulb.""" def _raise_for_invalid_brightness(self, value): @@ -85,6 +85,11 @@ def hsv(self) -> HSV: def color_temp(self) -> int: """Whether the bulb supports color temperature changes.""" + @property + @abstractmethod + def brightness(self) -> int: + """Return the current brightness in percentage.""" + @abstractmethod async def set_hsv( self, @@ -116,18 +121,30 @@ async def set_color_temp( :param int transition: transition in milliseconds. """ + @abstractmethod + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + @property @abstractmethod def presets(self) -> list[BulbPreset]: """Return a list of available bulb setting presets.""" -class LightEffect(Bulb, ABC): - """Base interface to represent a device that supports light effects.""" +class LightStrip(Bulb, ABC): + """Base interface to represent a LightStrip device.""" @property @abstractmethod - def is_custom_effects(self) -> bool: + def has_custom_effects(self) -> bool: """Return True if the device supports setting custom effects.""" @property @@ -175,10 +192,6 @@ async def set_custom_effect( :param str effect_dict: The custom effect dict to set """ - -class LightStrip(LightEffect, ABC): - """Base interface to represent a LightStrip device.""" - @property @abstractmethod def length(self) -> int: diff --git a/kasa/device.py b/kasa/device.py index 4cb6bd989..de9db95ad 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -333,6 +333,11 @@ def _add_feature(self, feature: Feature): assert feature.id is not None # TODO: hack for typing # noqa: S101 self._features[feature.id] = feature + @property + @abstractmethod + def has_led(self) -> bool: + """Return True if the device supports led.""" + @property @abstractmethod def has_emeter(self) -> bool: diff --git a/kasa/dimmer.py b/kasa/dimmer.py deleted file mode 100644 index ca7f4f0db..000000000 --- a/kasa/dimmer.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Module for bulb and light base class.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from enum import Enum -from typing import Optional - -from pydantic.v1 import BaseModel, Field, root_validator - -from .device import Device - - -class BehaviorMode(str, Enum): - """Enum to present type of turn on behavior.""" - - #: Return to the last state known state. - Last = "last_status" - #: Use chosen preset. - Preset = "customize_preset" - - -class TurnOnBehavior(BaseModel): - """Model to present a single turn on behavior. - - :param int preset: the index number of wanted preset. - :param BehaviorMode mode: last status or preset mode. - If you are changing existing settings, you should not set this manually. - - To change the behavior, it is only necessary to change the :attr:`preset` field - to contain either the preset index, or ``None`` for the last known state. - """ - - #: Index of preset to use, or ``None`` for the last known state. - preset: Optional[int] = Field(alias="index", default=None) # noqa: UP007 - #: Wanted behavior - mode: BehaviorMode - - @root_validator - def _mode_based_on_preset(cls, values): - """Set the mode based on the preset value.""" - if values["preset"] is not None: - values["mode"] = BehaviorMode.Preset - else: - values["mode"] = BehaviorMode.Last - - return values - - class Config: - """Configuration to make the validator run when changing the values.""" - - validate_assignment = True - - -class TurnOnBehaviors(BaseModel): - """Model to contain turn on behaviors.""" - - #: The behavior when the bulb is turned on programmatically. - soft: TurnOnBehavior = Field(alias="soft_on") - #: The behavior when the bulb has been off from mains power. - hard: TurnOnBehavior = Field(alias="hard_on") - - -class FadeType(Enum): - """Fade on/off setting.""" - - FadeOn = "fade_on" - FadeOff = "fade_off" - - -class ButtonAction(Enum): - """Button action.""" - - NoAction = "none" - Instant = "instant_on_off" - Gentle = "gentle_on_off" - Preset = "customize_preset" - - -class ActionType(Enum): - """Button action.""" - - DoubleClick = "double_click_action" - LongPress = "long_press_action" - - -class Dimmer(Device, ABC): - """Base class for devices that are dimmers.""" - - @property - @abstractmethod - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - - @property - @abstractmethod - def brightness(self) -> int: - """Return the current brightness in percentage.""" - - @abstractmethod - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> dict: - """Set the brightness in percentage. - - Note, transition is not supported and will be ignored. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ - - @abstractmethod - async def set_dimmer_transition(self, brightness: int, transition: int): - """Turn the bulb on to brightness percentage over transition milliseconds. - - A brightness value of 0 will turn off the dimmer. - """ - - @property - @abstractmethod - def is_transitions(self): - """Return True if the dimmer has transition settings.""" - - @abstractmethod - async def set_fade_time(self, fade_type: FadeType, time: int): - """Set time for fade in / fade out.""" - - @property - def is_on_behaviours(self): - """Return True if the device has turn on behaviour settings.""" - - @abstractmethod - async def get_behaviors(self): - """Return button behavior settings.""" - - @abstractmethod - async def get_turn_on_behavior(self) -> TurnOnBehaviors: - """Return the behavior for turning the bulb on.""" - - @abstractmethod - async def set_turn_on_behavior(self, behavior: TurnOnBehaviors): - """Set the behavior for turning the bulb on. - - If you do not want to manually construct the behavior object, - you should use :func:`get_turn_on_behavior` to get the current settings. - """ diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 2d57d9ebd..d9456e969 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -4,17 +4,70 @@ import logging import re -from typing import cast +from enum import Enum +from typing import Optional, cast + +from pydantic.v1 import BaseModel, Field, root_validator from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..dimmer import FadeType, TurnOnBehaviors from ..feature import Feature from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage + +class BehaviorMode(str, Enum): + """Enum to present type of turn on behavior.""" + + #: Return to the last state known state. + Last = "last_status" + #: Use chosen preset. + Preset = "customize_preset" + + +class TurnOnBehavior(BaseModel): + """Model to present a single turn on behavior. + + :param int preset: the index number of wanted preset. + :param BehaviorMode mode: last status or preset mode. + If you are changing existing settings, you should not set this manually. + + To change the behavior, it is only necessary to change the :attr:`preset` field + to contain either the preset index, or ``None`` for the last known state. + """ + + #: Index of preset to use, or ``None`` for the last known state. + preset: Optional[int] = Field(alias="index", default=None) # noqa: UP007 + #: Wanted behavior + mode: BehaviorMode + + @root_validator + def _mode_based_on_preset(cls, values): + """Set the mode based on the preset value.""" + if values["preset"] is not None: + values["mode"] = BehaviorMode.Preset + else: + values["mode"] = BehaviorMode.Last + + return values + + class Config: + """Configuration to make the validator run when changing the values.""" + + validate_assignment = True + + +class TurnOnBehaviors(BaseModel): + """Model to contain turn on behaviors.""" + + #: The behavior when the bulb is turned on programmatically. + soft: TurnOnBehavior = Field(alias="soft_on") + #: The behavior when the bulb has been off from mains power. + hard: TurnOnBehavior = Field(alias="hard_on") + + TPLINK_KELVIN = { "LB130": ColorTempRange(2500, 9000), "LB120": ColorTempRange(2700, 6500), @@ -275,14 +328,6 @@ async def set_turn_on_behavior(self, behavior: TurnOnBehaviors): self.LIGHT_SERVICE, "set_default_behavior", behavior.dict(by_alias=True) ) - @requires_update - async def get_behaviors(self): - """Return button behavior settings.""" - behaviors = await self._query_helper( - self.LIGHT_SERVICE, "get_default_behavior", {} - ) - return behaviors - async def get_light_state(self) -> dict[str, dict]: """Query the light state.""" # TODO: add warning and refer to use light.state? @@ -313,22 +358,6 @@ async def set_light_state( ) return light_state - @property - def is_transitions(self): - """Return True if the bulb has transition settings.""" - - async def set_fade_time(self, fade_type: FadeType, time: int): - """Set time for fade in / fade out.""" - # TODO will this actually work - await self.set_light_state({"on_off": self.is_on}, transition=time) - - async def set_dimmer_transition(self, brightness: int, transition: int): - """Turn the bulb on to brightness percentage over transition milliseconds. - - A brightness value of 0 will turn off the bulb. - """ - await self.set_brightness(brightness, transition) - @property # type: ignore @requires_update def hsv(self) -> HSV: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 81b5eddac..2f4f406e7 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -282,6 +282,11 @@ def has_emeter(self) -> bool: """Return True if device has an energy meter.""" return "ENE" in self._legacy_features + @property + def has_led(self) -> bool: + """Return True if the device supports led.""" + return False + async def get_sys_info(self) -> dict[str, Any]: """Retrieve system information.""" return await self._query_helper("system", "get_sysinfo") diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 4865a95cd..ded1da3c7 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -2,19 +2,43 @@ from __future__ import annotations +from enum import Enum from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..dimmer import ActionType, ButtonAction, FadeType from ..feature import Feature +from ..plug import Dimmer from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug from .modules import AmbientLight, Motion -class IotDimmer(IotPlug): +class ButtonAction(Enum): + """Button action.""" + + NoAction = "none" + Instant = "instant_on_off" + Gentle = "gentle_on_off" + Preset = "customize_preset" + + +class ActionType(Enum): + """Button action.""" + + DoubleClick = "double_click_action" + LongPress = "long_press_action" + + +class FadeType(Enum): + """Fade on/off setting.""" + + FadeOn = "fade_on" + FadeOff = "fade_off" + + +class IotDimmer(IotPlug, Dimmer): r"""Representation of a TP-Link Smart Dimmer. Dimmers work similarly to plugs, but provide also support for @@ -169,11 +193,6 @@ async def set_dimmer_transition(self, brightness: int, transition: int): {"brightness": brightness, "duration": transition}, ) - @property - def is_on_behaviours(self): - """Return True if the dimmer has turn on behaviour settings.""" - return True - @requires_update async def get_behaviors(self): """Return button behavior settings.""" diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 014a6f3df..cb2fb38b5 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -116,11 +116,6 @@ async def set_effect( await self.set_custom_effect(effect_dict) - @property - def is_custom_effects(self) -> bool: - """Return True if the device supports setting custom effects.""" - return True - @requires_update async def set_custom_effect( self, @@ -137,3 +132,8 @@ async def set_custom_effect( "set_lighting_effect", effect_dict, ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return True diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 410fabdcb..785c659f8 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -90,11 +90,9 @@ async def turn_off(self, **kwargs): return await self._query_helper("system", "set_relay_state", {"state": 0}) @property - @requires_update - def is_led(self) -> bool: + def has_led(self) -> bool: """Return True if the device supports led.""" - sys_info = self.sys_info - return "led_off" in sys_info + return True @property # type: ignore @requires_update diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 9e99a0748..69511d29d 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -10,6 +10,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import KasaException +from ..plug import Strip from ..protocol import BaseProtocol from .iotdevice import ( EmeterStatus, @@ -32,7 +33,7 @@ def merge_sums(dicts): return total_dict -class IotStrip(IotDevice): +class IotStrip(IotDevice, Strip): r"""Representation of a TP-Link Smart Power Strip. A strip consists of the parent device and its children. diff --git a/kasa/plug.py b/kasa/plug.py index f73c6f2f3..9c8193c22 100644 --- a/kasa/plug.py +++ b/kasa/plug.py @@ -1,5 +1,7 @@ """Module for a TPlink device with Led.""" +from __future__ import annotations + import logging from abc import ABC, abstractmethod @@ -8,13 +10,8 @@ _LOGGER = logging.getLogger(__name__) -class Led(Device, ABC): - """Base class to represent a device with an LED.""" - - @property - @abstractmethod - def is_led(self) -> bool: - """Return True if the device supports led.""" +class Plug(Device, ABC): + """Base class to represent a plug.""" @property @abstractmethod @@ -26,13 +23,30 @@ async def set_led(self, state: bool): """Set the state of the led (night mode).""" -class Plug(Led, ABC): - """Base class to represent a Plug.""" - - class WallSwitch(Plug, ABC): """Base class to represent a Wall Switch.""" class Strip(Plug, ABC): """Base class to represent a Power strip.""" + + +class Dimmer(Device, ABC): + """Base class for devices that are dimmers.""" + + @property + @abstractmethod + def brightness(self) -> int: + """Return the current brightness in percentage.""" + + @abstractmethod + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index 7e639ba30..1cb7f48a6 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING -from ...dimmer import FadeType from ...exceptions import KasaException from ...feature import Feature from ..smartmodule import SmartModule @@ -172,16 +171,6 @@ async def set_turn_off_transition(self, seconds: int): {"off_state": {**self._turn_on, "duration": seconds}}, ) - async def set_fade_time(self, fade_type: FadeType, time: int): - """Set time for fade in / fade out.""" - if self.supported_version == 1: - await self.set_enabled_v1(time > 0) - else: - if fade_type == FadeType.FadeOn: - await self.set_turn_on_transition(time) - else: - await self.set_turn_off_transition(time) - def query(self) -> dict: """Query to execute during the update cycle.""" # Some devices have the required info in the device info. diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 85afa89cf..16f145be2 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -12,12 +12,11 @@ from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..dimmer import FadeType, TurnOnBehaviors from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..fan import Fan from ..feature import Feature -from ..plug import Plug, Strip +from ..plug import Dimmer, Plug, Strip from ..smartprotocol import SmartProtocol from .modules import ( Brightness, @@ -30,7 +29,6 @@ Firmware, LedModule, LightEffectModule, - LightTransitionModule, TimeModule, ) @@ -48,7 +46,7 @@ # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. -class SmartDevice(LightStrip, Strip, Plug, Bulb, Fan, Device): +class SmartDevice(LightStrip, Strip, Plug, Dimmer, Bulb, Fan, Device): """Base class to represent a SMART protocol based device.""" def __init__( @@ -653,82 +651,6 @@ async def set_fan_speed_level(self, level: int): raise KasaException("Device is not a Fan") await cast(FanModule, self.modules["FanModule"]).set_fan_speed_level(level) - # Dimmer methods - - @property - def is_dimmable(self) -> bool: - """Whether the device supports brightness changes.""" - return "Brightness" in self.modules - - @property - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return cast(Brightness, self.modules["Brightness"]).brightness - - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> dict: - """Set the brightness in percentage. - - Note, transition is not supported and will be ignored. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ - if not self.is_dimmable: # pragma: no cover - raise KasaException("Device is not dimmable.") - - return await cast(Brightness, self.modules["Brightness"]).set_brightness( - brightness - ) - - async def set_dimmer_transition(self, brightness: int, transition: int): - """Turn the bulb on to brightness percentage over transition milliseconds. - - A brightness value of 0 will turn off the dimmer. - """ - if not self.is_transitions and self.is_dimmable: # pragma: no cover - raise KasaException("Device is not dimmable.") - # TODO can add a transition here - return await cast(Brightness, self.modules["Brightness"]).set_brightness( - brightness - ) - - @property - def is_transitions(self): - """Return True if the dimmer has behaviour settings.""" - return "LightTransitionModule" in self.modules - - async def set_fade_time(self, fade_type: FadeType, time: int): - """Set time for fade in / fade out.""" - await cast( - LightTransitionModule, self.modules["LightTransitionModule"] - ).set_fade_time(fade_type, time) - - @property - def is_on_behaviours(self): - """Return True if the device has turn on behaviour settings.""" - return False or "DefaultStatesModule" in self.modules - - async def get_behaviors(self): - """Return button behavior settings.""" - raise KasaException("Device does not support on behaviours.") - - async def get_turn_on_behavior(self) -> TurnOnBehaviors: - """Return the behavior for turning the bulb on.""" - raise KasaException("Device does not support on behaviours.") - - async def set_turn_on_behavior(self, behavior: TurnOnBehaviors): - """Set the behavior for turning the bulb on. - - If you do not want to manually construct the behavior object, - you should use :func:`get_turn_on_behavior` to get the current settings. - """ - raise KasaException("Device does not support on behaviours.") - # Bulb interface methods @property @@ -736,6 +658,11 @@ def is_color(self) -> bool: """Whether the bulb supports color changes.""" return "ColorModule" in self.modules + @property + def is_dimmable(self) -> bool: + """Whether the device supports brightness changes.""" + return "Brightness" in self.modules + @property def is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" @@ -775,6 +702,14 @@ def color_temp(self) -> int: ColorTemperatureModule, self.modules["ColorTemperatureModule"] ).color_temp + @property + def brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return cast(Brightness, self.modules["Brightness"]).brightness + async def set_hsv( self, hue: int, @@ -815,6 +750,23 @@ async def set_color_temp( ColorTemperatureModule, self.modules["ColorTemperatureModule"] ).set_color_temp(temp) + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + if not self.is_dimmable: # pragma: no cover + raise KasaException("Device is not dimmable.") + + return await cast(Brightness, self.modules["Brightness"]).set_brightness( + brightness + ) + @property def presets(self) -> list[BulbPreset]: """Return a list of available bulb setting presets.""" @@ -828,32 +780,27 @@ def has_effects(self) -> bool: # Plug / Wall Switch methods @property - def is_led(self) -> bool: + def has_led(self) -> bool: """Return True if device has a led.""" return "LedModule" in self.modules - @property - def is_plug(self) -> bool: - """Return True if device is a plug.""" - return self.is_led - @property def led(self) -> bool: """Return the state of the led.""" - if not self.is_led: + if not self.has_led: raise KasaException("Device does not support led.") return cast(LedModule, self.modules["LedModule"]).led async def set_led(self, state: bool): """Set the state of the led (night mode).""" - if not self.is_led: + if not self.has_led: raise KasaException("Device does not support led.") return await cast(LedModule, self.modules["LedModule"]).set_led(state) - # Light Effect methods + # LightStrip methods @property - def is_custom_effects(self) -> bool: + def has_custom_effects(self) -> bool: """Return True if the device supports setting custom effects.""" return False @@ -908,7 +855,7 @@ async def set_custom_effect( :param str effect_dict: The custom effect dict to set """ - if not self.is_custom_effects: + if not self.has_custom_effects: raise KasaException("Device does not support setting custom effects.") # Light Strip methods diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index ae1a7ad66..ef3dacd8c 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -218,7 +218,9 @@ def _send_request(self, request_dict: dict): # SMART fixtures started to be generated missing_result := self.FIXTURE_MISSING_MAP.get(method) ) and missing_result[0] in self.components: - result = copy.deepcopy(missing_result[1]) + # Copy to info so it will work with update methods + info[method] = copy.deepcopy(missing_result[1]) + result = copy.deepcopy(info[method]) retval = {"result": result, "error_code": 0} else: # PARAMS error returned for KS240 when get_device_usage called @@ -239,6 +241,10 @@ def _send_request(self, request_dict: dict): elif method == "set_dynamic_light_effect_rule_enable": self._set_light_effect(info, params) return {"error_code": 0} + elif method == "set_led_info": + info["get_led_info"]["led_status"] = params["led_rule"] != "never" + info["get_led_info"]["led_rule"] = params["led_rule"] + return {"error_code": 0} elif method[:4] == "set_": target_method = f"get_{method[4:]}" info[target_method].update(params) diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index 8989c975f..ed2645c23 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -30,7 +30,7 @@ async def test_switch_sysinfo(dev): assert dev.is_wallswitch -@plug_iot +@plug async def test_plug_led(dev): original = dev.led From 3ec909602b1722a65231d0aa4f2b6c11280a4529 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 3 May 2024 17:36:07 +0100 Subject: [PATCH 03/13] Fix non python 3.8 compatible set_led --- kasa/smart/modules/ledmodule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index 6fd0d637d..28817c365 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -55,7 +55,7 @@ async def set_led(self, enable: bool): This should probably be a select with always/never/nightmode. """ rule = "always" if enable else "never" - return await self.call("set_led_info", self.data | {"led_rule": rule}) + return await self.call("set_led_info", dict(self.data, **{"led_rule": rule})) @property def night_mode_settings(self): From 7bf8753c982791351acb64581bffba8d70515cd8 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Wed, 8 May 2024 12:33:17 +0100 Subject: [PATCH 04/13] Add common module interfaces --- kasa/__init__.py | 1 - kasa/bulb.py | 59 ----- kasa/device.py | 10 +- kasa/iot/effects.py | 298 ++++++++++++++++++++++++ kasa/iot/iotdevice.py | 29 +-- kasa/iot/iotdimmer.py | 3 +- kasa/iot/iotlightstrip.py | 37 ++- kasa/iot/iotmodule.py | 10 +- kasa/iot/iotplug.py | 36 +-- kasa/iot/iotstrip.py | 3 +- kasa/iot/modules/__init__.py | 2 + kasa/iot/modules/ledmodule.py | 52 +++++ kasa/iot/modules/lighteffectmodule.py | 114 +++++++++ kasa/modules/__init__.py | 78 +++++++ kasa/modules/ledmodule.py | 31 +++ kasa/modules/lighteffectmodule.py | 74 ++++++ kasa/plug.py | 52 ----- kasa/smart/modules/ledmodule.py | 3 +- kasa/smart/modules/lighteffectmodule.py | 27 ++- kasa/smart/smartdevice.py | 112 +-------- kasa/tests/test_childdevice.py | 1 - kasa/tests/test_common_modules.py | 82 +++++++ kasa/tests/test_lightstrip.py | 3 +- kasa/tests/test_plug.py | 2 +- 24 files changed, 821 insertions(+), 298 deletions(-) create mode 100644 kasa/iot/effects.py create mode 100644 kasa/iot/modules/ledmodule.py create mode 100644 kasa/iot/modules/lighteffectmodule.py create mode 100644 kasa/modules/__init__.py create mode 100644 kasa/modules/ledmodule.py create mode 100644 kasa/modules/lighteffectmodule.py delete mode 100644 kasa/plug.py create mode 100644 kasa/tests/test_common_modules.py diff --git a/kasa/__init__.py b/kasa/__init__.py index e0325ccc8..0e0a29d1a 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -40,7 +40,6 @@ IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) -from kasa.plug import Plug from kasa.protocol import BaseProtocol from kasa.smartprotocol import SmartProtocol diff --git a/kasa/bulb.py b/kasa/bulb.py index 158e37d92..52a722d92 100644 --- a/kasa/bulb.py +++ b/kasa/bulb.py @@ -137,62 +137,3 @@ async def set_brightness( @abstractmethod def presets(self) -> list[BulbPreset]: """Return a list of available bulb setting presets.""" - - -class LightStrip(Bulb, ABC): - """Base interface to represent a LightStrip device.""" - - @property - @abstractmethod - def has_custom_effects(self) -> bool: - """Return True if the device supports setting custom effects.""" - - @property - @abstractmethod - def effect(self) -> dict | str: - """Return effect state or name.""" - - @property - @abstractmethod - def effect_list(self) -> list[str] | None: - """Return built-in effects list. - - Example: - ['Aurora', 'Bubbling Cauldron', ...] - """ - - @abstractmethod - async def set_effect( - self, - effect: str, - *, - brightness: int | None = None, - transition: int | None = None, - ) -> None: - """Set an effect on the device. - - If brightness or transition is defined, - its value will be used instead of the effect-specific default. - - See :meth:`effect_list` for available effects, - or use :meth:`set_custom_effect` for custom effects. - - :param str effect: The effect to set - :param int brightness: The wanted brightness - :param int transition: The wanted transition time - """ - - @abstractmethod - async def set_custom_effect( - self, - effect_dict: dict, - ) -> None: - """Set a custom effect on the device. - - :param str effect_dict: The custom effect dict to set - """ - - @property - @abstractmethod - def length(self) -> int: - """Return length of the light strip.""" diff --git a/kasa/device.py b/kasa/device.py index 5be3d4478..4150e82b2 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Mapping, Sequence, overload +from typing import Any, Callable, Mapping, Sequence, overload from .credentials import Credentials from .device_type import DeviceType @@ -39,6 +39,9 @@ class WifiNetwork: _LOGGER = logging.getLogger(__name__) +CallableType = Callable[["Device", str], ModuleT] + + class Device(ABC): """Common device interface. @@ -345,11 +348,6 @@ def _add_feature(self, feature: Feature): assert feature.id is not None # TODO: hack for typing # noqa: S101 self._features[feature.id] = feature - @property - @abstractmethod - def has_led(self) -> bool: - """Return True if the device supports led.""" - @property @abstractmethod def has_emeter(self) -> bool: diff --git a/kasa/iot/effects.py b/kasa/iot/effects.py new file mode 100644 index 000000000..8b3e7b329 --- /dev/null +++ b/kasa/iot/effects.py @@ -0,0 +1,298 @@ +"""Module for light strip effects (LB*, KL*, KB*).""" + +from __future__ import annotations + +from typing import cast + +EFFECT_AURORA = { + "custom": 0, + "id": "xqUxDhbAhNLqulcuRMyPBmVGyTOyEMEu", + "brightness": 100, + "name": "Aurora", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 1500, + "direction": 4, + "spread": 7, + "repeat_times": 0, + "sequence": [[120, 100, 100], [240, 100, 100], [260, 100, 100], [280, 100, 100]], +} +EFFECT_BUBBLING_CAULDRON = { + "custom": 0, + "id": "tIwTRQBqJpeNKbrtBMFCgkdPTbAQGfRP", + "brightness": 100, + "name": "Bubbling Cauldron", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [100, 270], + "saturation_range": [80, 100], + "brightness_range": [50, 100], + "duration": 0, + "transition": 200, + "init_states": [[270, 100, 100]], + "fadeoff": 1000, + "random_seed": 24, + "backgrounds": [[270, 40, 50]], +} +EFFECT_CANDY_CANE = { + "custom": 0, + "id": "HCOttllMkNffeHjEOLEgrFJjbzQHoxEJ", + "brightness": 100, + "name": "Candy Cane", + "segments": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 700, + "transition": 500, + "direction": 1, + "spread": 1, + "repeat_times": 0, + "sequence": [ + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + ], +} +EFFECT_CHRISTMAS = { + "custom": 0, + "id": "bwTatyinOUajKrDwzMmqxxJdnInQUgvM", + "brightness": 100, + "name": "Christmas", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [136, 146], + "saturation_range": [90, 100], + "brightness_range": [50, 100], + "duration": 5000, + "transition": 0, + "init_states": [[136, 0, 100]], + "fadeoff": 2000, + "random_seed": 100, + "backgrounds": [[136, 98, 75], [136, 0, 0], [350, 0, 100], [350, 97, 94]], +} +EFFECT_FLICKER = { + "custom": 0, + "id": "bCTItKETDFfrKANolgldxfgOakaarARs", + "brightness": 100, + "name": "Flicker", + "segments": [1], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [30, 40], + "saturation_range": [100, 100], + "brightness_range": [50, 100], + "duration": 0, + "transition": 0, + "transition_range": [375, 500], + "init_states": [[30, 81, 80]], +} +EFFECT_HANUKKAH = { + "custom": 0, + "id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD", + "brightness": 100, + "name": "Hanukkah", + "segments": [1], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [200, 210], + "saturation_range": [0, 100], + "brightness_range": [50, 100], + "duration": 1500, + "transition": 0, + "transition_range": [400, 500], + "init_states": [[35, 81, 80]], +} +EFFECT_HAUNTED_MANSION = { + "custom": 0, + "id": "oJnFHsVQzFUTeIOBAhMRfVeujmSauhjJ", + "brightness": 80, + "name": "Haunted Mansion", + "segments": [80], + "expansion_strategy": 2, + "enable": 1, + "type": "random", + "hue_range": [45, 45], + "saturation_range": [10, 10], + "brightness_range": [0, 80], + "duration": 0, + "transition": 0, + "transition_range": [50, 1500], + "init_states": [[45, 10, 100]], + "fadeoff": 200, + "random_seed": 1, + "backgrounds": [[45, 10, 100]], +} +EFFECT_ICICLE = { + "custom": 0, + "id": "joqVjlaTsgzmuQQBAlHRkkPAqkBUiqeb", + "brightness": 70, + "name": "Icicle", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 400, + "direction": 4, + "spread": 3, + "repeat_times": 0, + "sequence": [ + [190, 100, 70], + [190, 100, 70], + [190, 30, 50], + [190, 100, 70], + [190, 100, 70], + ], +} +EFFECT_LIGHTNING = { + "custom": 0, + "id": "ojqpUUxdGHoIugGPknrUcRoyJiItsjuE", + "brightness": 100, + "name": "Lightning", + "segments": [7, 20, 23, 32, 34, 35, 49, 65, 66, 74, 80], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [240, 240], + "saturation_range": [10, 11], + "brightness_range": [90, 100], + "duration": 0, + "transition": 50, + "init_states": [[240, 30, 100]], + "fadeoff": 150, + "random_seed": 600, + "backgrounds": [[200, 100, 100], [200, 50, 10], [210, 10, 50], [240, 10, 0]], +} +EFFECT_OCEAN = { + "custom": 0, + "id": "oJjUMosgEMrdumfPANKbkFmBcAdEQsPy", + "brightness": 30, + "name": "Ocean", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 2000, + "direction": 3, + "spread": 16, + "repeat_times": 0, + "sequence": [[198, 84, 30], [198, 70, 30], [198, 10, 30]], +} +EFFECT_RAINBOW = { + "custom": 0, + "id": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT", + "brightness": 100, + "name": "Rainbow", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 1500, + "direction": 1, + "spread": 12, + "repeat_times": 0, + "sequence": [[0, 100, 100], [100, 100, 100], [200, 100, 100], [300, 100, 100]], +} +EFFECT_RAINDROP = { + "custom": 0, + "id": "QbDFwiSFmLzQenUOPnJrsGqyIVrJrRsl", + "brightness": 30, + "name": "Raindrop", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [200, 200], + "saturation_range": [10, 20], + "brightness_range": [10, 30], + "duration": 0, + "transition": 1000, + "init_states": [[200, 40, 100]], + "fadeoff": 1000, + "random_seed": 24, + "backgrounds": [[200, 40, 0]], +} +EFFECT_SPRING = { + "custom": 0, + "id": "URdUpEdQbnOOechDBPMkKrwhSupLyvAg", + "brightness": 100, + "name": "Spring", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [0, 90], + "saturation_range": [30, 100], + "brightness_range": [90, 100], + "duration": 600, + "transition": 0, + "transition_range": [2000, 6000], + "init_states": [[80, 30, 100]], + "fadeoff": 1000, + "random_seed": 20, + "backgrounds": [[130, 100, 40]], +} +EFFECT_VALENTINES = { + "custom": 0, + "id": "QglBhMShPHUAuxLqzNEefFrGiJwahOmz", + "brightness": 100, + "name": "Valentines", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [340, 340], + "saturation_range": [30, 40], + "brightness_range": [90, 100], + "duration": 600, + "transition": 2000, + "init_states": [[340, 30, 100]], + "fadeoff": 3000, + "random_seed": 100, + "backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], +} + +EFFECTS_LIST_V1 = [ + EFFECT_AURORA, + EFFECT_BUBBLING_CAULDRON, + EFFECT_CANDY_CANE, + EFFECT_CHRISTMAS, + EFFECT_FLICKER, + EFFECT_HANUKKAH, + EFFECT_HAUNTED_MANSION, + EFFECT_ICICLE, + EFFECT_LIGHTNING, + EFFECT_OCEAN, + EFFECT_RAINBOW, + EFFECT_RAINDROP, + EFFECT_SPRING, + EFFECT_VALENTINES, +] + +EFFECT_NAMES_V1: list[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1] +EFFECT_MAPPING_V1 = {effect["name"]: effect for effect in EFFECTS_LIST_V1} diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 1228a896b..9ef0b4150 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -26,7 +26,7 @@ from ..emeterstatus import EmeterStatus from ..exceptions import KasaException from ..feature import Feature -from ..module import ModuleT +from ..module import Module, ModuleT from ..protocol import BaseProtocol from .iotmodule import IotModule from .modules import Emeter, Time @@ -206,15 +206,16 @@ def modules(self) -> dict[str, IotModule]: def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... @overload - def get_module(self, module_type: str) -> IotModule | None: ... + def get_module(self, module_type: type) -> ModuleT | None: ... - def get_module( - self, module_type: type[ModuleT] | str - ) -> ModuleT | IotModule | None: + @overload + def get_module(self, module_type: str) -> Module | None: ... + + def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: """Return the module from the device modules or None if not present.""" if isinstance(module_type, str): module_name = module_type.lower() - elif issubclass(module_type, IotModule): + elif issubclass(module_type, Module): module_name = module_type.__name__.lower() else: return None @@ -303,11 +304,6 @@ def has_emeter(self) -> bool: """Return True if device has an energy meter.""" return "ENE" in self._legacy_features - @property - def has_led(self) -> bool: - """Return True if the device supports led.""" - return False - async def get_sys_info(self) -> dict[str, Any]: """Retrieve system information.""" return await self._query_helper("system", "get_sysinfo") @@ -329,10 +325,11 @@ async def update(self, update_children: bool = True): self._last_update = response self._set_sys_info(response["system"]["get_sysinfo"]) + await self._modular_update(req) + if not self._features: await self._initialize_features() - await self._modular_update(req) self._set_sys_info(self._last_update["system"]["get_sysinfo"]) async def _initialize_features(self): @@ -355,12 +352,18 @@ async def _initialize_features(self): ) ) + for module in self._modules.values(): + module._initialize_features() + for module_feat in module._module_features.values(): + self._add_feature(module_feat) + async def _modular_update(self, req: dict) -> None: """Execute an update query.""" if self.has_emeter: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" ) + # TODO self.add_module("emeter", Emeter(self, self.emeter_type)) # TODO: perhaps modules should not have unsupported modules, @@ -370,8 +373,6 @@ async def _modular_update(self, req: dict) -> None: for module in self.modules.values(): if module.is_supported: supported[module._module] = module - for module_feat in module._module_features.values(): - self._add_feature(module_feat) self._supported_modules = supported diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index ded1da3c7..672b22656 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -8,7 +8,6 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..feature import Feature -from ..plug import Dimmer from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug @@ -38,7 +37,7 @@ class FadeType(Enum): FadeOff = "fade_off" -class IotDimmer(IotPlug, Dimmer): +class IotDimmer(IotPlug): r"""Representation of a TP-Link Smart Dimmer. Dimmers work similarly to plugs, but provide also support for diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index cb2fb38b5..bf17dda17 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -2,16 +2,16 @@ from __future__ import annotations -from ..bulb import LightStrip from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..effects import EFFECT_NAMES_V1 from ..protocol import BaseProtocol from .iotbulb import IotBulb from .iotdevice import KasaException, requires_update +from .modules.lighteffectmodule import LightEffectModule -class IotLightStrip(IotBulb, LightStrip): +class IotLightStrip(IotBulb): """Representation of a TP-Link Smart light strip. Light strips work similarly to bulbs, but use a different service for controlling, @@ -55,6 +55,10 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip + self._light_effect_module = LightEffectModule( + self, "smartlife.iot.lighting_effect" + ) + self.add_module("lighteffectmodule", self._light_effect_module) @property # type: ignore @requires_update @@ -74,6 +78,8 @@ def effect(self) -> dict: 'id': '', 'name': ''} """ + # LightEffectModule returns the current effect name + # so return the dict here for backwards compatability return self.sys_info["lighting_effect_state"] @property # type: ignore @@ -84,6 +90,8 @@ def effect_list(self) -> list[str] | None: Example: ['Aurora', 'Bubbling Cauldron', ...] """ + # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value + # so return the original effect names here for backwards compatability return EFFECT_NAMES_V1 if self.has_effects else None @requires_update @@ -106,15 +114,9 @@ async def set_effect( :param int brightness: The wanted brightness :param int transition: The wanted transition time """ - if effect not in EFFECT_MAPPING_V1: - raise KasaException(f"The effect {effect} is not a built in effect.") - effect_dict = EFFECT_MAPPING_V1[effect] - if brightness is not None: - effect_dict["brightness"] = brightness - if transition is not None: - effect_dict["transition"] = transition - - await self.set_custom_effect(effect_dict) + await self._light_effect_module.set_effect( + effect, brightness=brightness, transition=transition + ) @requires_update async def set_custom_effect( @@ -127,13 +129,4 @@ async def set_custom_effect( """ if not self.has_effects: raise KasaException("Bulb does not support effects.") - await self._query_helper( - "smartlife.iot.lighting_effect", - "set_lighting_effect", - effect_dict, - ) - - @property - def has_custom_effects(self) -> bool: - """Return True if the device supports setting custom effects.""" - return True + await self._light_effect_module.set_custom_effect(effect_dict) diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index d8fb4812b..ca0c3adb7 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -43,13 +43,19 @@ def estimated_query_response_size(self): @property def data(self): """Return the module specific raw data from the last update.""" - if self._module not in self._device._last_update: + dev = self._device + q = self.query() + + if not q: + return dev.sys_info + + if self._module not in dev._last_update: raise KasaException( f"You need to call update() prior accessing module data" f" for '{self._module}'" ) - return self._device._last_update[self._module] + return dev._last_update[self._module] @property def is_supported(self) -> bool: diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 785c659f8..fe1b691e8 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -6,16 +6,14 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature -from ..plug import Plug, WallSwitch from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update -from .modules import Antitheft, Cloud, Schedule, Time, Usage +from .modules import Antitheft, Cloud, LedModule, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) -class IotPlug(IotDevice, Plug): +class IotPlug(IotDevice): r"""Representation of a TP-Link Smart Plug. To initialize, you have to await :func:`update()` at least once. @@ -59,20 +57,8 @@ def __init__( self.add_module("antitheft", Antitheft(self, "anti_theft")) self.add_module("time", Time(self, "time")) self.add_module("cloud", Cloud(self, "cnCloud")) - - async def _initialize_features(self): - await super()._initialize_features() - - self._add_feature( - Feature( - device=self, - name="LED", - icon="mdi:led-{state}", - attribute_getter="led", - attribute_setter="set_led", - type=Feature.Type.Switch, - ) - ) + self._led_module = LedModule(self, "system") + self.add_module("ledmodule", self._led_module) @property # type: ignore @requires_update @@ -89,26 +75,18 @@ async def turn_off(self, **kwargs): """Turn the switch off.""" return await self._query_helper("system", "set_relay_state", {"state": 0}) - @property - def has_led(self) -> bool: - """Return True if the device supports led.""" - return True - @property # type: ignore @requires_update def led(self) -> bool: """Return the state of the led.""" - sys_info = self.sys_info - return bool(1 - sys_info["led_off"]) + return self._led_module.led async def set_led(self, state: bool): """Set the state of the led (night mode).""" - return await self._query_helper( - "system", "set_led_off", {"off": int(not state)} - ) + return await self._led_module.set_led(state) -class IotWallSwitch(IotPlug, WallSwitch): +class IotWallSwitch(IotPlug): """Representation of a TP-Link Smart Wall Switch.""" def __init__( diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 69511d29d..9e99a0748 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -10,7 +10,6 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import KasaException -from ..plug import Strip from ..protocol import BaseProtocol from .iotdevice import ( EmeterStatus, @@ -33,7 +32,7 @@ def merge_sums(dicts): return total_dict -class IotStrip(IotDevice, Strip): +class IotStrip(IotDevice): r"""Representation of a TP-Link Smart Power Strip. A strip consists of the parent device and its children. diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 41e03bbdd..f061e6070 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -5,6 +5,7 @@ from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter +from .ledmodule import LedModule from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -17,6 +18,7 @@ "Cloud", "Countdown", "Emeter", + "LedModule", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/ledmodule.py b/kasa/iot/modules/ledmodule.py new file mode 100644 index 000000000..b2d800271 --- /dev/null +++ b/kasa/iot/modules/ledmodule.py @@ -0,0 +1,52 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...feature import Feature +from ...modules.ledmodule import LedModule as BaseLedModule +from ..iotmodule import IotModule + + +class LedModule(IotModule, BaseLedModule): + """Implementation of led controls.""" + + REQUIRED_COMPONENT = "led" + QUERY_GETTER_NAME = "get_led_info" + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device=device, + container=self, + name="LED", + icon="mdi:led-{state}", + attribute_getter="led", + attribute_setter="set_led", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def mode(self): + """LED mode setting. + + "always", "never" + """ + return "always" if self.led else "never" + + @property + def led(self) -> bool: + """Return the state of the led.""" + sys_info = self.data + return bool(1 - sys_info["led_off"]) + + async def set_led(self, state: bool): + """Set the state of the led (night mode).""" + return await self.call("set_led_off", {"off": int(not state)}) diff --git a/kasa/iot/modules/lighteffectmodule.py b/kasa/iot/modules/lighteffectmodule.py new file mode 100644 index 000000000..58aa6325b --- /dev/null +++ b/kasa/iot/modules/lighteffectmodule.py @@ -0,0 +1,114 @@ +"""Module for light effects.""" + +from __future__ import annotations + +from ...feature import Feature +from ...modules.lighteffectmodule import LightEffectModule as BaseLightEffectModule +from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..iotmodule import IotModule + + +class LightEffectModule(IotModule, BaseLightEffectModule): + """Implementation of dynamic light effects.""" + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + "Light effect", + container=self, + attribute_getter="effect", + attribute_setter="set_effect", + category=Feature.Category.Config, + type=Feature.Type.Choice, + choices_getter="effect_list", + ) + ) + + @property + def effect(self) -> str: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + if ( + (state := self.data.get("lighting_effect_state")) + and state.get("enable") + and (name := state.get("name")) + and name in EFFECT_NAMES_V1 + ): + return name + return self.LIGHT_EFFECTS_OFF + + @property + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + effect_list = [self.LIGHT_EFFECTS_OFF] + effect_list.extend(EFFECT_NAMES_V1) + return effect_list + + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + if effect == self.LIGHT_EFFECTS_OFF: + effect_dict = dict(self.data["lighting_effect_state"]) + effect_dict["enable"] = 0 + elif effect not in EFFECT_MAPPING_V1: + raise ValueError(f"The effect {effect} is not a built in effect.") + else: + effect_dict = EFFECT_MAPPING_V1[effect] + if brightness is not None: + effect_dict["brightness"] = brightness + if transition is not None: + effect_dict["transition"] = transition + + await self.set_custom_effect(effect_dict) + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + return await self.call( + "set_lighting_effect", + effect_dict, + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return True + + def query(self): + """Return the base query.""" + return {} diff --git a/kasa/modules/__init__.py b/kasa/modules/__init__.py new file mode 100644 index 000000000..4e1dbafb4 --- /dev/null +++ b/kasa/modules/__init__.py @@ -0,0 +1,78 @@ +"""Sub Package for device family independant modules.""" + +# Iot Modules +from ..iot.modules.ambientlight import AmbientLight +from ..iot.modules.antitheft import Antitheft +from ..iot.modules.cloud import Cloud +from ..iot.modules.countdown import Countdown +from ..iot.modules.emeter import Emeter +from ..iot.modules.motion import Motion +from ..iot.modules.rulemodule import Rule, RuleModule +from ..iot.modules.schedule import Schedule +from ..iot.modules.time import Time +from ..iot.modules.usage import Usage + +# Smart Modules +from ..smart.modules.alarmmodule import AlarmModule +from ..smart.modules.autooffmodule import AutoOffModule +from ..smart.modules.battery import BatterySensor +from ..smart.modules.brightness import Brightness +from ..smart.modules.childdevicemodule import ChildDeviceModule +from ..smart.modules.cloudmodule import CloudModule +from ..smart.modules.colormodule import ColorModule +from ..smart.modules.colortemp import ColorTemperatureModule +from ..smart.modules.devicemodule import DeviceModule +from ..smart.modules.energymodule import EnergyModule +from ..smart.modules.fanmodule import FanModule +from ..smart.modules.firmware import Firmware +from ..smart.modules.frostprotection import FrostProtectionModule +from ..smart.modules.humidity import HumiditySensor +from ..smart.modules.lighttransitionmodule import LightTransitionModule +from ..smart.modules.reportmodule import ReportModule +from ..smart.modules.temperature import TemperatureSensor +from ..smart.modules.temperaturecontrol import TemperatureControl +from ..smart.modules.timemodule import TimeModule +from ..smart.modules.waterleak import WaterleakSensor + +# Common Modules +from .ledmodule import LedModule +from .lighteffectmodule import LightEffectModule + +__all__ = [ + # Common modules + "LightEffectModule", + "LedModule", + # Iot Modules + "AmbientLight", + "Antitheft", + "Cloud", + "Countdown", + "Emeter", + "Motion", + "Rule", + "RuleModule", + "Schedule", + "Time", + "Usage", + # Smart Modules + "AlarmModule", + "TimeModule", + "EnergyModule", + "DeviceModule", + "ChildDeviceModule", + "BatterySensor", + "HumiditySensor", + "TemperatureSensor", + "TemperatureControl", + "ReportModule", + "AutoOffModule", + "Brightness", + "FanModule", + "Firmware", + "CloudModule", + "LightTransitionModule", + "ColorTemperatureModule", + "ColorModule", + "WaterleakSensor", + "FrostProtectionModule", +] diff --git a/kasa/modules/ledmodule.py b/kasa/modules/ledmodule.py new file mode 100644 index 000000000..7d9e5e6b9 --- /dev/null +++ b/kasa/modules/ledmodule.py @@ -0,0 +1,31 @@ +"""Module for base light effect module.""" + +from __future__ import annotations + +from ..module import Module + + +class LedModule(Module): + """Base interface to represent a LED module.""" + + # This needs to implement abstract methods for typing to work with + # overload get_module(type[ModuleT]) -> ModuleT: + # https://discuss.python.org/t/add-abstracttype-to-the-typing-module/21996 + + @property + def led(self) -> bool: + """Return current led status.""" + raise NotImplementedError() + + async def set_led(self, enable: bool) -> None: + """Set led.""" + raise NotImplementedError() + + def query(self) -> dict: + """Query to execute during the update cycle.""" + raise NotImplementedError() + + @property + def data(self): + """Query to execute during the update cycle.""" + raise NotImplementedError() diff --git a/kasa/modules/lighteffectmodule.py b/kasa/modules/lighteffectmodule.py new file mode 100644 index 000000000..cddb9fd98 --- /dev/null +++ b/kasa/modules/lighteffectmodule.py @@ -0,0 +1,74 @@ +"""Module for base light effect module.""" + +from __future__ import annotations + +from ..module import Module + + +class LightEffectModule(Module): + """Interface to represent a light effect module.""" + + # This needs to implement abstract methods for typing to work with + # overload get_module(type[ModuleT]) -> ModuleT: + # https://discuss.python.org/t/add-abstracttype-to-the-typing-module/21996 + + LIGHT_EFFECTS_OFF = "Off" + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + raise NotImplementedError() + + @property + def effect(self) -> str: + """Return effect state or name.""" + raise NotImplementedError() + + @property + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + raise NotImplementedError() + + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + raise NotImplementedError() + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + raise NotImplementedError() + + def query(self) -> dict: + """Query to execute during the update cycle.""" + raise NotImplementedError() + + @property + def data(self): + """Query to execute during the update cycle.""" + raise NotImplementedError() diff --git a/kasa/plug.py b/kasa/plug.py deleted file mode 100644 index 9c8193c22..000000000 --- a/kasa/plug.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Module for a TPlink device with Led.""" - -from __future__ import annotations - -import logging -from abc import ABC, abstractmethod - -from .device import Device - -_LOGGER = logging.getLogger(__name__) - - -class Plug(Device, ABC): - """Base class to represent a plug.""" - - @property - @abstractmethod - def led(self) -> bool: - """Return the state of the led.""" - - @abstractmethod - async def set_led(self, state: bool): - """Set the state of the led (night mode).""" - - -class WallSwitch(Plug, ABC): - """Base class to represent a Wall Switch.""" - - -class Strip(Plug, ABC): - """Base class to represent a Power strip.""" - - -class Dimmer(Device, ABC): - """Base class for devices that are dimmers.""" - - @property - @abstractmethod - def brightness(self) -> int: - """Return the current brightness in percentage.""" - - @abstractmethod - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> dict: - """Set the brightness in percentage. - - Note, transition is not supported and will be ignored. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index 28817c365..0d66c0ae2 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -5,13 +5,14 @@ from typing import TYPE_CHECKING from ...feature import Feature +from ...modules.ledmodule import LedModule as BaseLedModule from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LedModule(SmartModule): +class LedModule(SmartModule, BaseLedModule): """Implementation of led controls.""" REQUIRED_COMPONENT = "led" diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffectmodule.py index 7f03b8ff6..2b8f6707c 100644 --- a/kasa/smart/modules/lighteffectmodule.py +++ b/kasa/smart/modules/lighteffectmodule.py @@ -7,13 +7,14 @@ from typing import TYPE_CHECKING, Any from ...feature import Feature +from ...modules.lighteffectmodule import LightEffectModule as BaseLightEffectModule from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightEffectModule(SmartModule): +class LightEffectModule(SmartModule, BaseLightEffectModule): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_effect" @@ -22,7 +23,6 @@ class LightEffectModule(SmartModule): "L1": "Party", "L2": "Relax", } - LIGHT_EFFECTS_OFF = "Off" def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) @@ -63,7 +63,7 @@ def _initialize_effects(self) -> dict[str, dict[str, Any]]: return effects @property - def effect_list(self) -> list[str] | None: + def effect_list(self) -> list[str]: """Return built-in effects list. Example: @@ -89,6 +89,9 @@ def effect(self) -> str: async def set_effect( self, effect: str, + *, + brightness: int | None = None, + transition: int | None = None, ) -> None: """Set an effect for the device. @@ -107,6 +110,24 @@ async def set_effect( params["id"] = effect_id return await self.call("set_dynamic_light_effect_rule_enable", params) + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + raise NotImplementedError( + "Device does not support setting custom effects. " + "Use has_custom_effects to check for support." + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return False + def query(self) -> dict: """Query to execute during the update cycle.""" return {self.QUERY_GETTER_NAME: {"start_index": 0}} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index d72167c04..7feb7688f 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -8,7 +8,7 @@ from typing import Any, Mapping, Sequence, cast, overload from ..aestransport import AesTransport -from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange, LightStrip +from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -16,8 +16,7 @@ from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..fan import Fan from ..feature import Feature -from ..module import ModuleT -from ..plug import Dimmer, Plug, Strip +from ..module import Module, ModuleT from ..smartprotocol import SmartProtocol from .modules import ( Brightness, @@ -28,8 +27,6 @@ EnergyModule, FanModule, Firmware, - LedModule, - LightEffectModule, TimeModule, ) from .smartmodule import SmartModule @@ -46,7 +43,7 @@ # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. -class SmartDevice(LightStrip, Strip, Plug, Dimmer, Bulb, Fan, Device): +class SmartDevice(Bulb, Fan, Device): """Base class to represent a SMART protocol based device.""" def __init__( @@ -312,15 +309,16 @@ async def _initialize_features(self): def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... @overload - def get_module(self, module_type: str) -> SmartModule | None: ... + def get_module(self, module_type: type) -> Module | None: ... - def get_module( - self, module_type: type[ModuleT] | str - ) -> ModuleT | SmartModule | None: + @overload + def get_module(self, module_type: str) -> Module | None: ... + + def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: """Return the module from the device modules or None if not present.""" if isinstance(module_type, str): module_name = module_type - elif issubclass(module_type, SmartModule): + elif issubclass(module_type, Module): module_name = module_type.__name__ else: return None @@ -674,7 +672,7 @@ def is_color(self) -> bool: @property def is_dimmable(self) -> bool: - """Whether the device supports brightness changes.""" + """Whether the bulb supports brightness changes.""" return "Brightness" in self.modules @property @@ -775,7 +773,7 @@ async def set_brightness( :param int transition: transition in milliseconds. """ if not self.is_dimmable: # pragma: no cover - raise KasaException("Device is not dimmable.") + raise KasaException("Bulb is not dimmable.") return await cast(Brightness, self.modules["Brightness"]).set_brightness( brightness @@ -790,91 +788,3 @@ def presets(self) -> list[BulbPreset]: def has_effects(self) -> bool: """Return True if the device supports effects.""" return "LightEffectModule" in self.modules - - # Plug / Wall Switch methods - - @property - def has_led(self) -> bool: - """Return True if device has a led.""" - return "LedModule" in self.modules - - @property - def led(self) -> bool: - """Return the state of the led.""" - if not self.has_led: - raise KasaException("Device does not support led.") - return cast(LedModule, self.modules["LedModule"]).led - - async def set_led(self, state: bool): - """Set the state of the led (night mode).""" - if not self.has_led: - raise KasaException("Device does not support led.") - return await cast(LedModule, self.modules["LedModule"]).set_led(state) - - # LightStrip methods - - @property - def has_custom_effects(self) -> bool: - """Return True if the device supports setting custom effects.""" - return False - - @property - def effect(self) -> str: - """Return effect state or name.""" - if not self.has_effects: - raise KasaException("Device does not support effects.") - return cast(LightEffectModule, self.modules["LightEffectModule"]).effect - - @property - def effect_list(self) -> list[str] | None: - """Return built-in effects list. - - Example: - ['Aurora', 'Bubbling Cauldron', ...] - """ - if not self.has_effects: - raise KasaException("Device does not support effects.") - return cast(LightEffectModule, self.modules["LightEffectModule"]).effect_list - - async def set_effect( - self, - effect: str, - *, - brightness: int | None = None, - transition: int | None = None, - ) -> None: - """Set an effect on the device. - - If brightness or transition is defined, - its value will be used instead of the effect-specific default. - - See :meth:`effect_list` for available effects, - or use :meth:`set_custom_effect` for custom effects. - - :param str effect: The effect to set - :param int brightness: The wanted brightness - :param int transition: The wanted transition time - """ - if not self.has_effects: - raise KasaException("Device does not support effects.") - return await cast( - LightEffectModule, self.modules["LightEffectModule"] - ).set_effect(effect) - - async def set_custom_effect( - self, - effect_dict: dict, - ) -> None: - """Set a custom effect on the device. - - :param str effect_dict: The custom effect dict to set - """ - if not self.has_custom_effects: - raise KasaException("Device does not support setting custom effects.") - - # Light Strip methods - - @property - def length(self) -> int: - """Return length of the light strip.""" - return 1 diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 1bbf1e660..9e4b6fdb6 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -70,7 +70,6 @@ def _test_property_getters(): or name.startswith("valid_temperature_range") or name.startswith("hsv") or name.startswith("effect") - or name.startswith("led") ): continue try: diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py new file mode 100644 index 000000000..b6e22dddc --- /dev/null +++ b/kasa/tests/test_common_modules.py @@ -0,0 +1,82 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import Device +from kasa.modules import LedModule, LightEffectModule +from kasa.tests.device_fixtures import ( + lightstrip, + parametrize, + parametrize_combine, + plug_iot, +) + +led_smart = parametrize( + "has led smart", component_filter="led", protocol_filter={"SMART"} +) +led = parametrize_combine([led_smart, plug_iot]) + +light_effect_smart = parametrize( + "has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} +) +light_effect = parametrize_combine([light_effect_smart, lightstrip]) + + +@led +async def test_led_module(dev: Device, mocker: MockerFixture): + """Test fan speed feature.""" + led_module = dev.get_module(LedModule) + assert led_module + feat = led_module._module_features["led"] + + call = mocker.spy(led_module, "call") + await led_module.set_led(True) + assert call.call_count == 1 + await dev.update() + assert led_module.led is True + assert feat.value is True + + await led_module.set_led(False) + assert call.call_count == 2 + await dev.update() + assert led_module.led is False + assert feat.value is False + + await feat.set_value(True) + assert call.call_count == 3 + await dev.update() + assert feat.value is True + assert led_module.led is True + + +@light_effect +async def test_light_effect_module(dev: Device, mocker: MockerFixture): + """Test fan speed feature.""" + light_effect_module = dev.get_module(LightEffectModule) + assert light_effect_module + + call = mocker.spy(light_effect_module, "call") + effect_list = light_effect_module.effect_list + assert "Off" in effect_list + assert effect_list.index("Off") == 0 + assert len(effect_list) > 1 + + assert light_effect_module.has_custom_effects is not None + + await light_effect_module.set_effect("Off") + assert call.call_count == 1 + await dev.update() + assert light_effect_module.effect == "Off" + + await light_effect_module.set_effect(effect_list[1]) + assert call.call_count == 2 + await dev.update() + assert light_effect_module.effect == effect_list[1] + + await light_effect_module.set_effect(effect_list[len(effect_list) - 1]) + assert call.call_count == 3 + await dev.update() + assert light_effect_module.effect == effect_list[len(effect_list) - 1] + + with pytest.raises(ValueError): + await light_effect_module.set_effect("foobar") + assert call.call_count == 2 diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index fc987d2e6..f51f1805c 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,7 +1,6 @@ import pytest from kasa import DeviceType -from kasa.exceptions import KasaException from kasa.iot import IotLightStrip from .conftest import lightstrip @@ -23,7 +22,7 @@ async def test_lightstrip_effect(dev: IotLightStrip): @lightstrip async def test_effects_lightstrip_set_effect(dev: IotLightStrip): - with pytest.raises(KasaException): + with pytest.raises(ValueError): await dev.set_effect("Not real") await dev.set_effect("Candy Cane") diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py index ed2645c23..8989c975f 100644 --- a/kasa/tests/test_plug.py +++ b/kasa/tests/test_plug.py @@ -30,7 +30,7 @@ async def test_switch_sysinfo(dev): assert dev.is_wallswitch -@plug +@plug_iot async def test_plug_led(dev): original = dev.led From ed56937675bc396bd7e2a81799bb04de8bf404ed Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Wed, 8 May 2024 12:56:37 +0100 Subject: [PATCH 05/13] Add LightEffect feature tests --- kasa/tests/test_common_modules.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index b6e22dddc..af9dd475b 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -53,12 +53,14 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" light_effect_module = dev.get_module(LightEffectModule) assert light_effect_module + feat = light_effect_module._module_features["light_effect"] call = mocker.spy(light_effect_module, "call") effect_list = light_effect_module.effect_list assert "Off" in effect_list assert effect_list.index("Off") == 0 assert len(effect_list) > 1 + assert effect_list == feat.choices assert light_effect_module.has_custom_effects is not None @@ -66,17 +68,29 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): assert call.call_count == 1 await dev.update() assert light_effect_module.effect == "Off" + assert feat.value == "Off" - await light_effect_module.set_effect(effect_list[1]) + second_effect = effect_list[1] + await light_effect_module.set_effect(second_effect) assert call.call_count == 2 await dev.update() - assert light_effect_module.effect == effect_list[1] + assert light_effect_module.effect == second_effect + assert feat.value == second_effect - await light_effect_module.set_effect(effect_list[len(effect_list) - 1]) + last_effect = effect_list[len(effect_list) - 1] + await light_effect_module.set_effect(last_effect) assert call.call_count == 3 await dev.update() - assert light_effect_module.effect == effect_list[len(effect_list) - 1] + assert light_effect_module.effect == last_effect + assert feat.value == last_effect + + # Test feature set + await feat.set_value(second_effect) + assert call.call_count == 4 + await dev.update() + assert light_effect_module.effect == second_effect + assert feat.value == second_effect with pytest.raises(ValueError): await light_effect_module.set_effect("foobar") - assert call.call_count == 2 + assert call.call_count == 4 From 410065528d422f68798168961aff7afdd893f8c3 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Wed, 8 May 2024 13:16:24 +0100 Subject: [PATCH 06/13] Cleanup a few leftovers --- kasa/device.py | 5 +- kasa/effects.py | 298 -------------------------------- kasa/iot/iotdevice.py | 6 +- kasa/iot/iotlightstrip.py | 2 +- kasa/smart/modules/ledmodule.py | 5 - kasa/smart/smartdevice.py | 5 +- 6 files changed, 4 insertions(+), 317 deletions(-) delete mode 100644 kasa/effects.py diff --git a/kasa/device.py b/kasa/device.py index 4150e82b2..ea358a8de 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Callable, Mapping, Sequence, overload +from typing import Any, Mapping, Sequence, overload from .credentials import Credentials from .device_type import DeviceType @@ -39,9 +39,6 @@ class WifiNetwork: _LOGGER = logging.getLogger(__name__) -CallableType = Callable[["Device", str], ModuleT] - - class Device(ABC): """Common device interface. diff --git a/kasa/effects.py b/kasa/effects.py deleted file mode 100644 index 8b3e7b329..000000000 --- a/kasa/effects.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Module for light strip effects (LB*, KL*, KB*).""" - -from __future__ import annotations - -from typing import cast - -EFFECT_AURORA = { - "custom": 0, - "id": "xqUxDhbAhNLqulcuRMyPBmVGyTOyEMEu", - "brightness": 100, - "name": "Aurora", - "segments": [0], - "expansion_strategy": 1, - "enable": 1, - "type": "sequence", - "duration": 0, - "transition": 1500, - "direction": 4, - "spread": 7, - "repeat_times": 0, - "sequence": [[120, 100, 100], [240, 100, 100], [260, 100, 100], [280, 100, 100]], -} -EFFECT_BUBBLING_CAULDRON = { - "custom": 0, - "id": "tIwTRQBqJpeNKbrtBMFCgkdPTbAQGfRP", - "brightness": 100, - "name": "Bubbling Cauldron", - "segments": [0], - "expansion_strategy": 1, - "enable": 1, - "type": "random", - "hue_range": [100, 270], - "saturation_range": [80, 100], - "brightness_range": [50, 100], - "duration": 0, - "transition": 200, - "init_states": [[270, 100, 100]], - "fadeoff": 1000, - "random_seed": 24, - "backgrounds": [[270, 40, 50]], -} -EFFECT_CANDY_CANE = { - "custom": 0, - "id": "HCOttllMkNffeHjEOLEgrFJjbzQHoxEJ", - "brightness": 100, - "name": "Candy Cane", - "segments": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], - "expansion_strategy": 1, - "enable": 1, - "type": "sequence", - "duration": 700, - "transition": 500, - "direction": 1, - "spread": 1, - "repeat_times": 0, - "sequence": [ - [0, 0, 100], - [0, 0, 100], - [360, 81, 100], - [0, 0, 100], - [0, 0, 100], - [360, 81, 100], - [360, 81, 100], - [0, 0, 100], - [0, 0, 100], - [360, 81, 100], - [360, 81, 100], - [360, 81, 100], - [360, 81, 100], - [0, 0, 100], - [0, 0, 100], - [360, 81, 100], - ], -} -EFFECT_CHRISTMAS = { - "custom": 0, - "id": "bwTatyinOUajKrDwzMmqxxJdnInQUgvM", - "brightness": 100, - "name": "Christmas", - "segments": [0], - "expansion_strategy": 1, - "enable": 1, - "type": "random", - "hue_range": [136, 146], - "saturation_range": [90, 100], - "brightness_range": [50, 100], - "duration": 5000, - "transition": 0, - "init_states": [[136, 0, 100]], - "fadeoff": 2000, - "random_seed": 100, - "backgrounds": [[136, 98, 75], [136, 0, 0], [350, 0, 100], [350, 97, 94]], -} -EFFECT_FLICKER = { - "custom": 0, - "id": "bCTItKETDFfrKANolgldxfgOakaarARs", - "brightness": 100, - "name": "Flicker", - "segments": [1], - "expansion_strategy": 1, - "enable": 1, - "type": "random", - "hue_range": [30, 40], - "saturation_range": [100, 100], - "brightness_range": [50, 100], - "duration": 0, - "transition": 0, - "transition_range": [375, 500], - "init_states": [[30, 81, 80]], -} -EFFECT_HANUKKAH = { - "custom": 0, - "id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD", - "brightness": 100, - "name": "Hanukkah", - "segments": [1], - "expansion_strategy": 1, - "enable": 1, - "type": "random", - "hue_range": [200, 210], - "saturation_range": [0, 100], - "brightness_range": [50, 100], - "duration": 1500, - "transition": 0, - "transition_range": [400, 500], - "init_states": [[35, 81, 80]], -} -EFFECT_HAUNTED_MANSION = { - "custom": 0, - "id": "oJnFHsVQzFUTeIOBAhMRfVeujmSauhjJ", - "brightness": 80, - "name": "Haunted Mansion", - "segments": [80], - "expansion_strategy": 2, - "enable": 1, - "type": "random", - "hue_range": [45, 45], - "saturation_range": [10, 10], - "brightness_range": [0, 80], - "duration": 0, - "transition": 0, - "transition_range": [50, 1500], - "init_states": [[45, 10, 100]], - "fadeoff": 200, - "random_seed": 1, - "backgrounds": [[45, 10, 100]], -} -EFFECT_ICICLE = { - "custom": 0, - "id": "joqVjlaTsgzmuQQBAlHRkkPAqkBUiqeb", - "brightness": 70, - "name": "Icicle", - "segments": [0], - "expansion_strategy": 1, - "enable": 1, - "type": "sequence", - "duration": 0, - "transition": 400, - "direction": 4, - "spread": 3, - "repeat_times": 0, - "sequence": [ - [190, 100, 70], - [190, 100, 70], - [190, 30, 50], - [190, 100, 70], - [190, 100, 70], - ], -} -EFFECT_LIGHTNING = { - "custom": 0, - "id": "ojqpUUxdGHoIugGPknrUcRoyJiItsjuE", - "brightness": 100, - "name": "Lightning", - "segments": [7, 20, 23, 32, 34, 35, 49, 65, 66, 74, 80], - "expansion_strategy": 1, - "enable": 1, - "type": "random", - "hue_range": [240, 240], - "saturation_range": [10, 11], - "brightness_range": [90, 100], - "duration": 0, - "transition": 50, - "init_states": [[240, 30, 100]], - "fadeoff": 150, - "random_seed": 600, - "backgrounds": [[200, 100, 100], [200, 50, 10], [210, 10, 50], [240, 10, 0]], -} -EFFECT_OCEAN = { - "custom": 0, - "id": "oJjUMosgEMrdumfPANKbkFmBcAdEQsPy", - "brightness": 30, - "name": "Ocean", - "segments": [0], - "expansion_strategy": 1, - "enable": 1, - "type": "sequence", - "duration": 0, - "transition": 2000, - "direction": 3, - "spread": 16, - "repeat_times": 0, - "sequence": [[198, 84, 30], [198, 70, 30], [198, 10, 30]], -} -EFFECT_RAINBOW = { - "custom": 0, - "id": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT", - "brightness": 100, - "name": "Rainbow", - "segments": [0], - "expansion_strategy": 1, - "enable": 1, - "type": "sequence", - "duration": 0, - "transition": 1500, - "direction": 1, - "spread": 12, - "repeat_times": 0, - "sequence": [[0, 100, 100], [100, 100, 100], [200, 100, 100], [300, 100, 100]], -} -EFFECT_RAINDROP = { - "custom": 0, - "id": "QbDFwiSFmLzQenUOPnJrsGqyIVrJrRsl", - "brightness": 30, - "name": "Raindrop", - "segments": [0], - "expansion_strategy": 1, - "enable": 1, - "type": "random", - "hue_range": [200, 200], - "saturation_range": [10, 20], - "brightness_range": [10, 30], - "duration": 0, - "transition": 1000, - "init_states": [[200, 40, 100]], - "fadeoff": 1000, - "random_seed": 24, - "backgrounds": [[200, 40, 0]], -} -EFFECT_SPRING = { - "custom": 0, - "id": "URdUpEdQbnOOechDBPMkKrwhSupLyvAg", - "brightness": 100, - "name": "Spring", - "segments": [0], - "expansion_strategy": 1, - "enable": 1, - "type": "random", - "hue_range": [0, 90], - "saturation_range": [30, 100], - "brightness_range": [90, 100], - "duration": 600, - "transition": 0, - "transition_range": [2000, 6000], - "init_states": [[80, 30, 100]], - "fadeoff": 1000, - "random_seed": 20, - "backgrounds": [[130, 100, 40]], -} -EFFECT_VALENTINES = { - "custom": 0, - "id": "QglBhMShPHUAuxLqzNEefFrGiJwahOmz", - "brightness": 100, - "name": "Valentines", - "segments": [0], - "expansion_strategy": 1, - "enable": 1, - "type": "random", - "hue_range": [340, 340], - "saturation_range": [30, 40], - "brightness_range": [90, 100], - "duration": 600, - "transition": 2000, - "init_states": [[340, 30, 100]], - "fadeoff": 3000, - "random_seed": 100, - "backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], -} - -EFFECTS_LIST_V1 = [ - EFFECT_AURORA, - EFFECT_BUBBLING_CAULDRON, - EFFECT_CANDY_CANE, - EFFECT_CHRISTMAS, - EFFECT_FLICKER, - EFFECT_HANUKKAH, - EFFECT_HAUNTED_MANSION, - EFFECT_ICICLE, - EFFECT_LIGHTNING, - EFFECT_OCEAN, - EFFECT_RAINBOW, - EFFECT_RAINDROP, - EFFECT_SPRING, - EFFECT_VALENTINES, -] - -EFFECT_NAMES_V1: list[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1] -EFFECT_MAPPING_V1 = {effect["name"]: effect for effect in EFFECTS_LIST_V1} diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 0ab796052..b7f3ce438 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -206,10 +206,7 @@ def modules(self) -> dict[str, IotModule]: def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... @overload - def get_module(self, module_type: type) -> ModuleT | None: ... - - @overload - def get_module(self, module_type: str) -> Module | None: ... + def get_module(self, module_type: str) -> IotModule | None: ... def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: """Return the module from the device modules or None if not present.""" @@ -365,7 +362,6 @@ async def _modular_update(self, req: dict) -> None: _LOGGER.debug( "The device has emeter, querying its information along sysinfo" ) - # TODO self.add_module("emeter", Emeter(self, self.emeter_type)) # TODO: perhaps modules should not have unsupported modules, diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index bf17dda17..422985b56 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -4,8 +4,8 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..effects import EFFECT_NAMES_V1 from ..protocol import BaseProtocol +from .effects import EFFECT_NAMES_V1 from .iotbulb import IotBulb from .iotdevice import KasaException, requires_update from .modules.lighteffectmodule import LightEffectModule diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index ef30cfe00..f600647bc 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -2,14 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...modules.ledmodule import LedModule as BaseLedModule from ..smartmodule import SmartModule -if TYPE_CHECKING: - pass - class LedModule(SmartModule, BaseLedModule): """Implementation of led controls.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index a1bfa4da4..891129414 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -319,10 +319,7 @@ async def _initialize_features(self): def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... @overload - def get_module(self, module_type: type) -> Module | None: ... - - @overload - def get_module(self, module_type: str) -> Module | None: ... + def get_module(self, module_type: str) -> SmartModule | None: ... def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: """Return the module from the device modules or None if not present.""" From 7f6eac200cae589d8524fbf1a303210932477ffc Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 10 May 2024 17:13:07 +0100 Subject: [PATCH 07/13] Make modules dictionary support typed get and get_item (#908) --- .pre-commit-config.yaml | 5 + devtools/create_module_fixtures.py | 2 +- kasa/__init__.py | 2 + kasa/device.py | 21 ++-- .../ledinterface.py} | 23 ++--- .../lighteffectinterface.py} | 26 ++--- kasa/iot/iotdevice.py | 41 +++----- kasa/iot/iotlightstrip.py | 15 +-- kasa/iot/iotplug.py | 8 +- kasa/iot/iotstrip.py | 1 - kasa/iot/modules/ledmodule.py | 7 +- kasa/iot/modules/lighteffectmodule.py | 4 +- kasa/module.py | 64 ++++++++++++- kasa/modulemapping.py | 25 +++++ kasa/modulemapping.pyi | 96 +++++++++++++++++++ kasa/modules/__init__.py | 78 --------------- kasa/smart/modules/ledmodule.py | 4 +- kasa/smart/modules/lighteffectmodule.py | 4 +- kasa/smart/smartdevice.py | 41 +++----- kasa/tests/fakeprotocol_smart.py | 8 +- kasa/tests/smart/features/test_brightness.py | 2 +- kasa/tests/smart/modules/test_contact.py | 5 +- kasa/tests/smart/modules/test_fan.py | 8 +- kasa/tests/smart/modules/test_light_effect.py | 7 +- kasa/tests/test_common_modules.py | 7 +- kasa/tests/test_iotdevice.py | 13 ++- kasa/tests/test_smartdevice.py | 19 ++-- 27 files changed, 290 insertions(+), 246 deletions(-) rename kasa/{modules/ledmodule.py => interfaces/ledinterface.py} (58%) rename kasa/{modules/lighteffectmodule.py => interfaces/lighteffectinterface.py} (74%) create mode 100644 kasa/modulemapping.py create mode 100644 kasa/modulemapping.pyi delete mode 100644 kasa/modules/__init__.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c0438d9b..c274bb979 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,6 +21,11 @@ repos: hooks: - id: mypy additional_dependencies: [types-click] + exclude: | + (?x)^( + kasa/modulemapping\.py| + )$ + - repo: https://github.com/PyCQA/doc8 rev: 'v1.1.1' diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index 8372bfff5..ed881a88b 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -19,7 +19,7 @@ def create_fixtures(dev: IotDevice, outputdir: Path): """Iterate over supported modules and create version-specific fixture files.""" for name, module in dev.modules.items(): - module_dir = outputdir / name + module_dir = outputdir / str(name) if not module_dir.exists(): module_dir.mkdir(exist_ok=True, parents=True) diff --git a/kasa/__init__.py b/kasa/__init__.py index 0e0a29d1a..e9f64c708 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -40,6 +40,7 @@ IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) +from kasa.module import Module from kasa.protocol import BaseProtocol from kasa.smartprotocol import SmartProtocol @@ -60,6 +61,7 @@ "Device", "Bulb", "Plug", + "Module", "KasaException", "AuthenticationError", "DeviceError", diff --git a/kasa/device.py b/kasa/device.py index ea358a8de..8150352d9 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Mapping, Sequence, overload +from typing import TYPE_CHECKING, Any, Mapping, Sequence from .credentials import Credentials from .device_type import DeviceType @@ -15,10 +15,13 @@ from .exceptions import KasaException from .feature import Feature from .iotprotocol import IotProtocol -from .module import Module, ModuleT +from .module import Module from .protocol import BaseProtocol from .xortransport import XorTransport +if TYPE_CHECKING: + from .modulemapping import ModuleMapping + @dataclass class WifiNetwork: @@ -113,21 +116,9 @@ async def disconnect(self): @property @abstractmethod - def modules(self) -> Mapping[str, Module]: + def modules(self) -> ModuleMapping[Module]: """Return the device modules.""" - @overload - @abstractmethod - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - @abstractmethod - def get_module(self, module_type: str) -> Module | None: ... - - @abstractmethod - def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: - """Return the module from the device modules or None if not present.""" - @property @abstractmethod def is_on(self) -> bool: diff --git a/kasa/modules/ledmodule.py b/kasa/interfaces/ledinterface.py similarity index 58% rename from kasa/modules/ledmodule.py rename to kasa/interfaces/ledinterface.py index 0ea35e4b3..e7f920120 100644 --- a/kasa/modules/ledmodule.py +++ b/kasa/interfaces/ledinterface.py @@ -2,17 +2,15 @@ from __future__ import annotations +from abc import ABC, abstractmethod + from ..feature import Feature from ..module import Module -class LedModule(Module): +class LedInterface(Module, ABC): """Base interface to represent a LED module.""" - # This needs to implement abstract methods for typing to work with - # overload get_module(type[ModuleT]) -> ModuleT: - # https://discuss.python.org/t/add-abstracttype-to-the-typing-module/21996 - def _initialize_features(self): """Initialize features.""" device = self._device @@ -22,7 +20,7 @@ def _initialize_features(self): container=self, name="LED", id="led", - icon="mdi:led-{state}", + icon="mdi:led", attribute_getter="led", attribute_setter="set_led", type=Feature.Type.Switch, @@ -31,19 +29,10 @@ def _initialize_features(self): ) @property + @abstractmethod def led(self) -> bool: """Return current led status.""" - raise NotImplementedError() + @abstractmethod async def set_led(self, enable: bool) -> None: """Set led.""" - raise NotImplementedError() - - def query(self) -> dict: - """Query to execute during the update cycle.""" - raise NotImplementedError() - - @property - def data(self): - """Query to execute during the update cycle.""" - raise NotImplementedError() diff --git a/kasa/modules/lighteffectmodule.py b/kasa/interfaces/lighteffectinterface.py similarity index 74% rename from kasa/modules/lighteffectmodule.py rename to kasa/interfaces/lighteffectinterface.py index 795a44652..625834408 100644 --- a/kasa/modules/lighteffectmodule.py +++ b/kasa/interfaces/lighteffectinterface.py @@ -2,17 +2,15 @@ from __future__ import annotations +from abc import ABC, abstractmethod + from ..feature import Feature from ..module import Module -class LightEffectModule(Module): +class LightEffectInterface(Module, ABC): """Interface to represent a light effect module.""" - # This needs to implement abstract methods for typing to work with - # overload get_module(type[ModuleT]) -> ModuleT: - # https://discuss.python.org/t/add-abstracttype-to-the-typing-module/21996 - LIGHT_EFFECTS_OFF = "Off" def _initialize_features(self): @@ -33,24 +31,25 @@ def _initialize_features(self): ) @property + @abstractmethod def has_custom_effects(self) -> bool: """Return True if the device supports setting custom effects.""" - raise NotImplementedError() @property + @abstractmethod def effect(self) -> str: """Return effect state or name.""" - raise NotImplementedError() @property + @abstractmethod def effect_list(self) -> list[str]: """Return built-in effects list. Example: ['Aurora', 'Bubbling Cauldron', ...] """ - raise NotImplementedError() + @abstractmethod async def set_effect( self, effect: str, @@ -70,7 +69,6 @@ async def set_effect( :param int brightness: The wanted brightness :param int transition: The wanted transition time """ - raise NotImplementedError() async def set_custom_effect( self, @@ -80,13 +78,3 @@ async def set_custom_effect( :param str effect_dict: The custom effect dict to set """ - raise NotImplementedError() - - def query(self) -> dict: - """Query to execute during the update cycle.""" - raise NotImplementedError() - - @property - def data(self): - """Query to execute during the update cycle.""" - raise NotImplementedError() diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index b7f3ce438..1bf6cb992 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -19,14 +19,15 @@ import inspect import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast, overload +from typing import Any, Mapping, Sequence, cast from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import KasaException from ..feature import Feature -from ..module import Module, ModuleT +from ..module import Module +from ..modulemapping import ModuleMapping, ModuleName from ..protocol import BaseProtocol from .iotmodule import IotModule from .modules import Emeter, Time @@ -190,7 +191,7 @@ def __init__( self._supported_modules: dict[str, IotModule] | None = None self._legacy_features: set[str] = set() self._children: Mapping[str, IotDevice] = {} - self._modules: dict[str, IotModule] = {} + self._modules: dict[str | ModuleName[Module], IotModule] = {} @property def children(self) -> Sequence[IotDevice]: @@ -198,36 +199,18 @@ def children(self) -> Sequence[IotDevice]: return list(self._children.values()) @property - def modules(self) -> dict[str, IotModule]: + def modules(self) -> ModuleMapping[IotModule]: """Return the device modules.""" - return self._modules + return cast(ModuleMapping[IotModule], self._modules) - @overload - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - def get_module(self, module_type: str) -> IotModule | None: ... - - def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: - """Return the module from the device modules or None if not present.""" - if isinstance(module_type, str): - module_name = module_type.lower() - elif issubclass(module_type, Module): - module_name = module_type.__name__.lower() - else: - return None - if module_name in self.modules: - return self.modules[module_name] - return None - - def add_module(self, name: str, module: IotModule): + def add_module(self, name: str | ModuleName[Module], module: IotModule): """Register a module.""" if name in self.modules: _LOGGER.debug("Module %s already registered, ignoring..." % name) return _LOGGER.debug("Adding module %s", module) - self.modules[name] = module + self._modules[name] = module def _create_request( self, target: str, cmd: str, arg: dict | None = None, child_ids=None @@ -289,11 +272,11 @@ def features(self) -> dict[str, Feature]: @property # type: ignore @requires_update - def supported_modules(self) -> list[str]: + def supported_modules(self) -> list[str | ModuleName[Module]]: """Return a set of modules supported by the device.""" # TODO: this should rather be called `features`, but we don't want to break # the API now. Maybe just deprecate it and point the users to use this? - return list(self.modules.keys()) + return list(self._modules.keys()) @property # type: ignore @requires_update @@ -368,7 +351,7 @@ async def _modular_update(self, req: dict) -> None: # making separate handling for this unnecessary if self._supported_modules is None: supported = {} - for module in self.modules.values(): + for module in self._modules.values(): if module.is_supported: supported[module._module] = module @@ -376,7 +359,7 @@ async def _modular_update(self, req: dict) -> None: request_list = [] est_response_size = 1024 if "system" in req else 0 - for module in self.modules.values(): + for module in self._modules.values(): if not module.is_supported: _LOGGER.debug("Module %s not supported, skipping" % module) continue diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 422985b56..a120be7a7 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -4,6 +4,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..module import Module from ..protocol import BaseProtocol from .effects import EFFECT_NAMES_V1 from .iotbulb import IotBulb @@ -55,10 +56,10 @@ def __init__( ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip - self._light_effect_module = LightEffectModule( - self, "smartlife.iot.lighting_effect" + self.add_module( + Module.LightEffect, + LightEffectModule(self, "smartlife.iot.lighting_effect"), ) - self.add_module("lighteffectmodule", self._light_effect_module) @property # type: ignore @requires_update @@ -79,7 +80,7 @@ def effect(self) -> dict: 'name': ''} """ # LightEffectModule returns the current effect name - # so return the dict here for backwards compatability + # so return the dict here for backwards compatibility return self.sys_info["lighting_effect_state"] @property # type: ignore @@ -91,7 +92,7 @@ def effect_list(self) -> list[str] | None: ['Aurora', 'Bubbling Cauldron', ...] """ # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value - # so return the original effect names here for backwards compatability + # so return the original effect names here for backwards compatibility return EFFECT_NAMES_V1 if self.has_effects else None @requires_update @@ -114,7 +115,7 @@ async def set_effect( :param int brightness: The wanted brightness :param int transition: The wanted transition time """ - await self._light_effect_module.set_effect( + await self.modules[Module.LightEffect].set_effect( effect, brightness=brightness, transition=transition ) @@ -129,4 +130,4 @@ async def set_custom_effect( """ if not self.has_effects: raise KasaException("Bulb does not support effects.") - await self._light_effect_module.set_custom_effect(effect_dict) + await self.modules[Module.LightEffect].set_custom_effect(effect_dict) diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index fe1b691e8..22238c7a5 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -6,6 +6,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update from .modules import Antitheft, Cloud, LedModule, Schedule, Time, Usage @@ -57,8 +58,7 @@ def __init__( self.add_module("antitheft", Antitheft(self, "anti_theft")) self.add_module("time", Time(self, "time")) self.add_module("cloud", Cloud(self, "cnCloud")) - self._led_module = LedModule(self, "system") - self.add_module("ledmodule", self._led_module) + self.add_module(Module.Led, LedModule(self, "system")) @property # type: ignore @requires_update @@ -79,11 +79,11 @@ async def turn_off(self, **kwargs): @requires_update def led(self) -> bool: """Return the state of the led.""" - return self._led_module.led + return self.modules[Module.Led].led async def set_led(self, state: bool): """Set the state of the led (night mode).""" - return await self._led_module.set_led(state) + return await self.modules[Module.Led].set_led(state) class IotWallSwitch(IotPlug): diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 9e99a0748..ab14abb0a 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -253,7 +253,6 @@ def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: self._last_update = parent._last_update self._set_sys_info(parent.sys_info) self._device_type = DeviceType.StripSocket - self._modules = {} self.protocol = parent.protocol # Must use the same connection as the parent self.add_module("time", Time(self, "time")) diff --git a/kasa/iot/modules/ledmodule.py b/kasa/iot/modules/ledmodule.py index bed0273a2..60341a18d 100644 --- a/kasa/iot/modules/ledmodule.py +++ b/kasa/iot/modules/ledmodule.py @@ -2,16 +2,13 @@ from __future__ import annotations -from ...modules.ledmodule import LedModule as BaseLedModule +from ...interfaces.ledinterface import LedInterface from ..iotmodule import IotModule -class LedModule(IotModule, BaseLedModule): +class LedModule(IotModule, LedInterface): """Implementation of led controls.""" - REQUIRED_COMPONENT = "led" - QUERY_GETTER_NAME = "get_led_info" - def query(self) -> dict: """Query to execute during the update cycle.""" return {} diff --git a/kasa/iot/modules/lighteffectmodule.py b/kasa/iot/modules/lighteffectmodule.py index b1035ac16..1ec09a0b8 100644 --- a/kasa/iot/modules/lighteffectmodule.py +++ b/kasa/iot/modules/lighteffectmodule.py @@ -2,12 +2,12 @@ from __future__ import annotations -from ...modules.lighteffectmodule import LightEffectModule as BaseLightEffectModule +from ...interfaces.lighteffectinterface import LightEffectInterface from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..iotmodule import IotModule -class LightEffectModule(IotModule, BaseLightEffectModule): +class LightEffectModule(IotModule, LightEffectInterface): """Implementation of dynamic light effects.""" @property diff --git a/kasa/module.py b/kasa/module.py index 3da0c1ad2..3ce3cf11a 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -6,14 +6,20 @@ from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, + Final, TypeVar, ) from .exceptions import KasaException from .feature import Feature +from .modulemapping import ModuleName if TYPE_CHECKING: - from .device import Device + from .device import Device as DeviceType # avoid name clash with Device module + from .interfaces.ledinterface import LedInterface + from .interfaces.lighteffectinterface import LightEffectInterface + from .iot import modules as iot + from .smart import modules as smart _LOGGER = logging.getLogger(__name__) @@ -27,7 +33,61 @@ class Module(ABC): executed during the regular update cycle. """ - def __init__(self, device: Device, module: str): + # Common Modules + LightEffect: Final[ModuleName[LightEffectInterface]] = ModuleName( + "LightEffectModule" + ) + Led: Final[ModuleName[LedInterface]] = ModuleName("LedModule") + + # IOT only Modules + IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") + IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft") + IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown") + IotEmeter: Final[ModuleName[iot.Emeter]] = ModuleName("emeter") + IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion") + IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") + IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") + IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") + IotTime: Final[ModuleName[iot.Time]] = ModuleName("time") + + # SMART only Modules + Alarm: Final[ModuleName[smart.AlarmModule]] = ModuleName("AlarmModule") + AutoOff: Final[ModuleName[smart.AutoOffModule]] = ModuleName("AutoOffModule") + BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") + Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") + ChildDevice: Final[ModuleName[smart.ChildDeviceModule]] = ModuleName( + "ChildDeviceModule" + ) + Cloud: Final[ModuleName[smart.CloudModule]] = ModuleName("CloudModule") + Color: Final[ModuleName[smart.ColorModule]] = ModuleName("ColorModule") + ColorTemp: Final[ModuleName[smart.ColorTemperatureModule]] = ModuleName( + "ColorTemperatureModule" + ) + ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor") + Device: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") + Energy: Final[ModuleName[smart.EnergyModule]] = ModuleName("EnergyModule") + Fan: Final[ModuleName[smart.FanModule]] = ModuleName("FanModule") + Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware") + FrostProtection: Final[ModuleName[smart.FrostProtectionModule]] = ModuleName( + "FrostProtectionModule" + ) + Humidity: Final[ModuleName[smart.HumiditySensor]] = ModuleName("HumiditySensor") + LightTransition: Final[ModuleName[smart.LightTransitionModule]] = ModuleName( + "LightTransitionModule" + ) + Report: Final[ModuleName[smart.ReportModule]] = ModuleName("ReportModule") + Temperature: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( + "TemperatureSensor" + ) + TemperatureSensor: Final[ModuleName[smart.TemperatureControl]] = ModuleName( + "TemperatureControl" + ) + Time: Final[ModuleName[smart.TimeModule]] = ModuleName("TimeModule") + WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName( + "WaterleakSensor" + ) + + def __init__(self, device: DeviceType, module: str): self._device = device self._module = module self._module_features: dict[str, Feature] = {} diff --git a/kasa/modulemapping.py b/kasa/modulemapping.py new file mode 100644 index 000000000..06ba86190 --- /dev/null +++ b/kasa/modulemapping.py @@ -0,0 +1,25 @@ +"""Module for Implementation for ModuleMapping and ModuleName types. + +Custom dict for getting typed modules from the module dict. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from .module import Module + +_ModuleT = TypeVar("_ModuleT", bound="Module") + + +class ModuleName(str, Generic[_ModuleT]): + """Generic Module name type. + + At runtime this is a generic subclass of str. + """ + + __slots__ = () + + +ModuleMapping = dict diff --git a/kasa/modulemapping.pyi b/kasa/modulemapping.pyi new file mode 100644 index 000000000..8d110d39f --- /dev/null +++ b/kasa/modulemapping.pyi @@ -0,0 +1,96 @@ +"""Typing stub file for ModuleMapping.""" + +from abc import ABCMeta +from collections.abc import Mapping +from typing import Generic, TypeVar, overload + +from .module import Module + +__all__ = [ + "ModuleMapping", + "ModuleName", +] + +_ModuleT = TypeVar("_ModuleT", bound=Module, covariant=True) +_ModuleBaseT = TypeVar("_ModuleBaseT", bound=Module, covariant=True) + +class ModuleName(Generic[_ModuleT]): + """Class for typed Module names. At runtime delegated to str.""" + + def __init__(self, value: str, /) -> None: ... + def __len__(self) -> int: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __getitem__(self, index: int) -> str: ... + +class ModuleMapping( + Mapping[ModuleName[_ModuleBaseT] | str, _ModuleBaseT], metaclass=ABCMeta +): + """Custom dict type to provide better value type hints for Module key types.""" + + @overload + def __getitem__(self, key: ModuleName[_ModuleT], /) -> _ModuleT: ... + @overload + def __getitem__(self, key: str, /) -> _ModuleBaseT: ... + @overload + def __getitem__( + self, key: ModuleName[_ModuleT] | str, / + ) -> _ModuleT | _ModuleBaseT: ... + @overload # type: ignore[override] + def get(self, key: ModuleName[_ModuleT], /) -> _ModuleT | None: ... + @overload + def get(self, key: str, /) -> _ModuleBaseT | None: ... + @overload + def get( + self, key: ModuleName[_ModuleT] | str, / + ) -> _ModuleT | _ModuleBaseT | None: ... + +def _test_module_mapping_typing() -> None: + """Test ModuleMapping overloads work as intended. + + This is tested during the mypy run and needs to be in this file. + """ + from typing import Any, NewType, cast + + from typing_extensions import assert_type + + from .iot.iotmodule import IotModule + from .module import Module + from .smart.smartmodule import SmartModule + + NewCommonModule = NewType("NewCommonModule", Module) + NewIotModule = NewType("NewIotModule", IotModule) + NewSmartModule = NewType("NewSmartModule", SmartModule) + NotModule = NewType("NotModule", list) + + NEW_COMMON_MODULE: ModuleName[NewCommonModule] = ModuleName("NewCommonModule") + NEW_IOT_MODULE: ModuleName[NewIotModule] = ModuleName("NewIotModule") + NEW_SMART_MODULE: ModuleName[NewSmartModule] = ModuleName("NewSmartModule") + + # TODO Enable --warn-unused-ignores + NOT_MODULE: ModuleName[NotModule] = ModuleName("NotModule") # type: ignore[type-var] # noqa: F841 + NOT_MODULE_2 = ModuleName[NotModule]("NotModule2") # type: ignore[type-var] # noqa: F841 + + device_modules: ModuleMapping[Module] = cast(ModuleMapping[Module], {}) + assert_type(device_modules[NEW_COMMON_MODULE], NewCommonModule) + assert_type(device_modules[NEW_IOT_MODULE], NewIotModule) + assert_type(device_modules[NEW_SMART_MODULE], NewSmartModule) + assert_type(device_modules["foobar"], Module) + assert_type(device_modules[3], Any) # type: ignore[call-overload] + + assert_type(device_modules.get(NEW_COMMON_MODULE), NewCommonModule | None) + assert_type(device_modules.get(NEW_IOT_MODULE), NewIotModule | None) + assert_type(device_modules.get(NEW_SMART_MODULE), NewSmartModule | None) + assert_type(device_modules.get(NEW_COMMON_MODULE, default=[1, 2]), Any) # type: ignore[call-overload] + + iot_modules: ModuleMapping[IotModule] = cast(ModuleMapping[IotModule], {}) + smart_modules: ModuleMapping[SmartModule] = cast(ModuleMapping[SmartModule], {}) + + assert_type(smart_modules["foobar"], SmartModule) + assert_type(iot_modules["foobar"], IotModule) + + # Test for covariance + device_modules_2: ModuleMapping[Module] = iot_modules # noqa: F841 + device_modules_3: ModuleMapping[Module] = smart_modules # noqa: F841 + NEW_MODULE: ModuleName[Module] = NEW_SMART_MODULE # noqa: F841 + NEW_MODULE_2: ModuleName[Module] = NEW_IOT_MODULE # noqa: F841 diff --git a/kasa/modules/__init__.py b/kasa/modules/__init__.py deleted file mode 100644 index 4e1dbafb4..000000000 --- a/kasa/modules/__init__.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Sub Package for device family independant modules.""" - -# Iot Modules -from ..iot.modules.ambientlight import AmbientLight -from ..iot.modules.antitheft import Antitheft -from ..iot.modules.cloud import Cloud -from ..iot.modules.countdown import Countdown -from ..iot.modules.emeter import Emeter -from ..iot.modules.motion import Motion -from ..iot.modules.rulemodule import Rule, RuleModule -from ..iot.modules.schedule import Schedule -from ..iot.modules.time import Time -from ..iot.modules.usage import Usage - -# Smart Modules -from ..smart.modules.alarmmodule import AlarmModule -from ..smart.modules.autooffmodule import AutoOffModule -from ..smart.modules.battery import BatterySensor -from ..smart.modules.brightness import Brightness -from ..smart.modules.childdevicemodule import ChildDeviceModule -from ..smart.modules.cloudmodule import CloudModule -from ..smart.modules.colormodule import ColorModule -from ..smart.modules.colortemp import ColorTemperatureModule -from ..smart.modules.devicemodule import DeviceModule -from ..smart.modules.energymodule import EnergyModule -from ..smart.modules.fanmodule import FanModule -from ..smart.modules.firmware import Firmware -from ..smart.modules.frostprotection import FrostProtectionModule -from ..smart.modules.humidity import HumiditySensor -from ..smart.modules.lighttransitionmodule import LightTransitionModule -from ..smart.modules.reportmodule import ReportModule -from ..smart.modules.temperature import TemperatureSensor -from ..smart.modules.temperaturecontrol import TemperatureControl -from ..smart.modules.timemodule import TimeModule -from ..smart.modules.waterleak import WaterleakSensor - -# Common Modules -from .ledmodule import LedModule -from .lighteffectmodule import LightEffectModule - -__all__ = [ - # Common modules - "LightEffectModule", - "LedModule", - # Iot Modules - "AmbientLight", - "Antitheft", - "Cloud", - "Countdown", - "Emeter", - "Motion", - "Rule", - "RuleModule", - "Schedule", - "Time", - "Usage", - # Smart Modules - "AlarmModule", - "TimeModule", - "EnergyModule", - "DeviceModule", - "ChildDeviceModule", - "BatterySensor", - "HumiditySensor", - "TemperatureSensor", - "TemperatureControl", - "ReportModule", - "AutoOffModule", - "Brightness", - "FanModule", - "Firmware", - "CloudModule", - "LightTransitionModule", - "ColorTemperatureModule", - "ColorModule", - "WaterleakSensor", - "FrostProtectionModule", -] diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index f600647bc..20f69a2f3 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -2,11 +2,11 @@ from __future__ import annotations -from ...modules.ledmodule import LedModule as BaseLedModule +from ...interfaces.ledinterface import LedInterface from ..smartmodule import SmartModule -class LedModule(SmartModule, BaseLedModule): +class LedModule(SmartModule, LedInterface): """Implementation of led controls.""" REQUIRED_COMPONENT = "led" diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffectmodule.py index 667455f5d..eacf5bba4 100644 --- a/kasa/smart/modules/lighteffectmodule.py +++ b/kasa/smart/modules/lighteffectmodule.py @@ -6,14 +6,14 @@ import copy from typing import TYPE_CHECKING, Any -from ...modules.lighteffectmodule import LightEffectModule as BaseLightEffectModule +from ...interfaces.lighteffectinterface import LightEffectInterface from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightEffectModule(SmartModule, BaseLightEffectModule): +class LightEffectModule(SmartModule, LightEffectInterface): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_effect" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 891129414..066ddf65b 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,7 +5,7 @@ import base64 import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast, overload +from typing import Any, Mapping, Sequence, cast from ..aestransport import AesTransport from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange @@ -16,7 +16,8 @@ from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..fan import Fan from ..feature import Feature -from ..module import Module, ModuleT +from ..module import Module +from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol from .modules import ( Brightness, @@ -61,7 +62,7 @@ def __init__( self._components_raw: dict[str, Any] | None = None self._components: dict[str, int] = {} self._state_information: dict[str, Any] = {} - self._modules: dict[str, SmartModule] = {} + self._modules: dict[str | ModuleName[Module], SmartModule] = {} self._exposes_child_modules = False self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} @@ -102,9 +103,17 @@ def children(self) -> Sequence[SmartDevice]: return list(self._children.values()) @property - def modules(self) -> dict[str, SmartModule]: + def modules(self) -> ModuleMapping[SmartModule]: """Return the device modules.""" - return self._modules + if self._exposes_child_modules: + modules = {k: v for k, v in self._modules.items()} + for child in self._children.values(): + for k, v in child._modules.items(): + if k not in modules: + modules[k] = v + return cast(ModuleMapping[SmartModule], modules) + + return cast(ModuleMapping[SmartModule], self._modules) def _try_get_response(self, responses: dict, request: str, default=None) -> dict: response = responses.get(request) @@ -315,28 +324,6 @@ async def _initialize_features(self): for feat in module._module_features.values(): self._add_feature(feat) - @overload - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - def get_module(self, module_type: str) -> SmartModule | None: ... - - def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: - """Return the module from the device modules or None if not present.""" - if isinstance(module_type, str): - module_name = module_type - elif issubclass(module_type, Module): - module_name = module_type.__name__ - else: - return None - if module_name in self.modules: - return self.modules[module_name] - elif self._exposes_child_modules: - for child in self._children.values(): - if module_name in child.modules: - return child.modules[module_name] - return None - @property def is_cloud_connected(self): """Returns if the device is connected to the cloud.""" diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index ef3dacd8c..f43e5f8ba 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -189,6 +189,11 @@ def _set_light_effect(self, info, params): if "current_rule_id" in info["get_dynamic_light_effect_rules"]: del info["get_dynamic_light_effect_rules"]["current_rule_id"] + def _set_led_info(self, info, params): + """Set or remove values as per the device behaviour.""" + info["get_led_info"]["led_status"] = params["led_rule"] != "never" + info["get_led_info"]["led_rule"] = params["led_rule"] + def _send_request(self, request_dict: dict): method = request_dict["method"] params = request_dict["params"] @@ -242,8 +247,7 @@ def _send_request(self, request_dict: dict): self._set_light_effect(info, params) return {"error_code": 0} elif method == "set_led_info": - info["get_led_info"]["led_status"] = params["led_rule"] != "never" - info["get_led_info"]["led_rule"] = params["led_rule"] + self._set_led_info(info, params) return {"error_code": 0} elif method[:4] == "set_": target_method = f"get_{method[4:]}" diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index 02a396aae..3c00a4d11 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -10,7 +10,7 @@ @brightness async def test_brightness_component(dev: SmartDevice): """Test brightness feature.""" - brightness = dev.get_module("Brightness") + brightness = dev.modules.get("Brightness") assert brightness assert isinstance(dev, SmartDevice) assert "brightness" in dev._components diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py index fc3375450..88677c58f 100644 --- a/kasa/tests/smart/modules/test_contact.py +++ b/kasa/tests/smart/modules/test_contact.py @@ -1,7 +1,6 @@ import pytest -from kasa import SmartDevice -from kasa.smart.modules import ContactSensor +from kasa import Module, SmartDevice from kasa.tests.device_fixtures import parametrize contact = parametrize( @@ -18,7 +17,7 @@ ) async def test_contact_features(dev: SmartDevice, feature, type): """Test that features are registered and work as expected.""" - contact = dev.get_module(ContactSensor) + contact = dev.modules.get(Module.ContactSensor) assert contact is not None prop = getattr(contact, feature) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 372459510..9597471b6 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,8 +1,8 @@ import pytest from pytest_mock import MockerFixture +from kasa import Module from kasa.smart import SmartDevice -from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) @@ -11,7 +11,7 @@ @fan async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan level_feature = fan._module_features["fan_speed_level"] @@ -36,7 +36,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan sleep_feature = fan._module_features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) @@ -55,7 +55,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan device = fan._device assert device.is_fan diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index ba1b22934..cc0eee8a9 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -1,12 +1,11 @@ from __future__ import annotations from itertools import chain -from typing import cast import pytest from pytest_mock import MockerFixture -from kasa import Device, Feature +from kasa import Device, Feature, Module from kasa.smart.modules import LightEffectModule from kasa.tests.device_fixtures import parametrize @@ -18,8 +17,8 @@ @light_effect async def test_light_effect(dev: Device, mocker: MockerFixture): """Test light effect.""" - light_effect = cast(LightEffectModule, dev.modules.get("LightEffectModule")) - assert light_effect + light_effect = dev.modules.get(Module.LightEffect) + assert isinstance(light_effect, LightEffectModule) feature = light_effect._module_features["light_effect"] assert feature.type == Feature.Type.Choice diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index af9dd475b..8f7def957 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -1,8 +1,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Device -from kasa.modules import LedModule, LightEffectModule +from kasa import Device, Module from kasa.tests.device_fixtures import ( lightstrip, parametrize, @@ -24,7 +23,7 @@ @led async def test_led_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" - led_module = dev.get_module(LedModule) + led_module = dev.modules.get(Module.Led) assert led_module feat = led_module._module_features["led"] @@ -51,7 +50,7 @@ async def test_led_module(dev: Device, mocker: MockerFixture): @light_effect async def test_light_effect_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" - light_effect_module = dev.get_module(LightEffectModule) + light_effect_module = dev.modules[Module.LightEffect] assert light_effect_module feat = light_effect_module._module_features["light_effect"] diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index b4d56291e..d5c76192b 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -16,7 +16,7 @@ Schema, ) -from kasa import KasaException +from kasa import KasaException, Module from kasa.iot import IotDevice from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on @@ -261,27 +261,26 @@ async def test_modules_not_supported(dev: IotDevice): async def test_get_modules(): - """Test get_modules for child and parent modules.""" + """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( "HS100(US)_2.0_1.5.6.json", "IOT" ) from kasa.iot.modules import Cloud - from kasa.smart.modules import CloudModule # Modules on device - module = dummy_device.get_module("Cloud") + module = dummy_device.modules.get("cloud") assert module assert module._device == dummy_device assert isinstance(module, Cloud) - module = dummy_device.get_module(Cloud) + module = dummy_device.modules.get(Module.IotCloud) assert module assert module._device == dummy_device assert isinstance(module, Cloud) # Invalid modules - module = dummy_device.get_module("DummyModule") + module = dummy_device.modules.get("DummyModule") assert module is None - module = dummy_device.get_module(CloudModule) + module = dummy_device.modules.get(Module.Cloud) assert module is None diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index bb2f81bf0..a0af2cb12 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -9,7 +9,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import KasaException +from kasa import KasaException, Module from kasa.exceptions import SmartErrorCode from kasa.smart import SmartDevice @@ -123,40 +123,39 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): async def test_get_modules(): - """Test get_modules for child and parent modules.""" + """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( "KS240(US)_1.0_1.0.5.json", "SMART" ) - from kasa.iot.modules import AmbientLight - from kasa.smart.modules import CloudModule, FanModule + from kasa.smart.modules import CloudModule # Modules on device - module = dummy_device.get_module("CloudModule") + module = dummy_device.modules.get("CloudModule") assert module assert module._device == dummy_device assert isinstance(module, CloudModule) - module = dummy_device.get_module(CloudModule) + module = dummy_device.modules.get(Module.Cloud) assert module assert module._device == dummy_device assert isinstance(module, CloudModule) # Modules on child - module = dummy_device.get_module("FanModule") + module = dummy_device.modules.get("FanModule") assert module assert module._device != dummy_device assert module._device._parent == dummy_device - module = dummy_device.get_module(FanModule) + module = dummy_device.modules.get(Module.Fan) assert module assert module._device != dummy_device assert module._device._parent == dummy_device # Invalid modules - module = dummy_device.get_module("DummyModule") + module = dummy_device.modules.get("DummyModule") assert module is None - module = dummy_device.get_module(AmbientLight) + module = dummy_device.modules.get(Module.IotAmbientLight) assert module is None From 532458aa93e37ece7aca34bb2e7a4166bac2e6f4 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 8 May 2024 15:25:22 +0200 Subject: [PATCH 08/13] Move contribution instructions into docs (#901) Moves the instructions away from README.md to keep it simpler, and extend the documentation to be up-to-date and easier to approach. --------- Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- CONTRIBUTING.md | 4 ++ README.md | 36 ++-------------- docs/source/contribute.md | 86 +++++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 1 + 4 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/source/contribute.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..1f4005438 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contributing to python-kasa + +All types of contributions are very welcome. +To make the process as straight-forward as possible, we have written [some instructions in our docs](https://python-miio.readthedocs.io/en/latest/contribute.html) to get you started. diff --git a/README.md b/README.md index 42ecaaa8a..6c4cfcce1 100644 --- a/README.md +++ b/README.md @@ -185,42 +185,12 @@ The device type specific documentation can be found in their separate pages: ## Contributing -Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions. - -### Setting up development environment - -To get started, simply clone this repository and initialize the development environment. -We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute -`poetry install` which will install all necessary packages and create a virtual environment for you. - -### Code-style checks - -We use several tools to automatically check all contributions. The simplest way to verify that everything is formatted properly -before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. -This will make sure that the checks are passing when you do a commit. - -You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests. - -### Running tests - -You can run tests on the library by executing `pytest` in the source directory. -This will run the tests against contributed example responses, but you can also execute the tests against a real device: -``` -$ pytest --ip
-``` -Note that this will perform state changes on the device. - -### Analyzing network captures - -The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. -After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) -or the `parse_pcap.py` script contained inside the `devtools` directory. -Note, that this works currently only on kasa-branded devices which use port 9999 for communications. - +Contributions are very welcome! The easiest way to contribute is by [creating a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) for the automated test suite if your device hardware and firmware version is not currently listed as supported. +Please refer to [our contributing guidelines](https://python-kasa.readthedocs.io/en/latest/contribute.html). ## Supported devices -The following devices have been tested and confirmed as working. If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one). +The following devices have been tested and confirmed as working. If your device is unlisted but working, please consider [contributing a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files). diff --git a/docs/source/contribute.md b/docs/source/contribute.md new file mode 100644 index 000000000..67291eba1 --- /dev/null +++ b/docs/source/contribute.md @@ -0,0 +1,86 @@ +# Contributing + +You probably arrived to this page as you are interested in contributing to python-kasa in some form? +All types of contributions are very welcome, so thank you! +This page aims to help you to get started. + +```{contents} Contents + :local: +``` + +## Setting up the development environment + +To get started, simply clone this repository and initialize the development environment. +We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute +`poetry install` which will install all necessary packages and create a virtual environment for you. + +``` +$ git clone https://github.com/python-kasa/python-kasa.git +$ cd python-kasa +$ poetry install +``` + +## Code-style checks + +We use several tools to automatically check all contributions as part of our CI pipeline. +The simplest way to verify that everything is formatted properly +before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. +This will make sure that the checks are passing when you do a commit. + +```{note} +You can also execute the pre-commit hooks on all files by executing `pre-commit run -a` +``` + +## Running tests + +You can run tests on the library by executing `pytest` in the source directory: + +``` +$ poetry run pytest kasa +``` + +This will run the tests against the contributed example responses. + +```{note} +You can also execute the tests against a real device using `pytest --ip
`. +Note that this will perform state changes on the device. +``` + +## Analyzing network captures + +The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. +After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) +or the `parse_pcap.py` script contained inside the `devtools` directory. +Note, that this works currently only on kasa-branded devices which use port 9999 for communications. + +## Contributing fixture files + +One of the easiest ways to contribute is by creating a fixture file and uploading it for us. +These files will help us to improve the library and run tests against devices that we have no access to. + +This library is tested against responses from real devices ("fixture files"). +These files contain responses for selected, known device commands and are stored [in our test suite](https://github.com/python-kasa/python-kasa/tree/master/kasa/tests/fixtures). + +You can generate these files by using the `dump_devinfo.py` script. +Note, that this script should be run inside the main source directory so that the generated files are stored in the correct directories. +The easiest way to do that is by doing: + +``` +$ git clone https://github.com/python-kasa/python-kasa.git +$ cd python-kasa +$ poetry install +$ poetry shell +$ python -m devtools.dump_devinfo --username --password --host 192.168.1.123 +``` + +```{note} +You can also execute the script against a network by using `--target`: `python -m devtools.dump_devinfo --target network 192.168.1.255` +``` + +The script will run queries against the device, and prompt at the end if you want to save the results. +If you choose to do so, it will save the fixture files directly in their correct place to make it easy to create a pull request. + +```{note} +When adding new fixture files, you should run `pre-commit run -a` to re-generate the list of supported devices. +You may need to adjust `device_fixtures.py` to add a new model into the correct device categories. Verify that test pass by executing `poetry run pytest kasa`. +``` diff --git a/docs/source/index.rst b/docs/source/index.rst index 9dc648a9c..f5baf3894 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,6 +10,7 @@ discover smartdevice design + contribute smartbulb smartplug smartdimmer From c67a5beb4b69dc62fbd7db1a055f10507a49b700 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 9 May 2024 01:43:07 +0200 Subject: [PATCH 09/13] Improve smartdevice update module (#791) * Expose current and latest firmware as features * Provide API to get information about available firmware updates (e.g., changelog, release date etc.) * Implement updating the firmware --- kasa/smart/modules/firmware.py | 116 ++++++++++++++++++++-- kasa/tests/fakeprotocol_smart.py | 2 +- kasa/tests/smart/modules/test_firmware.py | 108 ++++++++++++++++++++ 3 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 kasa/tests/smart/modules/test_firmware.py diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 626add0f6..430515e4b 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -2,9 +2,14 @@ from __future__ import annotations +import asyncio +import logging from datetime import date -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional +# When support for cpython older than 3.11 is dropped +# async_timeout can be replaced with asyncio.timeout +from async_timeout import timeout as asyncio_timeout from pydantic.v1 import BaseModel, Field, validator from ...exceptions import SmartErrorCode @@ -15,11 +20,27 @@ from ..smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) + + +class DownloadState(BaseModel): + """Download state.""" + + # Example: + # {'status': 0, 'download_progress': 0, 'reboot_time': 5, + # 'upgrade_time': 5, 'auto_upgrade': False} + status: int + progress: int = Field(alias="download_progress") + reboot_time: int + upgrade_time: int + auto_upgrade: bool + + class UpdateInfo(BaseModel): """Update info status object.""" status: int = Field(alias="type") - fw_ver: Optional[str] = None # noqa: UP007 + version: Optional[str] = Field(alias="fw_ver", default=None) # noqa: UP007 release_date: Optional[date] = None # noqa: UP007 release_notes: Optional[str] = Field(alias="release_note", default=None) # noqa: UP007 fw_size: Optional[int] = None # noqa: UP007 @@ -71,6 +92,26 @@ def __init__(self, device: SmartDevice, module: str): category=Feature.Category.Info, ) ) + self._add_feature( + Feature( + device, + id="current_firmware_version", + name="Current firmware version", + container=self, + attribute_getter="current_firmware", + category=Feature.Category.Debug, + ) + ) + self._add_feature( + Feature( + device, + id="available_firmware_version", + name="Available firmware version", + container=self, + attribute_getter="latest_firmware", + category=Feature.Category.Debug, + ) + ) def query(self) -> dict: """Query to execute during the update cycle.""" @@ -80,7 +121,17 @@ def query(self) -> dict: return req @property - def latest_firmware(self): + def current_firmware(self) -> str: + """Return the current firmware version.""" + return self._device.hw_info["sw_ver"] + + @property + def latest_firmware(self) -> str: + """Return the latest firmware version.""" + return self.firmware_update_info.version + + @property + def firmware_update_info(self): """Return latest firmware information.""" fw = self.data.get("get_latest_fw") or self.data if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode): @@ -94,15 +145,62 @@ def update_available(self) -> bool | None: """Return True if update is available.""" if not self._device.is_cloud_connected: return None - return self.latest_firmware.update_available + return self.firmware_update_info.update_available - async def get_update_state(self): + async def get_update_state(self) -> DownloadState: """Return update state.""" - return await self.call("get_fw_download_state") + resp = await self.call("get_fw_download_state") + state = resp["get_fw_download_state"] + return DownloadState(**state) - async def update(self): + async def update( + self, progress_cb: Callable[[DownloadState], Coroutine] | None = None + ): """Update the device firmware.""" - return await self.call("fw_download") + current_fw = self.current_firmware + _LOGGER.info( + "Going to upgrade from %s to %s", + current_fw, + self.firmware_update_info.version, + ) + await self.call("fw_download") + + # TODO: read timeout from get_auto_update_info or from get_fw_download_state? + async with asyncio_timeout(60 * 5): + while True: + await asyncio.sleep(0.5) + try: + state = await self.get_update_state() + except Exception as ex: + _LOGGER.warning( + "Got exception, maybe the device is rebooting? %s", ex + ) + continue + + _LOGGER.debug("Update state: %s" % state) + if progress_cb is not None: + asyncio.create_task(progress_cb(state)) + + if state.status == 0: + _LOGGER.info( + "Update idle, hopefully updated to %s", + self.firmware_update_info.version, + ) + break + elif state.status == 2: + _LOGGER.info("Downloading firmware, progress: %s", state.progress) + elif state.status == 3: + upgrade_sleep = state.upgrade_time + _LOGGER.info( + "Flashing firmware, sleeping for %s before checking status", + upgrade_sleep, + ) + await asyncio.sleep(upgrade_sleep) + elif state.status < 0: + _LOGGER.error("Got error: %s", state.status) + break + else: + _LOGGER.warning("Unhandled state code: %s", state) @property def auto_update_enabled(self): @@ -115,4 +213,4 @@ def auto_update_enabled(self): async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" data = {**self.data["get_auto_update_info"], "enable": enabled} - await self.call("set_auto_update_info", data) # {"enable": enabled}) + await self.call("set_auto_update_info", data) diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index f43e5f8ba..7c73c71ea 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -241,7 +241,7 @@ def _send_request(self, request_dict: dict): pytest.fixtures_missing_methods[self.fixture_name] = set() pytest.fixtures_missing_methods[self.fixture_name].add(method) return retval - elif method == "set_qs_info": + elif method in ["set_qs_info", "fw_download"]: return {"error_code": 0} elif method == "set_dynamic_light_effect_rule_enable": self._set_light_effect(info, params) diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py new file mode 100644 index 000000000..d0df87ca5 --- /dev/null +++ b/kasa/tests/smart/modules/test_firmware.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa.smart import SmartDevice +from kasa.smart.modules import Firmware +from kasa.smart.modules.firmware import DownloadState +from kasa.tests.device_fixtures import parametrize + +firmware = parametrize( + "has firmware", component_filter="firmware", protocol_filter={"SMART"} +) + + +@firmware +@pytest.mark.parametrize( + "feature, prop_name, type, required_version", + [ + ("auto_update_enabled", "auto_update_enabled", bool, 2), + ("update_available", "update_available", bool, 1), + ("update_available", "update_available", bool, 1), + ("current_firmware_version", "current_firmware", str, 1), + ("available_firmware_version", "latest_firmware", str, 1), + ], +) +async def test_firmware_features( + dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture +): + """Test light effect.""" + fw = dev.get_module(Firmware) + assert fw + + if not dev.is_cloud_connected: + pytest.skip("Device is not cloud connected, skipping test") + + if fw.supported_version < required_version: + pytest.skip("Feature %s requires newer version" % feature) + + prop = getattr(fw, prop_name) + assert isinstance(prop, type) + + feat = fw._module_features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@firmware +async def test_update_available_without_cloud(dev: SmartDevice): + """Test that update_available returns None when disconnected.""" + fw = dev.get_module(Firmware) + assert fw + + if dev.is_cloud_connected: + assert isinstance(fw.update_available, bool) + else: + assert fw.update_available is None + + +@firmware +async def test_firmware_update( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test updating firmware.""" + caplog.set_level(logging.INFO) + + fw = dev.get_module(Firmware) + assert fw + + upgrade_time = 5 + extras = {"reboot_time": 5, "upgrade_time": upgrade_time, "auto_upgrade": False} + update_states = [ + # Unknown 1 + DownloadState(status=1, download_progress=0, **extras), + # Downloading + DownloadState(status=2, download_progress=10, **extras), + DownloadState(status=2, download_progress=100, **extras), + # Flashing + DownloadState(status=3, download_progress=100, **extras), + DownloadState(status=3, download_progress=100, **extras), + # Done + DownloadState(status=0, download_progress=100, **extras), + ] + + asyncio_sleep = asyncio.sleep + sleep = mocker.patch("asyncio.sleep") + mocker.patch.object(fw, "get_update_state", side_effect=update_states) + + cb_mock = mocker.AsyncMock() + + await fw.update(progress_cb=cb_mock) + + # This is necessary to allow the eventloop to process the created tasks + await asyncio_sleep(0) + + assert "Unhandled state code" in caplog.text + assert "Downloading firmware, progress: 10" in caplog.text + assert "Flashing firmware, sleeping" in caplog.text + assert "Update idle" in caplog.text + + for state in update_states: + cb_mock.assert_any_await(state) + + # sleep based on the upgrade_time + sleep.assert_any_call(upgrade_time) From ccdd38e3f066be6ed4b23f0027b0c15a30e89d80 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 10 May 2024 18:33:38 +0100 Subject: [PATCH 10/13] Fix test_firmware test --- kasa/tests/smart/modules/test_firmware.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index d0df87ca5..8f329f708 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/kasa/tests/smart/modules/test_firmware.py @@ -6,8 +6,8 @@ import pytest from pytest_mock import MockerFixture +from kasa import Module from kasa.smart import SmartDevice -from kasa.smart.modules import Firmware from kasa.smart.modules.firmware import DownloadState from kasa.tests.device_fixtures import parametrize @@ -31,7 +31,7 @@ async def test_firmware_features( dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture ): """Test light effect.""" - fw = dev.get_module(Firmware) + fw = dev.modules.get(Module.Firmware) assert fw if not dev.is_cloud_connected: @@ -51,7 +51,7 @@ async def test_firmware_features( @firmware async def test_update_available_without_cloud(dev: SmartDevice): """Test that update_available returns None when disconnected.""" - fw = dev.get_module(Firmware) + fw = dev.modules.get(Module.Firmware) assert fw if dev.is_cloud_connected: @@ -67,7 +67,7 @@ async def test_firmware_update( """Test updating firmware.""" caplog.set_level(logging.INFO) - fw = dev.get_module(Firmware) + fw = dev.modules.get(Module.Firmware) assert fw upgrade_time = 5 From 5dc6a709d82058211d857207da648a361c3d5bc9 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 10 May 2024 18:41:13 +0100 Subject: [PATCH 11/13] Rename interfaces to remove interface suffix --- kasa/interfaces/ledinterface.py | 2 +- kasa/interfaces/lighteffectinterface.py | 2 +- kasa/iot/modules/ledmodule.py | 4 ++-- kasa/iot/modules/lighteffectmodule.py | 4 ++-- kasa/module.py | 10 ++++------ kasa/smart/modules/ledmodule.py | 4 ++-- kasa/smart/modules/lighteffectmodule.py | 4 ++-- 7 files changed, 14 insertions(+), 16 deletions(-) diff --git a/kasa/interfaces/ledinterface.py b/kasa/interfaces/ledinterface.py index e7f920120..2ddba00c2 100644 --- a/kasa/interfaces/ledinterface.py +++ b/kasa/interfaces/ledinterface.py @@ -8,7 +8,7 @@ from ..module import Module -class LedInterface(Module, ABC): +class Led(Module, ABC): """Base interface to represent a LED module.""" def _initialize_features(self): diff --git a/kasa/interfaces/lighteffectinterface.py b/kasa/interfaces/lighteffectinterface.py index 625834408..0eb11b5b4 100644 --- a/kasa/interfaces/lighteffectinterface.py +++ b/kasa/interfaces/lighteffectinterface.py @@ -8,7 +8,7 @@ from ..module import Module -class LightEffectInterface(Module, ABC): +class LightEffect(Module, ABC): """Interface to represent a light effect module.""" LIGHT_EFFECTS_OFF = "Off" diff --git a/kasa/iot/modules/ledmodule.py b/kasa/iot/modules/ledmodule.py index 60341a18d..a0506b30c 100644 --- a/kasa/iot/modules/ledmodule.py +++ b/kasa/iot/modules/ledmodule.py @@ -2,11 +2,11 @@ from __future__ import annotations -from ...interfaces.ledinterface import LedInterface +from ...interfaces.ledinterface import Led from ..iotmodule import IotModule -class LedModule(IotModule, LedInterface): +class LedModule(IotModule, Led): """Implementation of led controls.""" def query(self) -> dict: diff --git a/kasa/iot/modules/lighteffectmodule.py b/kasa/iot/modules/lighteffectmodule.py index 1ec09a0b8..878cce1a1 100644 --- a/kasa/iot/modules/lighteffectmodule.py +++ b/kasa/iot/modules/lighteffectmodule.py @@ -2,12 +2,12 @@ from __future__ import annotations -from ...interfaces.lighteffectinterface import LightEffectInterface +from ...interfaces.lighteffectinterface import LightEffect from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..iotmodule import IotModule -class LightEffectModule(IotModule, LightEffectInterface): +class LightEffectModule(IotModule, LightEffect): """Implementation of dynamic light effects.""" @property diff --git a/kasa/module.py b/kasa/module.py index 3ce3cf11a..35a4a9207 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -16,8 +16,8 @@ if TYPE_CHECKING: from .device import Device as DeviceType # avoid name clash with Device module - from .interfaces.ledinterface import LedInterface - from .interfaces.lighteffectinterface import LightEffectInterface + from .interfaces.ledinterface import Led + from .interfaces.lighteffectinterface import LightEffect from .iot import modules as iot from .smart import modules as smart @@ -34,10 +34,8 @@ class Module(ABC): """ # Common Modules - LightEffect: Final[ModuleName[LightEffectInterface]] = ModuleName( - "LightEffectModule" - ) - Led: Final[ModuleName[LedInterface]] = ModuleName("LedModule") + LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffectModule") + Led: Final[ModuleName[Led]] = ModuleName("LedModule") # IOT only Modules IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index 20f69a2f3..4dd67ec19 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -2,11 +2,11 @@ from __future__ import annotations -from ...interfaces.ledinterface import LedInterface +from ...interfaces.ledinterface import Led from ..smartmodule import SmartModule -class LedModule(SmartModule, LedInterface): +class LedModule(SmartModule, Led): """Implementation of led controls.""" REQUIRED_COMPONENT = "led" diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffectmodule.py index eacf5bba4..5402f2cde 100644 --- a/kasa/smart/modules/lighteffectmodule.py +++ b/kasa/smart/modules/lighteffectmodule.py @@ -6,14 +6,14 @@ import copy from typing import TYPE_CHECKING, Any -from ...interfaces.lighteffectinterface import LightEffectInterface +from ...interfaces.lighteffectinterface import LightEffect from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightEffectModule(SmartModule, LightEffectInterface): +class LightEffectModule(SmartModule, LightEffect): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_effect" From 8ed6c7aaaee58b36f3ce0e36cbff096cacc61fa5 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 10 May 2024 18:48:19 +0100 Subject: [PATCH 12/13] Rename interface files --- kasa/interfaces/{ledinterface.py => led.py} | 0 kasa/interfaces/{lighteffectinterface.py => lighteffect.py} | 0 kasa/iot/modules/ledmodule.py | 2 +- kasa/iot/modules/lighteffectmodule.py | 2 +- kasa/module.py | 4 ++-- kasa/smart/modules/ledmodule.py | 2 +- kasa/smart/modules/lighteffectmodule.py | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename kasa/interfaces/{ledinterface.py => led.py} (100%) rename kasa/interfaces/{lighteffectinterface.py => lighteffect.py} (100%) diff --git a/kasa/interfaces/ledinterface.py b/kasa/interfaces/led.py similarity index 100% rename from kasa/interfaces/ledinterface.py rename to kasa/interfaces/led.py diff --git a/kasa/interfaces/lighteffectinterface.py b/kasa/interfaces/lighteffect.py similarity index 100% rename from kasa/interfaces/lighteffectinterface.py rename to kasa/interfaces/lighteffect.py diff --git a/kasa/iot/modules/ledmodule.py b/kasa/iot/modules/ledmodule.py index a0506b30c..6b3c61948 100644 --- a/kasa/iot/modules/ledmodule.py +++ b/kasa/iot/modules/ledmodule.py @@ -2,7 +2,7 @@ from __future__ import annotations -from ...interfaces.ledinterface import Led +from ...interfaces.led import Led from ..iotmodule import IotModule diff --git a/kasa/iot/modules/lighteffectmodule.py b/kasa/iot/modules/lighteffectmodule.py index 878cce1a1..c53de1920 100644 --- a/kasa/iot/modules/lighteffectmodule.py +++ b/kasa/iot/modules/lighteffectmodule.py @@ -2,7 +2,7 @@ from __future__ import annotations -from ...interfaces.lighteffectinterface import LightEffect +from ...interfaces.lighteffect import LightEffect from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..iotmodule import IotModule diff --git a/kasa/module.py b/kasa/module.py index 35a4a9207..b65f0499a 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -16,8 +16,8 @@ if TYPE_CHECKING: from .device import Device as DeviceType # avoid name clash with Device module - from .interfaces.ledinterface import Led - from .interfaces.lighteffectinterface import LightEffect + from .interfaces.led import Led + from .interfaces.lighteffect import LightEffect from .iot import modules as iot from .smart import modules as smart diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index 4dd67ec19..587be51c4 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -2,7 +2,7 @@ from __future__ import annotations -from ...interfaces.ledinterface import Led +from ...interfaces.led import Led from ..smartmodule import SmartModule diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffectmodule.py index 5402f2cde..a06e979a9 100644 --- a/kasa/smart/modules/lighteffectmodule.py +++ b/kasa/smart/modules/lighteffectmodule.py @@ -6,7 +6,7 @@ import copy from typing import TYPE_CHECKING, Any -from ...interfaces.lighteffectinterface import LightEffect +from ...interfaces.lighteffect import LightEffect from ..smartmodule import SmartModule if TYPE_CHECKING: From 76bc0f0bb47b4a422f9727c03793f7cf43b5d7b6 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Fri, 10 May 2024 19:23:10 +0100 Subject: [PATCH 13/13] Fix python 3.8 subscriptable type in cast --- kasa/iot/iotdevice.py | 6 ++++-- kasa/smart/smartdevice.py | 10 +++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 1bf6cb992..762fc06cd 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -19,7 +19,7 @@ import inspect import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast +from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig @@ -201,7 +201,9 @@ def children(self) -> Sequence[IotDevice]: @property def modules(self) -> ModuleMapping[IotModule]: """Return the device modules.""" - return cast(ModuleMapping[IotModule], self._modules) + if TYPE_CHECKING: + return cast(ModuleMapping[IotModule], self._modules) + return self._modules def add_module(self, name: str | ModuleName[Module], module: IotModule): """Register a module.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 066ddf65b..194e7c17f 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,7 +5,7 @@ import base64 import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast +from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange @@ -111,9 +111,13 @@ def modules(self) -> ModuleMapping[SmartModule]: for k, v in child._modules.items(): if k not in modules: modules[k] = v - return cast(ModuleMapping[SmartModule], modules) + if TYPE_CHECKING: + return cast(ModuleMapping[SmartModule], modules) + return modules - return cast(ModuleMapping[SmartModule], self._modules) + if TYPE_CHECKING: # Needed for python 3.8 + return cast(ModuleMapping[SmartModule], self._modules) + return self._modules def _try_get_response(self, responses: dict, request: str, default=None) -> dict: response = responses.get(request)