Add ColorModule for smart devices (#840)

Adds support L530 hw_version 1.0
This commit is contained in:
Steven B 2024-04-20 16:18:35 +01:00 committed by GitHub
parent 4573260ac8
commit aeb2c923c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 160 additions and 57 deletions

View File

@ -6,6 +6,7 @@ from .battery import BatterySensor
from .brightness import Brightness from .brightness import Brightness
from .childdevicemodule import ChildDeviceModule from .childdevicemodule import ChildDeviceModule
from .cloudmodule import CloudModule from .cloudmodule import CloudModule
from .colormodule import ColorModule
from .colortemp import ColorTemperatureModule from .colortemp import ColorTemperatureModule
from .devicemodule import DeviceModule from .devicemodule import DeviceModule
from .energymodule import EnergyModule from .energymodule import EnergyModule
@ -36,4 +37,5 @@ __all__ = [
"CloudModule", "CloudModule",
"LightTransitionModule", "LightTransitionModule",
"ColorTemperatureModule", "ColorTemperatureModule",
"ColorModule",
] ]

View File

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

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ...bulb import ColorTempRange from ...bulb import ColorTempRange
@ -12,6 +13,11 @@ if TYPE_CHECKING:
from ..smartdevice import SmartDevice from ..smartdevice import SmartDevice
_LOGGER = logging.getLogger(__name__)
DEFAULT_TEMP_RANGE = [2500, 6500]
class ColorTemperatureModule(SmartModule): class ColorTemperatureModule(SmartModule):
"""Implementation of color temp module.""" """Implementation of color temp module."""
@ -38,7 +44,14 @@ class ColorTemperatureModule(SmartModule):
@property @property
def valid_temperature_range(self) -> ColorTempRange: def valid_temperature_range(self) -> ColorTempRange:
"""Return valid color-temp range.""" """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 @property
def color_temp(self): def color_temp(self):
@ -56,3 +69,7 @@ class ColorTemperatureModule(SmartModule):
) )
return await self.call("set_device_info", {"color_temp": temp}) 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

View File

@ -2,9 +2,12 @@
from __future__ import annotations from __future__ import annotations
from ..bulb import Bulb from typing import cast
from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
from ..exceptions import KasaException 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 from .smartdevice import SmartDevice
AVAILABLE_EFFECTS = { AVAILABLE_EFFECTS = {
@ -22,8 +25,7 @@ class SmartBulb(SmartDevice, Bulb):
@property @property
def is_color(self) -> bool: def is_color(self) -> bool:
"""Whether the bulb supports color changes.""" """Whether the bulb supports color changes."""
# TODO: this makes an assumption that only color bulbs report this return "ColorModule" in self.modules
return "hue" in self._info
@property @property
def is_dimmable(self) -> bool: def is_dimmable(self) -> bool:
@ -33,9 +35,7 @@ class SmartBulb(SmartDevice, Bulb):
@property @property
def is_variable_color_temp(self) -> bool: def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes.""" """Whether the bulb supports color temperature changes."""
ct = self._info.get("color_temp_range") return "ColorTemperatureModule" in self.modules
# L900 reports [9000, 9000] even when it doesn't support changing the ct
return ct is not None and ct[0] != ct[1]
@property @property
def valid_temperature_range(self) -> ColorTempRange: def valid_temperature_range(self) -> ColorTempRange:
@ -46,8 +46,9 @@ class SmartBulb(SmartDevice, Bulb):
if not self.is_variable_color_temp: if not self.is_variable_color_temp:
raise KasaException("Color temperature not supported") raise KasaException("Color temperature not supported")
ct_range = self._info.get("color_temp_range", [0, 0]) return cast(
return ColorTempRange(min=ct_range[0], max=ct_range[1]) ColorTemperatureModule, self.modules["ColorTemperatureModule"]
).valid_temperature_range
@property @property
def has_effects(self) -> bool: def has_effects(self) -> bool:
@ -96,13 +97,7 @@ class SmartBulb(SmartDevice, Bulb):
if not self.is_color: if not self.is_color:
raise KasaException("Bulb does not support color.") raise KasaException("Bulb does not support color.")
h, s, v = ( return cast(ColorModule, self.modules["ColorModule"]).hsv
self._info.get("hue", 0),
self._info.get("saturation", 0),
self._info.get("brightness", 0),
)
return HSV(hue=h, saturation=s, value=v)
@property @property
def color_temp(self) -> int: def color_temp(self) -> int:
@ -110,7 +105,9 @@ class SmartBulb(SmartDevice, Bulb):
if not self.is_variable_color_temp: if not self.is_variable_color_temp:
raise KasaException("Bulb does not support colortemp.") raise KasaException("Bulb does not support colortemp.")
return self._info.get("color_temp", -1) return cast(
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
).color_temp
@property @property
def brightness(self) -> int: def brightness(self) -> int:
@ -134,33 +131,15 @@ class SmartBulb(SmartDevice, Bulb):
:param int hue: hue in degrees :param int hue: hue in degrees
:param int saturation: saturation in percentage [0,100] :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. :param int transition: transition in milliseconds.
""" """
if not self.is_color: if not self.is_color:
raise KasaException("Bulb does not support color.") raise KasaException("Bulb does not support color.")
if not isinstance(hue, int) or not (0 <= hue <= 360): return await cast(ColorModule, self.modules["ColorModule"]).set_hsv(
raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") hue, saturation, value
)
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}})
async def set_color_temp( async def set_color_temp(
self, temp: int, *, brightness=None, transition: int | None = None self, temp: int, *, brightness=None, transition: int | None = None
@ -172,20 +151,11 @@ class SmartBulb(SmartDevice, Bulb):
:param int temp: The new color temperature, in Kelvin :param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds. :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: if not self.is_variable_color_temp:
raise KasaException("Bulb does not support colortemp.") raise KasaException("Bulb does not support colortemp.")
return await cast(
valid_temperature_range = self.valid_temperature_range ColorTemperatureModule, self.modules["ColorTemperatureModule"]
if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: ).set_color_temp(temp)
raise ValueError(
"Temperature should be between {} and {}, was {}".format(
*valid_temperature_range, temp
)
)
return await self.protocol.query({"set_device_info": {"color_temp": temp}})
def _raise_for_invalid_brightness(self, value: int): def _raise_for_invalid_brightness(self, value: int):
"""Raise error on invalid brightness value.""" """Raise error on invalid brightness value."""

View File

@ -167,7 +167,8 @@ class SmartDevice(Device):
mod.__name__, mod.__name__,
) )
module = mod(self, mod.REQUIRED_COMPONENT) 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): async def _initialize_features(self):
"""Initialize device features.""" """Initialize device features."""

View File

@ -93,3 +93,12 @@ class SmartModule(Module):
def supported_version(self) -> int: def supported_version(self) -> int:
"""Return version supported by the device.""" """Return version supported by the device."""
return self._device._components[self.REQUIRED_COMPONENT] 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

View File

@ -237,6 +237,11 @@ variable_temp_iot = parametrize(
model_filter=BULBS_IOT_VARIABLE_TEMP, model_filter=BULBS_IOT_VARIABLE_TEMP,
protocol_filter={"IOT"}, protocol_filter={"IOT"},
) )
variable_temp_smart = parametrize(
"variable color temp smart",
model_filter=BULBS_SMART_VARIABLE_TEMP,
protocol_filter={"SMART"},
)
bulb_smart = parametrize( bulb_smart = parametrize(
"bulb devices smart", "bulb devices smart",

View File

@ -1,12 +1,10 @@
import pytest import pytest
from kasa.smart import SmartDevice 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): async def test_colortemp_component(dev: SmartDevice):
"""Test brightness feature.""" """Test brightness feature."""
assert isinstance(dev, SmartDevice) assert isinstance(dev, SmartDevice)

View File

@ -9,6 +9,7 @@ from voluptuous import (
from kasa import Bulb, BulbPreset, DeviceType, KasaException from kasa import Bulb, BulbPreset, DeviceType, KasaException
from kasa.iot import IotBulb from kasa.iot import IotBulb
from kasa.smart import SmartBulb
from .conftest import ( from .conftest import (
bulb, bulb,
@ -23,6 +24,7 @@ from .conftest import (
turn_on, turn_on,
variable_temp, variable_temp,
variable_temp_iot, variable_temp_iot,
variable_temp_smart,
) )
from .test_iotdevice import SYSINFO_SCHEMA 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 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 @variable_temp
async def test_out_of_range_temperature(dev: Bulb): async def test_out_of_range_temperature(dev: Bulb):
with pytest.raises(ValueError): with pytest.raises(ValueError):