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 .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",
|
||||||
]
|
]
|
||||||
|
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
|
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
|
||||||
|
@ -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."""
|
||||||
|
@ -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."""
|
||||||
|
@ -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
|
||||||
|
@ -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",
|
||||||
|
@ -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)
|
||||||
|
@ -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):
|
||||||
|
Loading…
Reference in New Issue
Block a user