From 2b1fbdbb5c2150655390232666df9f9d85573faa Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 28 Nov 2023 23:13:51 +0100 Subject: [PATCH 01/19] Add support for tapo light bulbs --- kasa/device_factory.py | 3 +- kasa/device_type.py | 1 + kasa/tapo/__init__.py | 3 +- kasa/tapo/tapobulb.py | 106 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 kasa/tapo/tapobulb.py diff --git a/kasa/device_factory.py b/kasa/device_factory.py index be293ee27..57a4dca54 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -18,7 +18,7 @@ from .smartplug import SmartPlug from .smartprotocol import SmartProtocol from .smartstrip import SmartStrip -from .tapo.tapoplug import TapoPlug +from .tapo import TapoBulb, TapoPlug DEVICE_TYPE_TO_CLASS = { DeviceType.Plug: SmartPlug, @@ -27,6 +27,7 @@ DeviceType.Dimmer: SmartDimmer, DeviceType.LightStrip: SmartLightStrip, DeviceType.TapoPlug: TapoPlug, + DeviceType.TapoBulb: TapoBulb, } _LOGGER = logging.getLogger(__name__) diff --git a/kasa/device_type.py b/kasa/device_type.py index c86573065..8373d730c 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -15,6 +15,7 @@ class DeviceType(Enum): Dimmer = "dimmer" LightStrip = "lightstrip" TapoPlug = "tapoplug" + TapoBulb = "tapobulb" Unknown = "unknown" @staticmethod diff --git a/kasa/tapo/__init__.py b/kasa/tapo/__init__.py index 0ec72f3dc..f6706a058 100644 --- a/kasa/tapo/__init__.py +++ b/kasa/tapo/__init__.py @@ -1,5 +1,4 @@ """Package for supporting tapo-branded and newer kasa devices.""" from .tapodevice import TapoDevice from .tapoplug import TapoPlug - -__all__ = ["TapoDevice", "TapoPlug"] +from .tapobulb import TapoBulb diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py new file mode 100644 index 000000000..2fb168337 --- /dev/null +++ b/kasa/tapo/tapobulb.py @@ -0,0 +1,106 @@ +from typing import Dict, Optional, List + +from .tapodevice import TapoDevice +from ..smartbulb import SmartBulb, HSV, ColorTempRange, SmartBulbPreset + + +class TapoBulb(SmartBulb, TapoDevice): + + @property + def is_color(self) -> bool: + # TODO: this makes an assumption that only color bulbs report this + return "hue" in self._info + + @property + def is_dimmable(self) -> bool: + # TODO: this makes an assumption that only dimmables report this + return "brightness" in self._info + + @property + def is_variable_color_temp(self) -> bool: + # TODO: this makes an assumption, that only ct bulbs report this + return bool(self._info.get("color_temp_range", False)) + + @property + def valid_temperature_range(self) -> ColorTempRange: + ct_range = self._info.get("color_temp_range") + return ColorTempRange(min=ct_range[0], max=ct_range[1]) + + @property + def has_effects(self) -> bool: + return "dynamic_light_effect_enable" in self._info + + # Effects + # * If no effect is active, dynamic_light_effect_id does not appear in the status report + # * Find out how to stop the effect + # 'dynamic_light_effect_id': 'L1', => party + # 'dynamic_light_effect_id': 'L2', => relax + + @property + def hsv(self) -> HSV: + h, s, v = self._info.get("hue"), self._info.get("saturation"), self._info.get("brightness") + + return HSV(hue=h, saturation=s, value=v) + + @property + def color_temp(self) -> int: + return self._info.get("color_temp") + + @property + def brightness(self) -> int: + return self._info.get("brightness") + + @property + def is_on(self) -> bool: + """This seems to be shared among tapo devices, move to TapoDevice class.""" + return self._info.get("device_on") + + async def set_hsv(self, hue: int, saturation: int, value: Optional[int] = None, *, + transition: Optional[int] = None) -> Dict: + return await self.protocol.query({"set_device_info": { + "hue": hue, + "saturation": saturation, + "brightness": value, + }}) + + + async def set_color_temp(self, temp: int, *, brightness=None, transition: Optional[int] = None) -> Dict: + # TODO: Decide how to handle brightness and transition + # TODO: Note, trying to set brightness at the same time with color_temp causes error -1008 + return await self.protocol.query({"set_device_info": {"color_temp": temp}}) + + + async def set_brightness(self, brightness: int, *, transition: Optional[int] = None) -> Dict: + # TODO: Decide how to handle transitions + return await self.protocol.query({"set_device_info": {"brightness": brightness}}) + + # Default state information, should be made to settings + """ + "info": { + "default_states": { + "re_power_type": "always_on", + "type": "last_states", + "state": { + "brightness": 36, + "hue": 0, + "saturation": 0, + "color_temp": 2700, + }, + }, + """ + + def _update_state_information(self): + """Overridden to add bulb infos.""" + # NOTE: overridden to avoid crashing on unavailable keys like auto_off_status + + async def turn_on(self, **kwargs): + """Turn on the device.""" + return await self.protocol.query({"set_device_info": {"device_on": True}}) + + async def turn_off(self, **kwargs): + """Turn off the device.""" + return await self.protocol.query({"set_device_info": {"device_on": False}}) + + @property + def presets(self) -> List[SmartBulbPreset]: + return [] From cad6aa78c7aae504e9a3d33f6181966fe0a9f85d Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 29 Nov 2023 16:19:02 +0100 Subject: [PATCH 02/19] Use TapoDevice for on/off --- kasa/tapo/tapobulb.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index 2fb168337..548f402d0 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -4,7 +4,7 @@ from ..smartbulb import SmartBulb, HSV, ColorTempRange, SmartBulbPreset -class TapoBulb(SmartBulb, TapoDevice): +class TapoBulb(TapoDevice, SmartBulb): @property def is_color(self) -> bool: @@ -50,11 +50,6 @@ def color_temp(self) -> int: def brightness(self) -> int: return self._info.get("brightness") - @property - def is_on(self) -> bool: - """This seems to be shared among tapo devices, move to TapoDevice class.""" - return self._info.get("device_on") - async def set_hsv(self, hue: int, saturation: int, value: Optional[int] = None, *, transition: Optional[int] = None) -> Dict: return await self.protocol.query({"set_device_info": { @@ -89,18 +84,6 @@ async def set_brightness(self, brightness: int, *, transition: Optional[int] = N }, """ - def _update_state_information(self): - """Overridden to add bulb infos.""" - # NOTE: overridden to avoid crashing on unavailable keys like auto_off_status - - async def turn_on(self, **kwargs): - """Turn on the device.""" - return await self.protocol.query({"set_device_info": {"device_on": True}}) - - async def turn_off(self, **kwargs): - """Turn off the device.""" - return await self.protocol.query({"set_device_info": {"device_on": False}}) - @property def presets(self) -> List[SmartBulbPreset]: return [] From a6b88529b82901b165275ebe7b296387764ea70f Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 29 Nov 2023 16:37:38 +0100 Subject: [PATCH 03/19] Add tapobulbs to discovery --- kasa/device_factory.py | 1 + kasa/discover.py | 1 + 2 files changed, 2 insertions(+) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 57a4dca54..15896e06b 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -140,6 +140,7 @@ def get_device_class_from_type_name(device_type: str) -> Optional[Type[SmartDevi """Return the device class from the type name.""" supported_device_types: dict[str, Type[SmartDevice]] = { "SMART.TAPOPLUG": TapoPlug, + "SMART.TAPOBULB": TapoBulb, "SMART.KASAPLUG": TapoPlug, "IOT.SMARTPLUGSWITCH": SmartPlug, } diff --git a/kasa/discover.py b/kasa/discover.py index 2038369b4..a3c95e69b 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -21,6 +21,7 @@ from kasa.json import loads as json_loads from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartdevice import SmartDevice, SmartDeviceException +from kasa.tapo import TapoBulb from .device_factory import ( get_device_class_from_sys_info, From 7948d8726d2979c1d72a3be3b82a00573da1fb74 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 29 Nov 2023 17:23:28 +0100 Subject: [PATCH 04/19] Add partial support for effects Activating the effect does not work as I thought it would, but this implements rest of the interface from SmartLightStrip. --- kasa/tapo/tapobulb.py | 60 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index 548f402d0..20c43616a 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -1,8 +1,12 @@ -from typing import Dict, Optional, List +from typing import Dict, List, Optional +from ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset from .tapodevice import TapoDevice -from ..smartbulb import SmartBulb, HSV, ColorTempRange, SmartBulbPreset +AVAILABLE_EFFECTS = { + 'L1': "Party", + "L2": "Relax", +} class TapoBulb(TapoDevice, SmartBulb): @@ -30,11 +34,39 @@ def valid_temperature_range(self) -> ColorTempRange: def has_effects(self) -> bool: return "dynamic_light_effect_enable" in self._info - # Effects - # * If no effect is active, dynamic_light_effect_id does not appear in the status report - # * Find out how to stop the effect - # 'dynamic_light_effect_id': 'L1', => party - # 'dynamic_light_effect_id': 'L2', => relax + @property + def effect(self) -> Dict: + """Return effect state. + + This follows the format used by SmartLightStrip. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # If no effect is active, dynamic_light_effect_id does not appear in info + current_effect = self._info.get("dynamic_light_effect_id", "") + data = { + "brightness": self.brightness, + "enable": current_effect != "", + "id": current_effect, + "name": AVAILABLE_EFFECTS.get(current_effect, ""), + } + + return data + + @property + def effect_list(self) -> Optional[List[str]]: + """Return built-in effects list. + + Example: + ['Party', 'Relax', ...] + """ + return AVAILABLE_EFFECTS.keys() if self.has_effects else None + @property def hsv(self) -> HSV: @@ -58,7 +90,6 @@ async def set_hsv(self, hue: int, saturation: int, value: Optional[int] = None, "brightness": value, }}) - async def set_color_temp(self, temp: int, *, brightness=None, transition: Optional[int] = None) -> Dict: # TODO: Decide how to handle brightness and transition # TODO: Note, trying to set brightness at the same time with color_temp causes error -1008 @@ -84,6 +115,19 @@ async def set_brightness(self, brightness: int, *, transition: Optional[int] = N }, """ + async def set_effect( + self, + effect: str, + *, + brightness: Optional[int] = None, + transition: Optional[int] = None, + ) -> None: + """Set an effect on the device.""" + raise NotImplementedError() # TODO: the code above does not seem to activate the effect + return await self.protocol.query({"set_device_info": {"dynamic_light_effect_enable": 1, + "dynamic_light_effect_id": effect}}) + + @property def presets(self) -> List[SmartBulbPreset]: return [] From c7860a9c0338719328f8cea029cb20431926cc3c Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 29 Nov 2023 18:16:32 +0100 Subject: [PATCH 05/19] Add missing __init__ for tapo package --- kasa/tapo/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kasa/tapo/__init__.py b/kasa/tapo/__init__.py index f6706a058..a0bb3e53c 100644 --- a/kasa/tapo/__init__.py +++ b/kasa/tapo/__init__.py @@ -1,4 +1,6 @@ """Package for supporting tapo-branded and newer kasa devices.""" from .tapodevice import TapoDevice -from .tapoplug import TapoPlug from .tapobulb import TapoBulb +from .tapoplug import TapoPlug + +__all__ = ["TapoDevice", "TapoPlug", "TapoBulb"] From f5e8fc1febc73539ff6d88c90460739925fc28a0 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 29 Nov 2023 18:25:52 +0100 Subject: [PATCH 06/19] Make mypy happy --- kasa/tapo/tapobulb.py | 104 ++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 24 deletions(-) diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index 20c43616a..6c8e18c7a 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -1,15 +1,17 @@ +"""Module for tapo-branded smart bulbs (L5**).""" from typing import Dict, List, Optional +from ..exceptions import SmartDeviceException from ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset from .tapodevice import TapoDevice AVAILABLE_EFFECTS = { - 'L1': "Party", + "L1": "Party", "L2": "Relax", } -class TapoBulb(TapoDevice, SmartBulb): +class TapoBulb(TapoDevice, SmartBulb): @property def is_color(self) -> bool: # TODO: this makes an assumption that only color bulbs report this @@ -27,11 +29,16 @@ def is_variable_color_temp(self) -> bool: @property def valid_temperature_range(self) -> ColorTempRange: - ct_range = self._info.get("color_temp_range") + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + ct_range = self._info.get("color_temp_range", [0, 0]) return ColorTempRange(min=ct_range[0], max=ct_range[1]) @property def has_effects(self) -> bool: + """Return True if the device supports effects.""" return "dynamic_light_effect_enable" in self._info @property @@ -65,40 +72,83 @@ def effect_list(self) -> Optional[List[str]]: Example: ['Party', 'Relax', ...] """ - return AVAILABLE_EFFECTS.keys() if self.has_effects else None - + return list(AVAILABLE_EFFECTS.keys()) if self.has_effects else None @property def hsv(self) -> HSV: - h, s, v = self._info.get("hue"), self._info.get("saturation"), self._info.get("brightness") + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if not self.is_color: + raise SmartDeviceException("Bulb does not support color.") + + h, s, v = ( + self._info.get("hue", 0), + self._info.get("saturation", 0), + self._info.get("brightness", 0), + ) return HSV(hue=h, saturation=s, value=v) @property def color_temp(self) -> int: - return self._info.get("color_temp") + """Whether the bulb supports color temperature changes.""" + if not self.is_variable_color_temp: + raise SmartDeviceException("Bulb does not support colortemp.") + + return self._info.get("color_temp", -1) @property def brightness(self) -> int: - return self._info.get("brightness") + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise SmartDeviceException("Bulb is not dimmable.") - async def set_hsv(self, hue: int, saturation: int, value: Optional[int] = None, *, - transition: Optional[int] = None) -> Dict: - return await self.protocol.query({"set_device_info": { - "hue": hue, - "saturation": saturation, - "brightness": value, - }}) + return self._info.get("brightness", -1) - async def set_color_temp(self, temp: int, *, brightness=None, transition: Optional[int] = None) -> Dict: + async def set_hsv( + self, + hue: int, + saturation: int, + value: Optional[int] = None, + *, + transition: Optional[int] = None, + ) -> Dict: + if not self.is_color: + raise SmartDeviceException("Bulb does not support color.") + + return await self.protocol.query( + { + "set_device_info": { + "hue": hue, + "saturation": saturation, + "brightness": value, + } + } + ) + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: Optional[int] = None + ) -> Dict: # TODO: Decide how to handle brightness and transition - # TODO: Note, trying to set brightness at the same time with color_temp causes error -1008 - return await self.protocol.query({"set_device_info": {"color_temp": temp}}) + # TODO: Note, trying to set brightness at the same time + # with color_temp causes error -1008 + if not self.is_variable_color_temp: + raise SmartDeviceException("Bulb does not support colortemp.") + return await self.protocol.query({"set_device_info": {"color_temp": temp}}) - async def set_brightness(self, brightness: int, *, transition: Optional[int] = None) -> Dict: + async def set_brightness( + self, brightness: int, *, transition: Optional[int] = None + ) -> Dict: # TODO: Decide how to handle transitions - return await self.protocol.query({"set_device_info": {"brightness": brightness}}) + if not self.is_dimmable: # pragma: no cover + raise SmartDeviceException("Bulb is not dimmable.") + + return await self.protocol.query( + {"set_device_info": {"brightness": brightness}} + ) # Default state information, should be made to settings """ @@ -123,10 +173,16 @@ async def set_effect( transition: Optional[int] = None, ) -> None: """Set an effect on the device.""" - raise NotImplementedError() # TODO: the code above does not seem to activate the effect - return await self.protocol.query({"set_device_info": {"dynamic_light_effect_enable": 1, - "dynamic_light_effect_id": effect}}) - + raise NotImplementedError() + # TODO: the code below does to activate the effect but gives no error + return await self.protocol.query( + { + "set_device_info": { + "dynamic_light_effect_enable": 1, + "dynamic_light_effect_id": effect, + } + } + ) @property def presets(self) -> List[SmartBulbPreset]: From b12f3d99839ffafcbcde2033ed7b745ac27aa406 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 29 Nov 2023 18:32:14 +0100 Subject: [PATCH 07/19] Add docstrings to make ruff happy --- kasa/discover.py | 1 + kasa/tapo/tapobulb.py | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index a3c95e69b..e231e5ede 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -22,6 +22,7 @@ from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartdevice import SmartDevice, SmartDeviceException from kasa.tapo import TapoBulb +from kasa.tapo.tapoplug import TapoPlug from .device_factory import ( get_device_class_from_sys_info, diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index 6c8e18c7a..eebef8394 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -12,18 +12,26 @@ class TapoBulb(TapoDevice, SmartBulb): + """Representation of a TP-Link Tapo Bulb. + + Documentation TBD. See :class:`~kasa.smartbulb.SmartBulb` for now. + """ + @property def is_color(self) -> bool: + """Whether the bulb supports color changes.""" # TODO: this makes an assumption that only color bulbs report this return "hue" in self._info @property def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" # TODO: this makes an assumption that only dimmables report this return "brightness" in self._info @property def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" # TODO: this makes an assumption, that only ct bulbs report this return bool(self._info.get("color_temp_range", False)) @@ -115,6 +123,15 @@ async def set_hsv( *, transition: Optional[int] = None, ) -> Dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ if not self.is_color: raise SmartDeviceException("Bulb does not support color.") @@ -131,7 +148,13 @@ async def set_hsv( async def set_color_temp( self, temp: int, *, brightness=None, transition: Optional[int] = None ) -> Dict: - # TODO: Decide how to handle brightness and transition + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ # TODO: Note, trying to set brightness at the same time # with color_temp causes error -1008 if not self.is_variable_color_temp: @@ -142,7 +165,13 @@ async def set_color_temp( async def set_brightness( self, brightness: int, *, transition: Optional[int] = None ) -> Dict: - # TODO: Decide how to handle transitions + """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 SmartDeviceException("Bulb is not dimmable.") @@ -186,4 +215,5 @@ async def set_effect( @property def presets(self) -> List[SmartBulbPreset]: - return [] + """Return a list of available bulb setting presets.""" + raise NotImplementedError() From 6e09ab6b9ce081286789e0dc0cdad1fd7eba67c9 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sun, 3 Dec 2023 16:54:29 +0100 Subject: [PATCH 08/19] Implement state_information and has_emeter --- kasa/tapo/__init__.py | 2 +- kasa/tapo/tapobulb.py | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/kasa/tapo/__init__.py b/kasa/tapo/__init__.py index a0bb3e53c..eeb3670cf 100644 --- a/kasa/tapo/__init__.py +++ b/kasa/tapo/__init__.py @@ -1,6 +1,6 @@ """Package for supporting tapo-branded and newer kasa devices.""" -from .tapodevice import TapoDevice from .tapobulb import TapoBulb +from .tapodevice import TapoDevice from .tapoplug import TapoPlug __all__ = ["TapoDevice", "TapoPlug", "TapoBulb"] diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index eebef8394..6ddc55f3f 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -1,5 +1,5 @@ """Module for tapo-branded smart bulbs (L5**).""" -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from ..exceptions import SmartDeviceException from ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset @@ -17,6 +17,17 @@ class TapoBulb(TapoDevice, SmartBulb): Documentation TBD. See :class:`~kasa.smartbulb.SmartBulb` for now. """ + @property + def has_emeter(self) -> bool: + """Bulbs have only historical emeter. + + {'usage': + 'power_usage': {'today': 6, 'past7': 106, 'past30': 106}, + 'saved_power': {'today': 35, 'past7': 529, 'past30': 529}, + } + """ + return False + @property def is_color(self) -> bool: """Whether the bulb supports color changes.""" @@ -213,7 +224,25 @@ async def set_effect( } ) + @property # type: ignore + def state_information(self) -> Dict[str, Any]: + """Return bulb-specific state information.""" + info: Dict[str, Any] = { + # TODO: re-enable after we don't inherit from smartbulb + # **super().state_information + "Brightness": self.brightness, + "Is dimmable": self.is_dimmable, + } + if self.is_variable_color_temp: + info["Color temperature"] = self.color_temp + info["Valid temperature range"] = self.valid_temperature_range + if self.is_color: + info["HSV"] = self.hsv + info["Presets"] = self.presets + + return info + @property def presets(self) -> List[SmartBulbPreset]: """Return a list of available bulb setting presets.""" - raise NotImplementedError() + return [] From 74dcc0ae335883c1be9796daa7adb92096355c9e Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Mon, 4 Dec 2023 20:05:52 +0100 Subject: [PATCH 09/19] Import tapoplug from kasa.tapo package --- kasa/discover.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index e231e5ede..50bd5af9a 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -21,8 +21,7 @@ from kasa.json import loads as json_loads from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartdevice import SmartDevice, SmartDeviceException -from kasa.tapo import TapoBulb -from kasa.tapo.tapoplug import TapoPlug +from kasa.tapo import TapoBulb, TapoPlug from .device_factory import ( get_device_class_from_sys_info, From 38dc81104c9ccc7eae51a4d308167decfd2f8778 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sun, 3 Dec 2023 16:57:10 +0100 Subject: [PATCH 10/19] Add tapo L530 fixture --- .../fixtures/smart/L530.smart_3.0_1.0.6.json | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L530.smart_3.0_1.0.6.json diff --git a/kasa/tests/fixtures/smart/L530.smart_3.0_1.0.6.json b/kasa/tests/fixtures/smart/L530.smart_3.0_1.0.6.json new file mode 100644 index 000000000..06e7cded6 --- /dev/null +++ b/kasa/tests/fixtures/smart/L530.smart_3.0_1.0.6.json @@ -0,0 +1,186 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "light_effect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_device_info": { + "avatar": "bulb", + "brightness": 100, + "color_temp": 2500, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 2500, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.6 Build 230509 Rel.195312", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "00-00-00-00-00-00", + "model": "L530", + "nickname": "c21hcnRlIFdMQU4tR2zDvGhiaXJuZQ==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -38, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "IyNNQVNLRUROQU1FIyM=", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1701618972 + }, + "get_device_usage": { + "power_usage": { + "past30": 107, + "past7": 107, + "today": 7 + }, + "saved_power": { + "past30": 535, + "past7": 535, + "today": 41 + }, + "time_usage": { + "past30": 642, + "past7": 642, + "today": 48 + } + } +} From 8426056fd969ed34ecdac0872e6ebd8a483652b2 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Mon, 4 Dec 2023 20:18:28 +0100 Subject: [PATCH 11/19] Enable tests for L530 fixture --- kasa/tests/conftest.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 50d2f0def..15e36338b 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -21,7 +21,7 @@ SmartStrip, TPLinkSmartHomeProtocol, ) -from kasa.tapo import TapoDevice, TapoPlug +from kasa.tapo import TapoDevice, TapoPlug, TapoBulb from .newfakes import FakeSmartProtocol, FakeTransportProtocol @@ -82,9 +82,11 @@ ALL_DEVICES_IOT = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) +BULBS_SMART = {"L530"} PLUGS_SMART = {"P110"} -ALL_DEVICES_SMART = PLUGS_SMART +ALL_DEVICES_SMART = BULBS_SMART.union(PLUGS_SMART) +BULBS = BULBS.union(BULBS_SMART) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) IP_MODEL_CACHE: Dict[str, str] = {} @@ -138,6 +140,9 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): plug_smart = parametrize( "plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}, ids=idgenerator ) +bulb_smart = parametrize( + "bulb devices smart", BULBS_SMART, protocol_filter={"SMART"}, ids=idgenerator +) device_smart = parametrize( "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"}, ids=idgenerator ) @@ -197,6 +202,7 @@ def check_categories(): + bulb.args[1] + lightstrip.args[1] + plug_smart.args[1] + + bulb_smart.args[1] ) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) if diff: @@ -225,6 +231,9 @@ def device_for_file(model, protocol): for d in PLUGS_SMART: if d in model: return TapoPlug + for d in BULBS_SMART: + if d in model: + return TapoBulb else: for d in STRIPS: if d in model: From e7ed900683da40e0a0f581498000f89973a73e87 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Mon, 4 Dec 2023 20:20:54 +0100 Subject: [PATCH 12/19] Make ruff happy --- kasa/discover.py | 1 - kasa/tests/conftest.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 50bd5af9a..2038369b4 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -21,7 +21,6 @@ from kasa.json import loads as json_loads from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartdevice import SmartDevice, SmartDeviceException -from kasa.tapo import TapoBulb, TapoPlug from .device_factory import ( get_device_class_from_sys_info, diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 15e36338b..725ae0b43 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -21,7 +21,7 @@ SmartStrip, TPLinkSmartHomeProtocol, ) -from kasa.tapo import TapoDevice, TapoPlug, TapoBulb +from kasa.tapo import TapoBulb, TapoDevice, TapoPlug from .newfakes import FakeSmartProtocol, FakeTransportProtocol From a63d543727a46545df3892fcbb4e05625a9afd88 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 5 Dec 2023 17:10:22 +0100 Subject: [PATCH 13/19] Update fixture filename --- .../smart/{L530.smart_3.0_1.0.6.json => L530E(EU)_3.0_1.0.6.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename kasa/tests/fixtures/smart/{L530.smart_3.0_1.0.6.json => L530E(EU)_3.0_1.0.6.json} (100%) diff --git a/kasa/tests/fixtures/smart/L530.smart_3.0_1.0.6.json b/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json similarity index 100% rename from kasa/tests/fixtures/smart/L530.smart_3.0_1.0.6.json rename to kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json From ef9d76b46055f7ce3aab8242627f80571984ca72 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 5 Dec 2023 18:40:37 +0100 Subject: [PATCH 14/19] Raise exceptions on invalid parameters --- kasa/tapo/tapobulb.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py index 6ddc55f3f..e01c69b0a 100644 --- a/kasa/tapo/tapobulb.py +++ b/kasa/tapo/tapobulb.py @@ -146,6 +146,17 @@ async def set_hsv( if not self.is_color: raise SmartDeviceException("Bulb does not support color.") + if not isinstance(hue, int) or not (0 <= hue <= 360): + raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") + + if not isinstance(saturation, int) or not (0 <= saturation <= 100): + raise ValueError( + f"Invalid saturation value: {saturation} (valid range: 0-100%)" + ) + + if value is not None: + self._raise_for_invalid_brightness(value) + return await self.protocol.query( { "set_device_info": { @@ -171,6 +182,14 @@ async def set_color_temp( if not self.is_variable_color_temp: raise SmartDeviceException("Bulb does not support colortemp.") + valid_temperature_range = self.valid_temperature_range + if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: + raise ValueError( + "Temperature should be between {} and {}, was {}".format( + *valid_temperature_range, temp + ) + ) + return await self.protocol.query({"set_device_info": {"color_temp": temp}}) async def set_brightness( From 7545b5bf7306698a04dc60ba66cdf5a041c59002 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 5 Dec 2023 18:43:14 +0100 Subject: [PATCH 15/19] Return results in a wrapped dict --- kasa/tests/newfakes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index c5bf238f8..88b656d4e 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -314,7 +314,7 @@ async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] if method == "component_nego" or method[:4] == "get_": - return self.info[method] + return {"result": self.info[method]} elif method[:4] == "set_": _LOGGER.debug("Call %s not implemented, doing nothing", method) From 5dc4f9f1158af643d5392b56d22e54202897da15 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 5 Dec 2023 18:43:30 +0100 Subject: [PATCH 16/19] Implement set_* --- kasa/tests/newfakes.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 88b656d4e..76faae339 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -313,10 +313,13 @@ async def handshake(self) -> None: async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] + params = request_dict["params"] if method == "component_nego" or method[:4] == "get_": return {"result": self.info[method]} elif method[:4] == "set_": - _LOGGER.debug("Call %s not implemented, doing nothing", method) + target_method = f"get_{method[4:]}" + self.info[target_method].update(params) + return {"result": ""} async def close(self) -> None: pass From c6d317c7804c67c64f65e26e99772b749d4a75fe Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 5 Dec 2023 18:44:10 +0100 Subject: [PATCH 17/19] Reorganize bulbs to iot&smart, fix tests for smarts --- kasa/tests/conftest.py | 79 +++++++++++++++++++++++++---------------- kasa/tests/test_bulb.py | 25 +++++++------ 2 files changed, 62 insertions(+), 42 deletions(-) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 725ae0b43..1d793af5e 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -42,19 +42,27 @@ SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES +# Tapo bulbs +BULBS_SMART_VARIABLE_TEMP = {"L530"} +BULBS_SMART_COLOR = {"L530"} +BULBS_SMART_LIGHT_STRIP = {} +BULBS_SMART_DIMMABLE = {} +BULBS_SMART = BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR).union(BULBS_SMART_DIMMABLE).union(BULBS_SMART_LIGHT_STRIP) + +# Kasa (IOT-prefixed) bulbs +BULBS_IOT_LIGHT_STRIP = {"KL400", "KL430", "KL420"} +BULBS_IOT_VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL135", "KL430"} +BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} +BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} +BULBS_IOT = BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR).union(BULBS_IOT_DIMMABLE).union(BULBS_IOT_LIGHT_STRIP) + +BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} +BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} +BULBS_LIGHT_STRIP = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} -LIGHT_STRIPS = {"KL400", "KL430", "KL420"} -VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL135", "KL430"} -COLOR_BULBS = {"LB130", "KL125", "KL130", "KL135", *LIGHT_STRIPS} BULBS = { - "KL50", - "KL60", - "LB100", - "LB110", - "KL110", - *VARIABLE_TEMP, - *COLOR_BULBS, - *LIGHT_STRIPS, + *BULBS_IOT, + *BULBS_SMART, } @@ -82,20 +90,21 @@ ALL_DEVICES_IOT = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) -BULBS_SMART = {"L530"} PLUGS_SMART = {"P110"} ALL_DEVICES_SMART = BULBS_SMART.union(PLUGS_SMART) -BULBS = BULBS.union(BULBS_SMART) ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) IP_MODEL_CACHE: Dict[str, str] = {} def idgenerator(paramtuple): - return basename(paramtuple[0]) + ( - "" if paramtuple[1] == "IOT" else "-" + paramtuple[1] - ) + try: + return basename(paramtuple[0]) + ( + "" if paramtuple[1] == "IOT" else "-" + paramtuple[1] + ) + except: # TODO: HACK as idgenerator is now used by default + return None def filter_model(desc, model_filter, protocol_filter=None): @@ -110,11 +119,15 @@ def filter_model(desc, model_filter, protocol_filter=None): filtered.append((file, protocol)) filtered_basenames = [basename(f) + "-" + p for f, p in filtered] - print(f"{desc}: {filtered_basenames}") + print(f"# {desc}") + for file in filtered_basenames: + print(f"\t{file}") return filtered def parametrize(desc, devices, protocol_filter=None, ids=None): + if ids is None: + ids = idgenerator return pytest.mark.parametrize( "dev", filter_model(desc, devices, protocol_filter), indirect=True, ids=ids ) @@ -123,31 +136,35 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): has_emeter = parametrize("has emeter", WITH_EMETER) no_emeter = parametrize("no emeter", ALL_DEVICES_IOT - WITH_EMETER) -bulb = parametrize("bulbs", BULBS, ids=idgenerator) -plug = parametrize("plugs", PLUGS, ids=idgenerator) -strip = parametrize("strips", STRIPS, ids=idgenerator) -dimmer = parametrize("dimmers", DIMMERS, ids=idgenerator) -lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=idgenerator) +bulb = parametrize("bulbs", BULBS, protocol_filter={"SMART", "IOT"}) +plug = parametrize("plugs", PLUGS) +strip = parametrize("strips", STRIPS) +dimmer = parametrize("dimmers", DIMMERS) +lightstrip = parametrize("lightstrips", BULBS_LIGHT_STRIP) # bulb types dimmable = parametrize("dimmable", DIMMABLE) non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE) -variable_temp = parametrize("variable color temp", VARIABLE_TEMP) -non_variable_temp = parametrize("non-variable color temp", BULBS - VARIABLE_TEMP) -color_bulb = parametrize("color bulbs", COLOR_BULBS) -non_color_bulb = parametrize("non-color bulbs", BULBS - COLOR_BULBS) +variable_temp = parametrize("variable color temp", BULBS_VARIABLE_TEMP, {"SMART", "IOT"}) +non_variable_temp = parametrize("non-variable color temp", BULBS - BULBS_VARIABLE_TEMP, {"SMART", "IOT"}) +color_bulb = parametrize("color bulbs", BULBS_COLOR, {"SMART", "IOT"}) +non_color_bulb = parametrize("non-color bulbs", BULBS - BULBS_COLOR, {"SMART", "IOT"}) + +color_bulb_iot = parametrize("color bulbs iot", BULBS_COLOR, {"IOT"}) +variable_temp_iot = parametrize("variable color temp iot", BULBS_VARIABLE_TEMP, {"IOT"}) +bulb_iot = parametrize("bulb devices iot", BULBS_IOT) plug_smart = parametrize( - "plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}, ids=idgenerator + "plug devices smart", PLUGS_SMART, protocol_filter={"SMART"} ) bulb_smart = parametrize( - "bulb devices smart", BULBS_SMART, protocol_filter={"SMART"}, ids=idgenerator + "bulb devices smart", BULBS_SMART, protocol_filter={"SMART"} ) device_smart = parametrize( - "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"}, ids=idgenerator + "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"} ) device_iot = parametrize( - "devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"}, ids=idgenerator + "devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"} ) @@ -244,7 +261,7 @@ def device_for_file(model, protocol): return SmartPlug # Light strips are recognized also as bulbs, so this has to go first - for d in LIGHT_STRIPS: + for d in BULBS_IOT_LIGHT_STRIP: if d in model: return SmartLightStrip diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index f73a948b2..5bacf3cc5 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -4,7 +4,9 @@ from .conftest import ( bulb, + bulb_iot, color_bulb, + color_bulb_iot, dimmable, handle_turn_on, non_color_bulb, @@ -12,6 +14,7 @@ non_variable_temp, turn_on, variable_temp, + variable_temp_iot, ) from .newfakes import BULB_SCHEMA, LIGHT_STATE_SCHEMA @@ -38,7 +41,7 @@ async def test_state_attributes(dev: SmartBulb): assert dev.state_information["Is dimmable"] == dev.is_dimmable -@bulb +@bulb_iot async def test_light_state_without_update(dev: SmartBulb, monkeypatch): with pytest.raises(SmartDeviceException): monkeypatch.setitem( @@ -47,7 +50,7 @@ async def test_light_state_without_update(dev: SmartBulb, monkeypatch): print(dev.light_state) -@bulb +@bulb_iot async def test_get_light_state(dev: SmartBulb): LIGHT_STATE_SCHEMA(await dev.get_light_state()) @@ -72,7 +75,7 @@ async def test_hsv(dev: SmartBulb, turn_on): assert brightness == 1 -@color_bulb +@color_bulb_iot async def test_set_hsv_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.set_hsv(10, 10, 100, transition=1000) @@ -138,7 +141,7 @@ async def test_try_set_colortemp(dev: SmartBulb, turn_on): assert dev.color_temp == 2700 -@variable_temp +@variable_temp_iot async def test_set_color_temp_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.set_color_temp(2700, transition=100) @@ -146,7 +149,7 @@ async def test_set_color_temp_transition(dev: SmartBulb, mocker): set_light_state.assert_called_with({"color_temp": 2700}, transition=100) -@variable_temp +@variable_temp_iot async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") @@ -192,7 +195,7 @@ async def test_dimmable_brightness(dev: SmartBulb, turn_on): await dev.set_brightness("foo") -@bulb +@bulb_iot async def test_turn_on_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.turn_on(transition=1000) @@ -204,7 +207,7 @@ async def test_turn_on_transition(dev: SmartBulb, mocker): set_light_state.assert_called_with({"on_off": 0}, transition=100) -@bulb +@bulb_iot async def test_dimmable_brightness_transition(dev: SmartBulb, mocker): set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") await dev.set_brightness(10, transition=1000) @@ -233,7 +236,7 @@ async def test_non_dimmable(dev: SmartBulb): await dev.set_brightness(100) -@bulb +@bulb_iot async def test_ignore_default_not_set_without_color_mode_change_turn_on( dev: SmartBulb, mocker ): @@ -248,7 +251,7 @@ async def test_ignore_default_not_set_without_color_mode_change_turn_on( assert args[2] == {"on_off": 0, "ignore_default": 1} -@bulb +@bulb_iot async def test_list_presets(dev: SmartBulb): presets = dev.presets assert len(presets) == len(dev.sys_info["preferred_state"]) @@ -261,7 +264,7 @@ async def test_list_presets(dev: SmartBulb): assert preset.color_temp == raw["color_temp"] -@bulb +@bulb_iot async def test_modify_preset(dev: SmartBulb, mocker): """Verify that modifying preset calls the and exceptions are raised properly.""" if not dev.presets: @@ -291,7 +294,7 @@ async def test_modify_preset(dev: SmartBulb, mocker): ) -@bulb +@bulb_iot @pytest.mark.parametrize( ("preset", "payload"), [ From d2145281472d64d95538650a825a447942bc5652 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 5 Dec 2023 18:49:08 +0100 Subject: [PATCH 18/19] Fix linting --- devtools/check_readme_vs_fixtures.py | 11 ++++-- kasa/tests/conftest.py | 50 ++++++++++++++++++---------- kasa/tests/test_discovery.py | 4 +-- 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py index 2c1e7d95e..ca75713bc 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -1,11 +1,18 @@ """Script that checks if README.md is missing devices that have fixtures.""" -from kasa.tests.conftest import ALL_DEVICES, BULBS, DIMMERS, LIGHT_STRIPS, PLUGS, STRIPS +from kasa.tests.conftest import ( + ALL_DEVICES, + BULBS, + BULBS_IOT_LIGHT_STRIP, + DIMMERS, + PLUGS, + STRIPS, +) with open("README.md") as f: readme = f.read() typemap = { - "light strips": LIGHT_STRIPS, + "light strips": BULBS_IOT_LIGHT_STRIP, "bulbs": BULBS, "plugs": PLUGS, "strips": STRIPS, diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 1d793af5e..ca923b0f1 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -6,7 +6,7 @@ from json import dumps as json_dumps from os.path import basename from pathlib import Path, PurePath -from typing import Dict, Optional +from typing import Dict, Optional, Set from unittest.mock import MagicMock import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 @@ -45,16 +45,32 @@ # Tapo bulbs BULBS_SMART_VARIABLE_TEMP = {"L530"} BULBS_SMART_COLOR = {"L530"} -BULBS_SMART_LIGHT_STRIP = {} -BULBS_SMART_DIMMABLE = {} -BULBS_SMART = BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR).union(BULBS_SMART_DIMMABLE).union(BULBS_SMART_LIGHT_STRIP) +BULBS_SMART_LIGHT_STRIP: Set[str] = set() +BULBS_SMART_DIMMABLE: Set[str] = set() +BULBS_SMART = ( + BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) + .union(BULBS_SMART_DIMMABLE) + .union(BULBS_SMART_LIGHT_STRIP) +) # Kasa (IOT-prefixed) bulbs BULBS_IOT_LIGHT_STRIP = {"KL400", "KL430", "KL420"} -BULBS_IOT_VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL125", "KL130", "KL135", "KL430"} +BULBS_IOT_VARIABLE_TEMP = { + "LB120", + "LB130", + "KL120", + "KL125", + "KL130", + "KL135", + "KL430", +} BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} -BULBS_IOT = BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR).union(BULBS_IOT_DIMMABLE).union(BULBS_IOT_LIGHT_STRIP) +BULBS_IOT = ( + BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) + .union(BULBS_IOT_DIMMABLE) + .union(BULBS_IOT_LIGHT_STRIP) +) BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} @@ -103,7 +119,7 @@ def idgenerator(paramtuple): return basename(paramtuple[0]) + ( "" if paramtuple[1] == "IOT" else "-" + paramtuple[1] ) - except: # TODO: HACK as idgenerator is now used by default + except: # TODO: HACK as idgenerator is now used by default # noqa: E722 return None @@ -145,8 +161,12 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): # bulb types dimmable = parametrize("dimmable", DIMMABLE) non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE) -variable_temp = parametrize("variable color temp", BULBS_VARIABLE_TEMP, {"SMART", "IOT"}) -non_variable_temp = parametrize("non-variable color temp", BULBS - BULBS_VARIABLE_TEMP, {"SMART", "IOT"}) +variable_temp = parametrize( + "variable color temp", BULBS_VARIABLE_TEMP, {"SMART", "IOT"} +) +non_variable_temp = parametrize( + "non-variable color temp", BULBS - BULBS_VARIABLE_TEMP, {"SMART", "IOT"} +) color_bulb = parametrize("color bulbs", BULBS_COLOR, {"SMART", "IOT"}) non_color_bulb = parametrize("non-color bulbs", BULBS - BULBS_COLOR, {"SMART", "IOT"}) @@ -154,18 +174,12 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): variable_temp_iot = parametrize("variable color temp iot", BULBS_VARIABLE_TEMP, {"IOT"}) bulb_iot = parametrize("bulb devices iot", BULBS_IOT) -plug_smart = parametrize( - "plug devices smart", PLUGS_SMART, protocol_filter={"SMART"} -) -bulb_smart = parametrize( - "bulb devices smart", BULBS_SMART, protocol_filter={"SMART"} -) +plug_smart = parametrize("plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}) +bulb_smart = parametrize("bulb devices smart", BULBS_SMART, protocol_filter={"SMART"}) device_smart = parametrize( "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"} ) -device_iot = parametrize( - "devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"} -) +device_iot = parametrize("devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"}) def get_fixture_data(): diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index ea97d94ad..72555c7ea 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -15,7 +15,7 @@ from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps from kasa.exceptions import AuthenticationException, UnsupportedDeviceException -from .conftest import bulb, dimmer, lightstrip, plug, strip +from .conftest import bulb, bulb_iot, dimmer, lightstrip, plug, strip UNSUPPORTED = { "result": { @@ -46,7 +46,7 @@ async def test_type_detection_plug(dev: SmartDevice): assert d.device_type == DeviceType.Plug -@bulb +@bulb_iot async def test_type_detection_bulb(dev: SmartDevice): d = Discover._get_device_class(dev._last_update)("localhost") # TODO: light_strip is a special case for now to force bulb tests on it From 5a9dec96b2ef81d73015c5b8207e74886f430614 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 5 Dec 2023 18:59:21 +0100 Subject: [PATCH 19/19] Fix BULBS_LIGHT_STRIP back to LIGHT_STRIPS --- devtools/check_readme_vs_fixtures.py | 4 ++-- kasa/tests/conftest.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py index ca75713bc..1f55eea87 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -2,8 +2,8 @@ from kasa.tests.conftest import ( ALL_DEVICES, BULBS, - BULBS_IOT_LIGHT_STRIP, DIMMERS, + LIGHT_STRIPS, PLUGS, STRIPS, ) @@ -12,7 +12,7 @@ readme = f.read() typemap = { - "light strips": BULBS_IOT_LIGHT_STRIP, + "light strips": LIGHT_STRIPS, "bulbs": BULBS, "plugs": PLUGS, "strips": STRIPS, diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index ca923b0f1..0d5180cdd 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -74,8 +74,9 @@ BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} -BULBS_LIGHT_STRIP = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} + +LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} BULBS = { *BULBS_IOT, *BULBS_SMART, @@ -156,7 +157,7 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): plug = parametrize("plugs", PLUGS) strip = parametrize("strips", STRIPS) dimmer = parametrize("dimmers", DIMMERS) -lightstrip = parametrize("lightstrips", BULBS_LIGHT_STRIP) +lightstrip = parametrize("lightstrips", LIGHT_STRIPS) # bulb types dimmable = parametrize("dimmable", DIMMABLE)