mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 19:23:34 +00:00
Add ColorModule for smart devices (#840)
Adds support L530 hw_version 1.0
This commit is contained in:
parent
4573260ac8
commit
aeb2c923c6
@ -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",
|
||||
]
|
||||
|
94
kasa/smart/modules/colormodule.py
Normal file
94
kasa/smart/modules/colormodule.py
Normal 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})
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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."""
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
Loading…
Reference in New Issue
Block a user