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 .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 @@ __all__ = [
"CloudModule",
"LightTransitionModule",
"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
import logging
from typing import TYPE_CHECKING
from ...bulb import ColorTempRange
@ -12,6 +13,11 @@ if TYPE_CHECKING:
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 @@ class ColorTemperatureModule(SmartModule):
@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 @@ class ColorTemperatureModule(SmartModule):
)
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 ..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 @@ class SmartBulb(SmartDevice, Bulb):
@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 @@ class SmartBulb(SmartDevice, Bulb):
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 @@ class SmartBulb(SmartDevice, Bulb):
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 @@ class SmartBulb(SmartDevice, Bulb):
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 @@ class SmartBulb(SmartDevice, Bulb):
: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 @@ class SmartBulb(SmartDevice, Bulb):
: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."""

View File

@ -167,7 +167,8 @@ class SmartDevice(Device):
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."""

View File

@ -93,3 +93,12 @@ class SmartModule(Module):
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

View File

@ -237,6 +237,11 @@ variable_temp_iot = 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",

View File

@ -1,12 +1,10 @@
import pytest
from kasa.smart import SmartDevice
from kasa.tests.conftest import parametrize
brightness = parametrize("colortemp smart", component_filter="color_temperature")
from kasa.tests.conftest import variable_temp_smart
@brightness
@variable_temp_smart
async def test_colortemp_component(dev: SmartDevice):
"""Test brightness feature."""
assert isinstance(dev, SmartDevice)

View File

@ -9,6 +9,7 @@ from voluptuous import (
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 @@ from .conftest import (
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):