diff --git a/kasa/device.py b/kasa/device.py index 8150352d..0f88f3a1 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -7,6 +7,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, Mapping, Sequence +from warnings import warn from .credentials import Credentials from .device_type import DeviceType @@ -208,61 +209,6 @@ class Device(ABC): def sys_info(self) -> dict[str, Any]: """Returns the device info.""" - @property - def is_bulb(self) -> bool: - """Return True if the device is a bulb.""" - return self.device_type == DeviceType.Bulb - - @property - def is_light_strip(self) -> bool: - """Return True if the device is a led strip.""" - return self.device_type == DeviceType.LightStrip - - @property - def is_plug(self) -> bool: - """Return True if the device is a plug.""" - return self.device_type == DeviceType.Plug - - @property - def is_wallswitch(self) -> bool: - """Return True if the device is a switch.""" - return self.device_type == DeviceType.WallSwitch - - @property - def is_strip(self) -> bool: - """Return True if the device is a strip.""" - return self.device_type == DeviceType.Strip - - @property - def is_strip_socket(self) -> bool: - """Return True if the device is a strip socket.""" - return self.device_type == DeviceType.StripSocket - - @property - def is_dimmer(self) -> bool: - """Return True if the device is a dimmer.""" - return self.device_type == DeviceType.Dimmer - - @property - def is_dimmable(self) -> bool: - """Return True if the device is dimmable.""" - return False - - @property - def is_fan(self) -> bool: - """Return True if the device is a fan.""" - return self.device_type == DeviceType.Fan - - @property - def is_variable_color_temp(self) -> bool: - """Return True if the device supports color temperature.""" - return False - - @property - def is_color(self) -> bool: - """Return True if the device supports color changes.""" - return False - def get_plug_by_name(self, name: str) -> Device: """Return child device for the given name.""" for p in self.children: @@ -383,3 +329,53 @@ class Device(ABC): if self._last_update is None: return f"<{self.device_type} at {self.host} - update() needed>" return f"<{self.device_type} at {self.host} - {self.alias} ({self.model})>" + + _deprecated_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, + ), + } + + def __getattr__(self, name) -> bool: + if name in self._deprecated_attributes: + module = self._deprecated_attributes[name][0] + func = self._deprecated_attributes[name][1] + msg = f"{name} is deprecated" + if module: + msg += f", use: {module} in device.modules instead" + warn(msg, DeprecationWarning, stacklevel=1) + return func(self) + raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index e2d86043..51df94d1 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -226,21 +226,21 @@ class IotBulb(IotDevice): @property # type: ignore @requires_update - def is_color(self) -> bool: + def _is_color(self) -> bool: """Whether the bulb supports color changes.""" sys_info = self.sys_info return bool(sys_info["is_color"]) @property # type: ignore @requires_update - def is_dimmable(self) -> bool: + def _is_dimmable(self) -> bool: """Whether the bulb supports brightness changes.""" sys_info = self.sys_info return bool(sys_info["is_dimmable"]) @property # type: ignore @requires_update - def is_variable_color_temp(self) -> bool: + def _is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" sys_info = self.sys_info return bool(sys_info["is_variable_color_temp"]) @@ -252,7 +252,7 @@ class IotBulb(IotDevice): :return: White temperature range in Kelvin (minimum, maximum) """ - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Color temperature not supported") for model, temp_range in TPLINK_KELVIN.items(): @@ -352,7 +352,7 @@ class IotBulb(IotDevice): :return: hue, saturation and value (degrees, %, %) """ - if not self.is_color: + if not self._is_color: raise KasaException("Bulb does not support color.") light_state = cast(dict, self.light_state) @@ -379,7 +379,7 @@ class IotBulb(IotDevice): :param int value: value in percentage [0, 100] :param int transition: transition in milliseconds. """ - if not self.is_color: + if not self._is_color: raise KasaException("Bulb does not support color.") if not isinstance(hue, int) or not (0 <= hue <= 360): @@ -406,7 +406,7 @@ class IotBulb(IotDevice): @requires_update def color_temp(self) -> int: """Return color temperature of the device in kelvin.""" - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") light_state = self.light_state @@ -421,7 +421,7 @@ class IotBulb(IotDevice): :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") valid_temperature_range = self.valid_temperature_range @@ -446,7 +446,7 @@ class IotBulb(IotDevice): @requires_update def brightness(self) -> int: """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover + if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") light_state = self.light_state @@ -461,7 +461,7 @@ class IotBulb(IotDevice): :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - if not self.is_dimmable: # pragma: no cover + if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") self._raise_for_invalid_brightness(brightness) diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index d6f49c24..ef99f749 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -96,7 +96,7 @@ class IotDimmer(IotPlug): Will return a range between 0 - 100. """ - if not self.is_dimmable: + if not self._is_dimmable: raise KasaException("Device is not dimmable.") sys_info = self.sys_info @@ -109,7 +109,7 @@ class IotDimmer(IotPlug): :param int transition: transition duration in milliseconds. Using a transition will cause the dimmer to turn on. """ - if not self.is_dimmable: + if not self._is_dimmable: raise KasaException("Device is not dimmable.") if not isinstance(brightness, int): @@ -218,7 +218,7 @@ class IotDimmer(IotPlug): @property # type: ignore @requires_update - def is_dimmable(self) -> bool: + def _is_dimmable(self) -> bool: """Whether the switch supports brightness changes.""" sys_info = self.sys_info return "brightness" in sys_info diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 89243a1b..1bebf817 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast +from ...device_type import DeviceType from ...exceptions import KasaException from ...feature import Feature from ...interfaces.light import HSV, ColorTempRange @@ -78,14 +79,19 @@ class Light(IotModule, LightInterface): return {} def _get_bulb_device(self) -> IotBulb | None: - if self._device.is_bulb or self._device.is_light_strip: + """For type checker this gets an IotBulb. + + IotDimmer is not a subclass of IotBulb and using isinstance + here at runtime would create a circular import. + """ + if self._device.device_type in {DeviceType.Bulb, DeviceType.LightStrip}: return cast("IotBulb", self._device) return None @property # type: ignore def is_dimmable(self) -> int: """Whether the bulb supports brightness changes.""" - return self._device.is_dimmable + return self._device._is_dimmable @property # type: ignore def brightness(self) -> int: @@ -107,14 +113,14 @@ class Light(IotModule, LightInterface): """Whether the light supports color changes.""" if (bulb := self._get_bulb_device()) is None: return False - return bulb.is_color + return bulb._is_color @property def is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" if (bulb := self._get_bulb_device()) is None: return False - return bulb.is_variable_color_temp + return bulb._is_variable_color_temp @property def has_effects(self) -> bool: @@ -129,7 +135,7 @@ class Light(IotModule, LightInterface): :return: hue, saturation and value (degrees, %, %) """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + if (bulb := self._get_bulb_device()) is None or not bulb._is_color: raise KasaException("Light does not support color.") return bulb.hsv @@ -150,7 +156,7 @@ class Light(IotModule, LightInterface): :param int value: value in percentage [0, 100] :param int transition: transition in milliseconds. """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + 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) @@ -160,14 +166,18 @@ class Light(IotModule, LightInterface): :return: White temperature range in Kelvin (minimum, maximum) """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + if ( + 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 @property def color_temp(self) -> int: """Whether the bulb supports color temperature changes.""" - if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + if ( + 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 @@ -181,7 +191,9 @@ class Light(IotModule, LightInterface): :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + if ( + 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( temp, brightness=brightness, transition=transition diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e1939c70..e4260995 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -14,7 +14,6 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature -from ..interfaces.light import LightPreset from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol @@ -444,11 +443,6 @@ class SmartDevice(Device): """Return if the device has emeter.""" return Module.Energy in self.modules - @property - def is_dimmer(self) -> bool: - """Whether the device acts as a dimmer.""" - return self.is_dimmable - @property def is_on(self) -> bool: """Return true if the device is on.""" @@ -648,8 +642,3 @@ class SmartDevice(Device): return DeviceType.Thermostat _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug - - @property - def presets(self) -> list[LightPreset]: - """Return a list of available bulb setting presets.""" - return [] diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index b9627d9f..e5e1ff72 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -58,7 +58,6 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): fan = dev.modules.get(Module.Fan) assert fan device = fan._device - assert device.is_fan await fan.set_fan_speed_level(1) await dev.update() diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 5cfa25da..2930db57 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -208,7 +208,7 @@ async def test_non_variable_temp(dev: Device): async def test_dimmable_brightness(dev: IotBulb, turn_on): assert isinstance(dev, (IotBulb, IotDimmer)) await handle_turn_on(dev, turn_on) - assert dev.is_dimmable + assert dev._is_dimmable await dev.set_brightness(50) await dev.update() @@ -244,7 +244,7 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): @dimmable_iot async def test_invalid_brightness(dev: IotBulb): - assert dev.is_dimmable + assert dev._is_dimmable with pytest.raises(ValueError): await dev.set_brightness(110) @@ -255,7 +255,7 @@ async def test_invalid_brightness(dev: IotBulb): @non_dimmable_iot async def test_non_dimmable(dev: IotBulb): - assert not dev.is_dimmable + assert not dev._is_dimmable with pytest.raises(KasaException): assert dev.brightness == 0 diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index 76ea1acf..6fd63d15 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 +from kasa import Credentials, Device, DeviceConfig, DeviceType from kasa.iot import IotDevice from kasa.smart import SmartChildDevice, SmartDevice @@ -121,3 +121,56 @@ def test_deprecated_exceptions(exceptions_class, use_class): with pytest.deprecated_call(match=msg): getattr(kasa, exceptions_class) getattr(kasa, use_class.__name__) + + +deprecated_is_device_type = { + "is_bulb": DeviceType.Bulb, + "is_plug": DeviceType.Plug, + "is_dimmer": DeviceType.Dimmer, + "is_light_strip": DeviceType.LightStrip, + "is_wallswitch": DeviceType.WallSwitch, + "is_strip": DeviceType.Strip, + "is_strip_socket": DeviceType.StripSocket, +} +deprecated_is_light_function_smart_module = { + "is_color": "Color", + "is_dimmable": "Brightness", + "is_variable_color_temp": "ColorTemperature", +} + + +def test_deprecated_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]: + msg += f", use: {module} in device.modules instead" + with pytest.deprecated_call(match=msg): + val = getattr(dev, attribute) + return val + + for attribute in deprecated_is_device_type: + val = _test_attr(attribute) + 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