Deprecate device level light, effect and led attributes (#916)

Deprecates the attributes at device level for light, light effects, and led. i.e. device.led, device.is_color. Will continue to support consumers using these attributes and emit a warning.
This commit is contained in:
Steven B 2024-05-15 18:49:08 +01:00 committed by GitHub
parent 133a839f22
commit a2e8d2c4e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 232 additions and 172 deletions

View File

@ -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}")

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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."""

View File

@ -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])

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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)