diff --git a/docs/tutorial.py b/docs/tutorial.py index 8d0a1435..e0cafb22 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -52,9 +52,9 @@ True >>> await dev.update() >>> light.brightness 50 ->>> light.is_color +>>> light.has_feature(light.set_hsv) True ->>> if light.is_color: +>>> if light.has_feature(light.set_hsv): >>> print(light.hsv) HSV(hue=0, saturation=100, value=50) diff --git a/kasa/device.py b/kasa/device.py index 76d7a7c5..345cd62f 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -107,7 +107,7 @@ from __future__ import annotations import logging from abc import ABC, abstractmethod -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime, tzinfo from typing import TYPE_CHECKING, Any, TypeAlias @@ -525,10 +525,47 @@ class Device(ABC): return None + def _get_deprecated_callable_attribute(self, name: str) -> Any | None: + vals: dict[str, tuple[ModuleName, Callable[[Any], Any], str]] = { + "is_dimmable": ( + Module.Light, + lambda c: c.has_feature("set_brightness"), + "light_module.has_feature('set_brightness')", + ), + "is_color": ( + Module.Light, + lambda c: c.has_feature("set_hsv"), + "light_module.has_feature('set_hsv')", + ), + "is_variable_color_temp": ( + Module.Light, + lambda c: c.has_feature("set_color_temp"), + "light_module.has_feature('set_color_temp')", + ), + "valid_temperature_range": ( + Module.Light, + lambda c: c._deprecated_valid_temperature_range(), + "minimum and maximum value of get_feature('set_color_temp')", + ), + "has_effects": ( + Module.Light, + lambda c: Module.LightEffect in c._device.modules, + "Module.LightEffect in c._device.modules", + ), + } + if mod_call_msg := vals.get(name): + mod, call, msg = mod_call_msg + msg = f"{name} is deprecated, use: {msg} instead" + warn(msg, DeprecationWarning, stacklevel=2) + if (module := self.modules.get(mod)) is None: + raise AttributeError(f"Device has no attribute {name!r}") + return call(module) + + 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"]), @@ -536,8 +573,6 @@ class Device(ABC): "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"]), "_deprecated_set_light_state": (Module.Light, ["has_effects"]), # led attributes "led": (Module.Led, ["led"]), @@ -576,6 +611,9 @@ class Device(ABC): msg = f"{name} is deprecated, use device_type property instead" warn(msg, DeprecationWarning, stacklevel=2) return self.device_type == dep_device_type_attr[1] + # callable + if result := self._get_deprecated_callable_attributes(name) is not None: + return result # 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])) diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 298ad1f8..161c0f21 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -25,11 +25,11 @@ Get the light module to interact: You can use the ``is_``-prefixed properties to check for supported features: ->>> light.is_dimmable +>>> light.has_feature(light.set_brightness) True ->>> light.is_color +>>> light.has_feature(light.set_hsv) True ->>> light.is_variable_color_temp +>>> light.has_feature(light.set_color_temp) True All known bulbs support changing the brightness: @@ -43,8 +43,9 @@ All known bulbs support changing the brightness: Bulbs supporting color temperature can be queried for the supported range: ->>> light.valid_temperature_range -ColorTempRange(min=2500, max=6500) +>>> if color_temp_feature := light.get_feature(light.set_color_temp): +>>> print(f"{color_temp_feature.minimum_value}, {color_temp_feature.maximum_value}") +2500, 6500 >>> await light.set_color_temp(3000) >>> await dev.update() >>> light.color_temp @@ -63,10 +64,13 @@ HSV(hue=180, saturation=100, value=80) from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Callable from dataclasses import dataclass -from typing import NamedTuple +from typing import Annotated, Any, NamedTuple +from warnings import warn -from ..module import Module +from ..exceptions import KasaException +from ..module import FeatureAttribute, Module @dataclass @@ -101,35 +105,7 @@ class Light(Module, ABC): @property @abstractmethod - def is_dimmable(self) -> bool: - """Whether the light supports brightness changes.""" - - @property - @abstractmethod - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - - @property - @abstractmethod - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - - @property - @abstractmethod - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - - @property - @abstractmethod - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - - @property - @abstractmethod - def hsv(self) -> HSV: + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) @@ -137,12 +113,12 @@ class Light(Module, ABC): @property @abstractmethod - def color_temp(self) -> int: + def color_temp(self) -> Annotated[int, FeatureAttribute()]: """Whether the bulb supports color temperature changes.""" @property @abstractmethod - def brightness(self) -> int: + def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" @abstractmethod @@ -153,7 +129,7 @@ class Light(Module, ABC): value: int | None = None, *, transition: int | None = None, - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set new HSV. Note, transition is not supported and will be ignored. @@ -167,7 +143,7 @@ class Light(Module, ABC): @abstractmethod async def set_color_temp( self, temp: int, *, brightness: int | None = None, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -179,7 +155,7 @@ class Light(Module, ABC): @abstractmethod async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the brightness in percentage. Note, transition is not supported and will be ignored. @@ -196,3 +172,42 @@ class Light(Module, ABC): @abstractmethod async def set_state(self, state: LightState) -> dict: """Set the light state.""" + + def _deprecated_valid_temperature_range(self) -> ColorTempRange: + if not (temp := self.get_feature(self.set_color_temp)): + raise KasaException("Color temperature not supported") + return ColorTempRange(temp.minimum_value, temp.maximum_value) + + def _deprecated_attributes(self, dep_name: str) -> Callable | None: + map: dict[str, Callable] = { + "is_color": self.set_hsv, + "is_dimmable": self.set_brightness, + "is_variable_color_temp": self.set_color_temp, + } + return map.get(dep_name) + + def __getattr__(self, name: str) -> Any: + if name == "valid_temperature_range": + res = self._deprecated_valid_temperature_range() + msg = ( + "valid_temperature_range is deprecated, use " + "get_feature(self.set_color_temp) minimum_value " + " and maximum_value instead" + ) + warn(msg, DeprecationWarning, stacklevel=2) + return res + + if name == "has_effects": + msg = ( + "has_effects is deprecated, use Module.LightEffect " + "in device.modules instead" + ) + warn(msg, DeprecationWarning, stacklevel=2) + return Module.LightEffect in self._device.modules + + if attr := self._deprecated_attributes(name): + msg = f"{name} is deprecated, use has_feature({attr}) instead" + warn(msg, DeprecationWarning, stacklevel=2) + return self.has_feature(attr) + + raise AttributeError(f"Energy module has no attribute {name!r}") diff --git a/kasa/interfaces/lighteffect.py b/kasa/interfaces/lighteffect.py index 9a69f2d0..fa50dd3e 100644 --- a/kasa/interfaces/lighteffect.py +++ b/kasa/interfaces/lighteffect.py @@ -13,8 +13,7 @@ Living Room Bulb Light effects are accessed via the LightPreset module. To list available presets ->>> if dev.modules[Module.Light].has_effects: ->>> light_effect = dev.modules[Module.LightEffect] +>>> light_effect = dev.modules[Module.LightEffect] >>> light_effect.effect_list ['Off', 'Party', 'Relax'] diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 5fdbf014..da55bcad 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -3,13 +3,14 @@ from __future__ import annotations from dataclasses import asdict -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Annotated, cast from ...device_type import DeviceType from ...exceptions import KasaException from ...feature import Feature -from ...interfaces.light import HSV, ColorTempRange, LightState +from ...interfaces.light import HSV, LightState from ...interfaces.light import Light as LightInterface +from ...module import FeatureAttribute from ..iotmodule import IotModule if TYPE_CHECKING: @@ -32,7 +33,7 @@ class Light(IotModule, LightInterface): super()._initialize_features() device = self._device - if self._device._is_dimmable: + if device._is_dimmable: self._add_feature( Feature( device, @@ -46,7 +47,9 @@ class Light(IotModule, LightInterface): category=Feature.Category.Primary, ) ) - if self._device._is_variable_color_temp: + if device._is_variable_color_temp: + if TYPE_CHECKING: + assert isinstance(device, IotBulb) self._add_feature( Feature( device=device, @@ -55,12 +58,12 @@ class Light(IotModule, LightInterface): container=self, attribute_getter="color_temp", attribute_setter="set_color_temp", - range_getter="valid_temperature_range", + range_getter=lambda: device._valid_temperature_range, category=Feature.Category.Primary, type=Feature.Type.Number, ) ) - if self._device._is_color: + if device._is_color: self._add_feature( Feature( device=device, @@ -90,18 +93,13 @@ class Light(IotModule, LightInterface): return None @property # type: ignore - def is_dimmable(self) -> int: - """Whether the bulb supports brightness changes.""" - return self._device._is_dimmable - - @property # type: ignore - def brightness(self) -> int: + def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" return self._device._brightness async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the brightness in percentage. A value of 0 will turn off the light. :param int brightness: brightness in percent @@ -112,28 +110,7 @@ class Light(IotModule, LightInterface): ) @property - def is_color(self) -> bool: - """Whether the light supports color changes.""" - if (bulb := self._get_bulb_device()) is None: - return False - 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 - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - if (bulb := self._get_bulb_device()) is None: - return False - return bulb._has_effects - - @property - def hsv(self) -> HSV: + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) @@ -149,7 +126,7 @@ class Light(IotModule, LightInterface): value: int | None = None, *, transition: int | None = None, - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set new HSV. Note, transition is not supported and will be ignored. @@ -164,19 +141,7 @@ class Light(IotModule, LightInterface): return await bulb._set_hsv(hue, saturation, value, transition=transition) @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - 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: + def color_temp(self) -> Annotated[int, FeatureAttribute()]: """Whether the bulb supports color temperature changes.""" if ( bulb := self._get_bulb_device() @@ -186,7 +151,7 @@ class Light(IotModule, LightInterface): async def set_color_temp( self, temp: int, *, brightness: int | None = None, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -246,13 +211,13 @@ class Light(IotModule, LightInterface): state = LightState(light_on=False) else: state = LightState(light_on=True) - if self.is_dimmable: + if self._device._is_dimmable: state.brightness = self.brightness - if self.is_color: + if self._device._is_color: hsv = self.hsv state.hue = hsv.hue state.saturation = hsv.saturation - if self.is_variable_color_temp: + if self._device._is_variable_color_temp: state.color_temp = self.color_temp self._light_state = state diff --git a/kasa/module.py b/kasa/module.py index ccd22d4e..e3f363db 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -147,6 +147,11 @@ class Module(ABC): self._module = module self._module_features: dict[str, Feature] = {} + @property + def _all_features(self) -> dict[str, Feature]: + """Get the features for this module and any sub modules.""" + return self._module_features + def has_feature(self, attribute: str | property | Callable) -> bool: """Return True if the module attribute feature is supported.""" return bool(self.get_feature(attribute)) @@ -247,7 +252,7 @@ def _get_bound_feature( ) check = {attribute_name, attribute_callable} - for feature in module._module_features.values(): + for feature in module._all_features.values(): if (getter := feature.attribute_getter) and getter in check: return feature diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index e637b607..d10139d9 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -3,11 +3,13 @@ from __future__ import annotations from dataclasses import asdict +from typing import Annotated from ...exceptions import KasaException -from ...interfaces.light import HSV, ColorTempRange, LightState +from ...feature import Feature +from ...interfaces.light import HSV, LightState from ...interfaces.light import Light as LightInterface -from ...module import Module +from ...module import FeatureAttribute, Module from ..smartmodule import SmartModule @@ -16,59 +18,45 @@ class Light(SmartModule, LightInterface): _light_state: LightState + @property + def _all_features(self) -> dict[str, Feature]: + """Get the features for this module and any sub modules.""" + ret: dict[str, Feature] = {} + if brightness := self._device.modules.get(Module.Brightness): + ret.update(**brightness._module_features) + if color := self._device.modules.get(Module.Color): + ret.update(**color._module_features) + if temp := self._device.modules.get(Module.ColorTemperature): + ret.update(**temp._module_features) + return ret + def query(self) -> dict: """Query to execute during the update cycle.""" return {} @property - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - return Module.Color in self._device.modules - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - return Module.Brightness in self._device.modules - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - return Module.ColorTemperature in self._device.modules - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if not self.is_variable_color_temp: - raise KasaException("Color temperature not supported") - - return self._device.modules[Module.ColorTemperature].valid_temperature_range - - @property - def hsv(self) -> HSV: + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: """Return the current HSV state of the bulb. :return: hue, saturation and value (degrees, %, %) """ - if not self.is_color: + if Module.Color not in self._device.modules: raise KasaException("Bulb does not support color.") return self._device.modules[Module.Color].hsv @property - def color_temp(self) -> int: + def color_temp(self) -> Annotated[int, FeatureAttribute()]: """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: + if Module.ColorTemperature not in self._device.modules: raise KasaException("Bulb does not support colortemp.") return self._device.modules[Module.ColorTemperature].color_temp @property - def brightness(self) -> int: + def brightness(self) -> Annotated[int, FeatureAttribute()]: """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover + if Module.Brightness not in self._device.modules: # pragma: no cover raise KasaException("Bulb is not dimmable.") return self._device.modules[Module.Brightness].brightness @@ -80,7 +68,7 @@ class Light(SmartModule, LightInterface): value: int | None = None, *, transition: int | None = None, - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set new HSV. Note, transition is not supported and will be ignored. @@ -90,14 +78,14 @@ class Light(SmartModule, LightInterface): :param int value: value between 1 and 100 :param int transition: transition in milliseconds. """ - if not self.is_color: + if Module.Color not in self._device.modules: raise KasaException("Bulb does not support color.") return await self._device.modules[Module.Color].set_hsv(hue, saturation, value) async def set_color_temp( self, temp: int, *, brightness: int | None = None, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the color temperature of the device in kelvin. Note, transition is not supported and will be ignored. @@ -105,7 +93,7 @@ class Light(SmartModule, LightInterface): :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - if not self.is_variable_color_temp: + if Module.ColorTemperature not in self._device.modules: raise KasaException("Bulb does not support colortemp.") return await self._device.modules[Module.ColorTemperature].set_color_temp( temp, brightness=brightness @@ -113,7 +101,7 @@ class Light(SmartModule, LightInterface): async def set_brightness( self, brightness: int, *, transition: int | None = None - ) -> dict: + ) -> Annotated[dict, FeatureAttribute()]: """Set the brightness in percentage. Note, transition is not supported and will be ignored. @@ -121,16 +109,11 @@ class Light(SmartModule, LightInterface): :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - if not self.is_dimmable: # pragma: no cover + if Module.Brightness not in self._device.modules: # pragma: no cover raise KasaException("Bulb is not dimmable.") return await self._device.modules[Module.Brightness].set_brightness(brightness) - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return Module.LightEffect in self._device.modules - async def set_state(self, state: LightState) -> dict: """Set the light state.""" state_dict = asdict(state) @@ -157,12 +140,12 @@ class Light(SmartModule, LightInterface): state = LightState(light_on=False) else: state = LightState(light_on=True) - if self.is_dimmable: + if Module.Brightness in self._device.modules: state.brightness = self.brightness - if self.is_color: + if Module.Color in self._device.modules: hsv = self.hsv state.hue = hsv.hue state.saturation = hsv.saturation - if self.is_variable_color_temp: + if Module.ColorTemperature in self._device.modules: state.color_temp = self.color_temp self._light_state = state diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 2eba7572..63805cf7 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -96,13 +96,20 @@ class LightPreset(SmartModule, LightPresetInterface): """Return current preset name.""" light = self._device.modules[SmartModule.Light] brightness = light.brightness - color_temp = light.color_temp if light.is_variable_color_temp else None - h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None) + color_temp = ( + light.color_temp if light.has_feature(light.set_color_temp) else None + ) + h, s = ( + (light.hsv.hue, light.hsv.saturation) + if light.has_feature(light.set_hsv) + else (None, None) + ) for preset_name, preset in self._presets.items(): if ( preset.brightness == brightness and ( - preset.color_temp == color_temp or not light.is_variable_color_temp + preset.color_temp == color_temp + or not light.has_feature(light.set_color_temp) ) and preset.hue == h and preset.saturation == s @@ -117,7 +124,7 @@ class LightPreset(SmartModule, LightPresetInterface): """Set a light preset for the device.""" light = self._device.modules[SmartModule.Light] if preset_name == self.PRESET_NOT_SET: - if light.is_color: + if light.has_feature(light.set_hsv): preset = LightState(hue=0, saturation=0, brightness=100) else: preset = LightState(brightness=100)