Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Add ColorModule for smart devices #840

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions kasa/smart/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -36,4 +37,5 @@
"CloudModule",
"LightTransitionModule",
"ColorTemperatureModule",
"ColorModule",
]
94 changes: 94 additions & 0 deletions kasa/smart/modules/colormodule.py
Original file line number Diff line number Diff line change
@@ -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})
19 changes: 18 additions & 1 deletion kasa/smart/modules/colortemp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from ...bulb import ColorTempRange
Expand All @@ -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."""

Expand All @@ -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):
Expand All @@ -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
72 changes: 21 additions & 51 deletions kasa/smart/smartbulb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -96,21 +97,17 @@ 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:
"""Whether the bulb supports color temperature changes."""
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:
Expand All @@ -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
Expand All @@ -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."""
Expand Down
3 changes: 2 additions & 1 deletion kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
9 changes: 9 additions & 0 deletions kasa/smart/smartmodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions kasa/tests/device_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 2 additions & 4 deletions kasa/tests/smart/features/test_colortemp.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
7 changes: 7 additions & 0 deletions kasa/tests/test_bulb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,7 @@
turn_on,
variable_temp,
variable_temp_iot,
variable_temp_smart,
)
from .test_iotdevice import SYSINFO_SCHEMA

Expand Down Expand Up @@ -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):
Expand Down