diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index e2da5b690..938bc2b4d 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -6,6 +6,7 @@ from .brightness import Brightness from .childdevicemodule import ChildDeviceModule from .cloudmodule import CloudModule +from .colormodule import ColorModule from .colortemp import ColorTemperatureModule from .devicemodule import DeviceModule from .energymodule import EnergyModule @@ -36,4 +37,5 @@ "CloudModule", "LightTransitionModule", "ColorTemperatureModule", + "ColorModule", ] diff --git a/kasa/smart/modules/colormodule.py b/kasa/smart/modules/colormodule.py new file mode 100644 index 000000000..234acc742 --- /dev/null +++ b/kasa/smart/modules/colormodule.py @@ -0,0 +1,94 @@ +"""Implementation of color module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...bulb import HSV +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class ColorModule(SmartModule): + """Implementation of color module.""" + + REQUIRED_COMPONENT = "color" + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + "HSV", + container=self, + attribute_getter="hsv", + # TODO proper type for setting hsv + attribute_setter="set_hsv", + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # HSV is contained in the main device info response. + return {} + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, 1-100) + """ + h, s, v = ( + self.data.get("hue", 0), + self.data.get("saturation", 0), + self.data.get("brightness", 0), + ) + + return HSV(hue=h, saturation=s, value=v) + + def _raise_for_invalid_brightness(self, value: int): + """Raise error on invalid brightness value.""" + if not isinstance(value, int) or not (1 <= value <= 100): + raise ValueError(f"Invalid brightness value: {value} (valid range: 1-100%)") + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = 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 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) + + request_payload = { + "color_temp": 0, # If set, color_temp takes precedence over hue&sat + "hue": hue, + "saturation": saturation, + } + # The device errors on invalid brightness values. + if value is not None: + request_payload["brightness"] = value + + return await self.call("set_device_info", {**request_payload}) diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemp.py index 3fda9c8af..2ecb09ddc 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemp.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING from ...bulb import ColorTempRange @@ -12,6 +13,11 @@ from ..smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) + +DEFAULT_TEMP_RANGE = [2500, 6500] + + class ColorTemperatureModule(SmartModule): """Implementation of color temp module.""" @@ -38,7 +44,14 @@ def query(self) -> dict: @property def valid_temperature_range(self) -> ColorTempRange: """Return valid color-temp range.""" - return ColorTempRange(*self.data.get("color_temp_range")) + if (ct_range := self.data.get("color_temp_range")) is None: + _LOGGER.debug( + "Device doesn't report color temperature range, " + "falling back to default %s", + DEFAULT_TEMP_RANGE, + ) + ct_range = DEFAULT_TEMP_RANGE + return ColorTempRange(*ct_range) @property def color_temp(self): @@ -56,3 +69,7 @@ async def set_color_temp(self, temp: int): ) return await self.call("set_device_info", {"color_temp": temp}) + + async def _check_supported(self) -> bool: + """Check the color_temp_range has more than one value.""" + return self.valid_temperature_range.min != self.valid_temperature_range.max diff --git a/kasa/smart/smartbulb.py b/kasa/smart/smartbulb.py index 082035e74..0c654e1ff 100644 --- a/kasa/smart/smartbulb.py +++ b/kasa/smart/smartbulb.py @@ -2,9 +2,12 @@ from __future__ import annotations -from ..bulb import Bulb +from typing import cast + +from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..exceptions import KasaException -from ..iot.iotbulb import HSV, BulbPreset, ColorTempRange +from .modules.colormodule import ColorModule +from .modules.colortemp import ColorTemperatureModule from .smartdevice import SmartDevice AVAILABLE_EFFECTS = { @@ -22,8 +25,7 @@ class SmartBulb(SmartDevice, Bulb): @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 + return "ColorModule" in self.modules @property def is_dimmable(self) -> bool: @@ -33,9 +35,7 @@ def is_dimmable(self) -> bool: @property def is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" - ct = self._info.get("color_temp_range") - # L900 reports [9000, 9000] even when it doesn't support changing the ct - return ct is not None and ct[0] != ct[1] + return "ColorTemperatureModule" in self.modules @property def valid_temperature_range(self) -> ColorTempRange: @@ -46,8 +46,9 @@ def valid_temperature_range(self) -> ColorTempRange: if not self.is_variable_color_temp: raise KasaException("Color temperature not supported") - ct_range = self._info.get("color_temp_range", [0, 0]) - return ColorTempRange(min=ct_range[0], max=ct_range[1]) + return cast( + ColorTemperatureModule, self.modules["ColorTemperatureModule"] + ).valid_temperature_range @property def has_effects(self) -> bool: @@ -96,13 +97,7 @@ def hsv(self) -> HSV: if not self.is_color: raise KasaException("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) + return cast(ColorModule, self.modules["ColorModule"]).hsv @property def color_temp(self) -> int: @@ -110,7 +105,9 @@ def color_temp(self) -> int: if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - return self._info.get("color_temp", -1) + return cast( + ColorTemperatureModule, self.modules["ColorTemperatureModule"] + ).color_temp @property def brightness(self) -> int: @@ -134,33 +131,15 @@ async def set_hsv( :param int hue: hue in degrees :param int saturation: saturation in percentage [0,100] - :param int value: value in percentage [0, 100] + :param int value: value between 1 and 100 :param int transition: transition in milliseconds. """ if not self.is_color: raise KasaException("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) - - request_payload = { - "color_temp": 0, # If set, color_temp takes precedence over hue&sat - "hue": hue, - "saturation": saturation, - } - # The device errors on invalid brightness values. - if value is not None: - request_payload["brightness"] = value - - return await self.protocol.query({"set_device_info": {**request_payload}}) + return await cast(ColorModule, self.modules["ColorModule"]).set_hsv( + hue, saturation, value + ) async def set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None @@ -172,20 +151,11 @@ async def set_color_temp( :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: raise KasaException("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}}) + return await cast( + ColorTemperatureModule, self.modules["ColorTemperatureModule"] + ).set_color_temp(temp) def _raise_for_invalid_brightness(self, value: int): """Raise error on invalid brightness value.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index f921fda9c..32cf7cfe0 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -167,7 +167,8 @@ async def _initialize_modules(self): mod.__name__, ) module = mod(self, mod.REQUIRED_COMPONENT) - self.modules[module.name] = module + if await module._check_supported(): + self.modules[module.name] = module async def _initialize_features(self): """Initialize device features.""" diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index a0f3c1051..9169b752a 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -93,3 +93,12 @@ def data(self): def supported_version(self) -> int: """Return version supported by the device.""" return self._device._components[self.REQUIRED_COMPONENT] + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device. + + Used for parents who report components on the parent that are only available + on the child or for modules where the device has a pointless component like + color_temp_range but only supports one value. + """ + return True diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 7fe40f486..362015db9 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -237,6 +237,11 @@ def parametrize( model_filter=BULBS_IOT_VARIABLE_TEMP, protocol_filter={"IOT"}, ) +variable_temp_smart = parametrize( + "variable color temp smart", + model_filter=BULBS_SMART_VARIABLE_TEMP, + protocol_filter={"SMART"}, +) bulb_smart = parametrize( "bulb devices smart", diff --git a/kasa/tests/smart/features/test_colortemp.py b/kasa/tests/smart/features/test_colortemp.py index 8c899d6d5..e7022578d 100644 --- a/kasa/tests/smart/features/test_colortemp.py +++ b/kasa/tests/smart/features/test_colortemp.py @@ -1,12 +1,10 @@ import pytest from kasa.smart import SmartDevice -from kasa.tests.conftest import parametrize +from kasa.tests.conftest import variable_temp_smart -brightness = parametrize("colortemp smart", component_filter="color_temperature") - -@brightness +@variable_temp_smart async def test_colortemp_component(dev: SmartDevice): """Test brightness feature.""" assert isinstance(dev, SmartDevice) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index be27df1b9..9e7ab5178 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -9,6 +9,7 @@ from kasa import Bulb, BulbPreset, DeviceType, KasaException from kasa.iot import IotBulb +from kasa.smart import SmartBulb from .conftest import ( bulb, @@ -23,6 +24,7 @@ turn_on, variable_temp, variable_temp_iot, + variable_temp_smart, ) from .test_iotdevice import SYSINFO_SCHEMA @@ -159,6 +161,11 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text +@variable_temp_smart +async def test_smart_temp_range(dev: SmartBulb): + assert dev.valid_temperature_range + + @variable_temp async def test_out_of_range_temperature(dev: Bulb): with pytest.raises(ValueError):