diff --git a/kasa/device.py b/kasa/device.py index 0f88f3a1..052abc4c 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -21,7 +21,7 @@ from .protocol import BaseProtocol from .xortransport import XorTransport if TYPE_CHECKING: - from .modulemapping import ModuleMapping + from .modulemapping import ModuleMapping, ModuleName @dataclass @@ -330,52 +330,73 @@ class Device(ABC): return f"<{self.device_type} at {self.host} - update() needed>" return f"<{self.device_type} at {self.host} - {self.alias} ({self.model})>" - _deprecated_attributes = { + _deprecated_device_type_attributes = { # is_type - "is_bulb": (Module.Light, lambda self: self.device_type == DeviceType.Bulb), - "is_dimmer": ( - Module.Light, - lambda self: self.device_type == DeviceType.Dimmer, - ), - "is_light_strip": ( - Module.LightEffect, - lambda self: self.device_type == DeviceType.LightStrip, - ), - "is_plug": (Module.Led, lambda self: self.device_type == DeviceType.Plug), - "is_wallswitch": ( - Module.Led, - lambda self: self.device_type == DeviceType.WallSwitch, - ), - "is_strip": (None, lambda self: self.device_type == DeviceType.Strip), - "is_strip_socket": ( - None, - lambda self: self.device_type == DeviceType.StripSocket, - ), # TODO - # is_light_function - "is_color": ( - Module.Light, - lambda self: Module.Light in self.modules - and self.modules[Module.Light].is_color, - ), - "is_dimmable": ( - Module.Light, - lambda self: Module.Light in self.modules - and self.modules[Module.Light].is_dimmable, - ), - "is_variable_color_temp": ( - Module.Light, - lambda self: Module.Light in self.modules - and self.modules[Module.Light].is_variable_color_temp, - ), + "is_bulb": (Module.Light, DeviceType.Bulb), + "is_dimmer": (Module.Light, DeviceType.Dimmer), + "is_light_strip": (Module.LightEffect, DeviceType.LightStrip), + "is_plug": (Module.Led, DeviceType.Plug), + "is_wallswitch": (Module.Led, DeviceType.WallSwitch), + "is_strip": (None, DeviceType.Strip), + "is_strip_socket": (None, DeviceType.StripSocket), } - def __getattr__(self, name) -> bool: - if name in self._deprecated_attributes: - module = self._deprecated_attributes[name][0] - func = self._deprecated_attributes[name][1] + def _get_replacing_attr(self, module_name: ModuleName, *attrs): + if module_name not in self.modules: + return None + + for attr in attrs: + if hasattr(self.modules[module_name], attr): + return getattr(self.modules[module_name], attr) + + return None + + _deprecated_other_attributes = { + # light attributes + "is_color": (Module.Light, ["is_color"]), + "is_dimmable": (Module.Light, ["is_dimmable"]), + "is_variable_color_temp": (Module.Light, ["is_variable_color_temp"]), + "brightness": (Module.Light, ["brightness"]), + "set_brightness": (Module.Light, ["set_brightness"]), + "hsv": (Module.Light, ["hsv"]), + "set_hsv": (Module.Light, ["set_hsv"]), + "color_temp": (Module.Light, ["color_temp"]), + "set_color_temp": (Module.Light, ["set_color_temp"]), + "valid_temperature_range": (Module.Light, ["valid_temperature_range"]), + "has_effects": (Module.Light, ["has_effects"]), + # led attributes + "led": (Module.Led, ["led"]), + "set_led": (Module.Led, ["set_led"]), + # light effect attributes + # The return values for effect is a str instead of dict so the lightstrip + # modules have a _deprecated method to return the value as before. + "effect": (Module.LightEffect, ["_deprecated_effect", "effect"]), + # The return values for effect_list includes the Off effect so the lightstrip + # modules have a _deprecated method to return the values as before. + "effect_list": (Module.LightEffect, ["_deprecated_effect_list", "effect_list"]), + "set_effect": (Module.LightEffect, ["set_effect"]), + "set_custom_effect": (Module.LightEffect, ["set_custom_effect"]), + } + + def __getattr__(self, name): + # is_device_type + if dep_device_type_attr := self._deprecated_device_type_attributes.get(name): + module = dep_device_type_attr[0] msg = f"{name} is deprecated" if module: msg += f", use: {module} in device.modules instead" warn(msg, DeprecationWarning, stacklevel=1) - return func(self) + return self.device_type == dep_device_type_attr[1] + # Other deprecated attributes + if (dep_attr := self._deprecated_other_attributes.get(name)) and ( + (replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1])) + is not None + ): + module_name = dep_attr[0] + msg = ( + f"{name} is deprecated, use: " + + f"Module.{module_name} in device.modules instead" + ) + warn(msg, DeprecationWarning, stacklevel=1) + return replacing_attr raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 51df94d1..ffeac280 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -247,7 +247,7 @@ class IotBulb(IotDevice): @property # type: ignore @requires_update - def valid_temperature_range(self) -> ColorTempRange: + def _valid_temperature_range(self) -> ColorTempRange: """Return the device-specific white temperature range (in Kelvin). :return: White temperature range in Kelvin (minimum, maximum) @@ -284,7 +284,7 @@ class IotBulb(IotDevice): @property # type: ignore @requires_update - def has_effects(self) -> bool: + def _has_effects(self) -> bool: """Return True if the device supports effects.""" return "lighting_effect_state" in self.sys_info @@ -347,7 +347,7 @@ class IotBulb(IotDevice): @property # type: ignore @requires_update - def hsv(self) -> HSV: + def _hsv(self) -> HSV: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) @@ -364,7 +364,7 @@ class IotBulb(IotDevice): return HSV(hue, saturation, value) @requires_update - async def set_hsv( + async def _set_hsv( self, hue: int, saturation: int, @@ -404,7 +404,7 @@ class IotBulb(IotDevice): @property # type: ignore @requires_update - def color_temp(self) -> int: + def _color_temp(self) -> int: """Return color temperature of the device in kelvin.""" if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") @@ -413,7 +413,7 @@ class IotBulb(IotDevice): return int(light_state["color_temp"]) @requires_update - async def set_color_temp( + async def _set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None ) -> dict: """Set the color temperature of the device in kelvin. @@ -444,7 +444,7 @@ class IotBulb(IotDevice): @property # type: ignore @requires_update - def brightness(self) -> int: + def _brightness(self) -> int: """Return the current brightness in percentage.""" if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") @@ -453,7 +453,7 @@ class IotBulb(IotDevice): return int(light_state["brightness"]) @requires_update - async def set_brightness( + async def _set_brightness( self, brightness: int, *, transition: int | None = None ) -> dict: """Set the brightness in percentage. diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index ef99f749..740d9bb5 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -91,7 +91,7 @@ class IotDimmer(IotPlug): @property # type: ignore @requires_update - def brightness(self) -> int: + def _brightness(self) -> int: """Return current brightness on dimmers. Will return a range between 0 - 100. @@ -103,7 +103,7 @@ class IotDimmer(IotPlug): return int(sys_info["brightness"]) @requires_update - async def set_brightness(self, brightness: int, *, transition: int | None = None): + async def _set_brightness(self, brightness: int, *, transition: int | None = None): """Set the new dimmer brightness level in percentage. :param int transition: transition duration in milliseconds. @@ -222,3 +222,13 @@ class IotDimmer(IotPlug): """Whether the switch supports brightness changes.""" sys_info = self.sys_info return "brightness" in sys_info + + @property + def _is_variable_color_temp(self) -> bool: + """Whether the device supports variable color temp.""" + return False + + @property + def _is_color(self) -> bool: + """Whether the device supports color.""" + return False diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 6bc56258..f6a9719d 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -6,9 +6,8 @@ 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 -from .iotdevice import KasaException, requires_update +from .iotdevice import requires_update from .modules.lighteffect import LightEffect @@ -70,68 +69,3 @@ class IotLightStrip(IotBulb): def length(self) -> int: """Return length of the strip.""" return self.sys_info["length"] - - @property # type: ignore - @requires_update - def effect(self) -> dict: - """Return effect state. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ - # LightEffectModule returns the current effect name - # so return the dict here for backwards compatibility - return self.sys_info["lighting_effect_state"] - - @property # type: ignore - @requires_update - def effect_list(self) -> list[str] | None: - """Return built-in effects list. - - Example: - ['Aurora', 'Bubbling Cauldron', ...] - """ - # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value - # so return the original effect names here for backwards compatibility - return EFFECT_NAMES_V1 if self.has_effects else None - - @requires_update - 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 - """ - await self.modules[Module.LightEffect].set_effect( - effect, brightness=brightness, transition=transition - ) - - @requires_update - 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_effects: - raise KasaException("Bulb does not support effects.") - await self.modules[Module.LightEffect].set_custom_effect(effect_dict) diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 07226178..8651bf9a 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -79,16 +79,6 @@ class IotPlug(IotDevice): """Turn the switch off.""" return await self._query_helper("system", "set_relay_state", {"state": 0}) - @property # type: ignore - @requires_update - def led(self) -> bool: - """Return the state of the 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.modules[Module.Led].set_led(state) - class IotWallSwitch(IotPlug): """Representation of a TP-Link Smart Wall Switch.""" diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index c4dcc57f..619046bd 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -147,17 +147,6 @@ class IotStrip(IotDevice): return max(plug.on_since for plug in self.children if plug.on_since is not None) - @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"]) - - async def set_led(self, state: bool): - """Set the state of the led (night mode).""" - await self._query_helper("system", "set_led_off", {"off": int(not state)}) - async def current_consumption(self) -> float: """Get the current power consumption in watts.""" return sum([await plug.current_consumption() for plug in self.children]) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 1bebf817..833709df 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -30,7 +30,7 @@ class Light(IotModule, LightInterface): super()._initialize_features() device = self._device - if self._device.is_dimmable: + if self._device._is_dimmable: self._add_feature( Feature( device, @@ -45,7 +45,7 @@ class Light(IotModule, LightInterface): category=Feature.Category.Primary, ) ) - if self._device.is_variable_color_temp: + if self._device._is_variable_color_temp: self._add_feature( Feature( device=device, @@ -59,7 +59,7 @@ class Light(IotModule, LightInterface): type=Feature.Type.Number, ) ) - if self._device.is_color: + if self._device._is_color: self._add_feature( Feature( device=device, @@ -96,7 +96,7 @@ class Light(IotModule, LightInterface): @property # type: ignore def brightness(self) -> int: """Return the current brightness in percentage.""" - return self._device.brightness + return self._device._brightness async def set_brightness( self, brightness: int, *, transition: int | None = None @@ -106,7 +106,7 @@ class Light(IotModule, LightInterface): :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - return await self._device.set_brightness(brightness, transition=transition) + return await self._device._set_brightness(brightness, transition=transition) @property def is_color(self) -> bool: @@ -127,7 +127,7 @@ class Light(IotModule, LightInterface): """Return True if the device supports effects.""" if (bulb := self._get_bulb_device()) is None: return False - return bulb.has_effects + return bulb._has_effects @property def hsv(self) -> HSV: @@ -137,7 +137,7 @@ class Light(IotModule, LightInterface): """ if (bulb := self._get_bulb_device()) is None or not bulb._is_color: raise KasaException("Light does not support color.") - return bulb.hsv + return bulb._hsv async def set_hsv( self, @@ -158,7 +158,7 @@ class Light(IotModule, LightInterface): """ if (bulb := self._get_bulb_device()) is None or not bulb._is_color: raise KasaException("Light does not support color.") - return await bulb.set_hsv(hue, saturation, value, transition=transition) + return await bulb._set_hsv(hue, saturation, value, transition=transition) @property def valid_temperature_range(self) -> ColorTempRange: @@ -170,7 +170,7 @@ class Light(IotModule, LightInterface): bulb := self._get_bulb_device() ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") - return bulb.valid_temperature_range + return bulb._valid_temperature_range @property def color_temp(self) -> int: @@ -179,7 +179,7 @@ class Light(IotModule, LightInterface): bulb := self._get_bulb_device() ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") - return bulb.color_temp + return bulb._color_temp async def set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None @@ -195,6 +195,6 @@ class Light(IotModule, LightInterface): bulb := self._get_bulb_device() ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") - return await bulb.set_color_temp( + return await bulb._set_color_temp( temp, brightness=brightness, transition=transition ) diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py index de12fabb..54b4725b 100644 --- a/kasa/iot/modules/lighteffect.py +++ b/kasa/iot/modules/lighteffect.py @@ -94,3 +94,29 @@ class LightEffect(IotModule, LightEffectInterface): def query(self): """Return the base query.""" return {} + + @property # type: ignore + def _deprecated_effect(self) -> dict: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # LightEffectModule returns the current effect name + # so return the dict here for backwards compatibility + return self.data["lighting_effect_state"] + + @property # type: ignore + def _deprecated_effect_list(self) -> list[str] | None: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value + # so return the original effect names here for backwards compatibility + return EFFECT_NAMES_V1 diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index b47f3fde..854cf481 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -107,3 +107,29 @@ class LightStripEffect(SmartModule, LightEffectInterface): def query(self): """Return the base query.""" return {} + + @property # type: ignore + def _deprecated_effect(self) -> dict: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # LightEffectModule returns the current effect name + # so return the dict here for backwards compatibility + return self.data["lighting_effect"] + + @property # type: ignore + def _deprecated_effect_list(self) -> list[str] | None: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value + # so return the original effect names here for backwards compatibility + return EFFECT_NAMES diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index 6fd63d15..d8f28d1b 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -9,7 +9,7 @@ from unittest.mock import Mock, patch import pytest import kasa -from kasa import Credentials, Device, DeviceConfig, DeviceType +from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module from kasa.iot import IotDevice from kasa.smart import SmartChildDevice, SmartDevice @@ -139,14 +139,12 @@ deprecated_is_light_function_smart_module = { } -def test_deprecated_attributes(dev: SmartDevice): +def test_deprecated_device_type_attributes(dev: SmartDevice): """Test deprecated attributes on all devices.""" - tested_keys = set() def _test_attr(attribute): - tested_keys.add(attribute) msg = f"{attribute} is deprecated" - if module := Device._deprecated_attributes[attribute][0]: + if module := Device._deprecated_device_type_attributes[attribute][0]: msg += f", use: {module} in device.modules instead" with pytest.deprecated_call(match=msg): val = getattr(dev, attribute) @@ -157,20 +155,86 @@ def test_deprecated_attributes(dev: SmartDevice): expected_val = dev.device_type == deprecated_is_device_type[attribute] assert val == expected_val - for attribute in deprecated_is_light_function_smart_module: - val = _test_attr(attribute) - if isinstance(dev, SmartDevice): - expected_val = ( - deprecated_is_light_function_smart_module[attribute] in dev.modules - ) - elif hasattr(dev, f"_{attribute}"): - expected_val = getattr(dev, f"_{attribute}") - else: - expected_val = False - assert val == expected_val - assert len(tested_keys) == len(Device._deprecated_attributes) - untested_keys = [ - key for key in Device._deprecated_attributes if key not in tested_keys - ] - assert len(untested_keys) == 0 +async def _test_attribute( + dev: Device, attribute_name, is_expected, module_name, *args, will_raise=False +): + if is_expected and will_raise: + ctx = pytest.raises(will_raise) + elif is_expected: + ctx = pytest.deprecated_call( + match=( + f"{attribute_name} is deprecated, use: Module." + + f"{module_name} in device.modules instead" + ) + ) + else: + ctx = pytest.raises( + AttributeError, match=f"Device has no attribute '{attribute_name}'" + ) + + with ctx: + if args: + await getattr(dev, attribute_name)(*args) + else: + attribute_val = getattr(dev, attribute_name) + assert attribute_val is not None + + +async def test_deprecated_light_effect_attributes(dev: Device): + light_effect = dev.modules.get(Module.LightEffect) + + await _test_attribute(dev, "effect", bool(light_effect), "LightEffect") + await _test_attribute(dev, "effect_list", bool(light_effect), "LightEffect") + await _test_attribute(dev, "set_effect", bool(light_effect), "LightEffect", "Off") + exc = ( + NotImplementedError + if light_effect and not light_effect.has_custom_effects + else None + ) + await _test_attribute( + dev, + "set_custom_effect", + bool(light_effect), + "LightEffect", + {"enable": 0, "name": "foo", "id": "bar"}, + will_raise=exc, + ) + + +async def test_deprecated_light_attributes(dev: Device): + light = dev.modules.get(Module.Light) + + await _test_attribute(dev, "is_dimmable", bool(light), "Light") + await _test_attribute(dev, "is_color", bool(light), "Light") + await _test_attribute(dev, "is_variable_color_temp", bool(light), "Light") + + exc = KasaException if light and not light.is_dimmable else None + await _test_attribute(dev, "brightness", bool(light), "Light", will_raise=exc) + await _test_attribute( + dev, "set_brightness", bool(light), "Light", 50, will_raise=exc + ) + + exc = KasaException if light and not light.is_color else None + await _test_attribute(dev, "hsv", bool(light), "Light", will_raise=exc) + await _test_attribute( + dev, "set_hsv", bool(light), "Light", 50, 50, 50, will_raise=exc + ) + + exc = KasaException if light and not light.is_variable_color_temp else None + await _test_attribute(dev, "color_temp", bool(light), "Light", will_raise=exc) + await _test_attribute( + dev, "set_color_temp", bool(light), "Light", 2700, will_raise=exc + ) + await _test_attribute( + dev, "valid_temperature_range", bool(light), "Light", will_raise=exc + ) + + await _test_attribute(dev, "has_effects", bool(light), "Light") + + +async def test_deprecated_other_attributes(dev: Device): + led_module = dev.modules.get(Module.Led) + + await _test_attribute(dev, "led", bool(led_module), "Led") + await _test_attribute(dev, "set_led", bool(led_module), "Led", True)