Update cli, light modules, and docs to use FeatureAttributes (#1364)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run

This commit is contained in:
Steven B. 2024-12-11 15:53:35 +00:00 committed by GitHub
parent f8a46f74cd
commit 7709bb967f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 90 additions and 69 deletions

View File

@ -40,7 +40,7 @@ Different groups of functionality are supported by modules which you can access
key from :class:`~kasa.Module`.
Modules will only be available on the device if they are supported but some individual features of a module may not be available for your device.
You can check the availability using ``is_``-prefixed properties like `is_color`.
You can check the availability using ``has_feature()`` method.
>>> from kasa import Module
>>> Module.Light in dev.modules
@ -52,9 +52,9 @@ True
>>> await dev.update()
>>> light.brightness
50
>>> light.is_color
>>> light.has_feature("hsv")
True
>>> if light.is_color:
>>> if light.has_feature("hsv"):
>>> print(light.hsv)
HSV(hue=0, saturation=100, value=50)

View File

@ -25,7 +25,9 @@ def light(dev) -> None:
@pass_dev_or_child
async def brightness(dev: Device, brightness: int, transition: int):
"""Get or set brightness."""
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable:
if not (light := dev.modules.get(Module.Light)) or not light.has_feature(
"brightness"
):
error("This device does not support brightness.")
return
@ -45,13 +47,15 @@ async def brightness(dev: Device, brightness: int, transition: int):
@pass_dev_or_child
async def temperature(dev: Device, temperature: int, transition: int):
"""Get or set color temperature."""
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp:
if not (light := dev.modules.get(Module.Light)) or not (
color_temp_feat := light.get_feature("color_temp")
):
error("Device does not support color temperature")
return
if temperature is None:
echo(f"Color temperature: {light.color_temp}")
valid_temperature_range = light.valid_temperature_range
valid_temperature_range = color_temp_feat.range
if valid_temperature_range != (0, 0):
echo("(min: {}, max: {})".format(*valid_temperature_range))
else:
@ -59,7 +63,7 @@ async def temperature(dev: Device, temperature: int, transition: int):
"Temperature range unknown, please open a github issue"
f" or a pull request for model '{dev.model}'"
)
return light.valid_temperature_range
return color_temp_feat.range
else:
echo(f"Setting color temperature to {temperature}")
return await light.set_color_temp(temperature, transition=transition)
@ -99,7 +103,7 @@ async def effect(dev: Device, ctx, effect):
@pass_dev_or_child
async def hsv(dev: Device, ctx, h, s, v, transition):
"""Get or set color in HSV."""
if not (light := dev.modules.get(Module.Light)) or not light.is_color:
if not (light := dev.modules.get(Module.Light)) or not light.has_feature("hsv"):
error("Device does not support colors")
return

View File

@ -23,13 +23,13 @@ Get the light module to interact:
>>> light = dev.modules[Module.Light]
You can use the ``is_``-prefixed properties to check for supported features:
You can use the ``has_feature()`` method to check for supported features:
>>> light.is_dimmable
>>> light.has_feature("brightness")
True
>>> light.is_color
>>> light.has_feature("hsv")
True
>>> light.is_variable_color_temp
>>> light.has_feature("color_temp")
True
All known bulbs support changing the brightness:
@ -43,8 +43,9 @@ All known bulbs support changing the brightness:
Bulbs supporting color temperature can be queried for the supported range:
>>> light.valid_temperature_range
ColorTempRange(min=2500, max=6500)
>>> if color_temp_feature := light.get_feature("color_temp"):
>>> print(f"{color_temp_feature.minimum_value}, {color_temp_feature.maximum_value}")
2500, 6500
>>> await light.set_color_temp(3000)
>>> await dev.update()
>>> light.color_temp

View File

@ -13,7 +13,6 @@ Living Room Bulb
Light effects are accessed via the LightPreset module. To list available presets
>>> if dev.modules[Module.Light].has_effects:
>>> light_effect = dev.modules[Module.LightEffect]
>>> light_effect.effect_list
['Off', 'Party', 'Relax']

View File

@ -3,13 +3,14 @@
from __future__ import annotations
from dataclasses import asdict
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Annotated, cast
from ...device_type import DeviceType
from ...exceptions import KasaException
from ...feature import Feature
from ...interfaces.light import HSV, ColorTempRange, LightState
from ...interfaces.light import Light as LightInterface
from ...module import FeatureAttribute
from ..iotmodule import IotModule
if TYPE_CHECKING:
@ -32,7 +33,7 @@ class Light(IotModule, LightInterface):
super()._initialize_features()
device = self._device
if self._device._is_dimmable:
if device._is_dimmable:
self._add_feature(
Feature(
device,
@ -46,7 +47,7 @@ class Light(IotModule, LightInterface):
category=Feature.Category.Primary,
)
)
if self._device._is_variable_color_temp:
if device._is_variable_color_temp:
self._add_feature(
Feature(
device=device,
@ -60,7 +61,7 @@ class Light(IotModule, LightInterface):
type=Feature.Type.Number,
)
)
if self._device._is_color:
if device._is_color:
self._add_feature(
Feature(
device=device,
@ -95,13 +96,13 @@ class Light(IotModule, LightInterface):
return self._device._is_dimmable
@property # type: ignore
def brightness(self) -> int:
def brightness(self) -> Annotated[int, FeatureAttribute()]:
"""Return the current brightness in percentage."""
return self._device._brightness
async def set_brightness(
self, brightness: int, *, transition: int | None = None
) -> dict:
) -> Annotated[dict, FeatureAttribute()]:
"""Set the brightness in percentage. A value of 0 will turn off the light.
:param int brightness: brightness in percent
@ -133,7 +134,7 @@ class Light(IotModule, LightInterface):
return bulb._has_effects
@property
def hsv(self) -> HSV:
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
"""Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %)
@ -149,7 +150,7 @@ class Light(IotModule, LightInterface):
value: int | None = None,
*,
transition: int | None = None,
) -> dict:
) -> Annotated[dict, FeatureAttribute()]:
"""Set new HSV.
Note, transition is not supported and will be ignored.
@ -176,7 +177,7 @@ class Light(IotModule, LightInterface):
return bulb._valid_temperature_range
@property
def color_temp(self) -> int:
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
"""Whether the bulb supports color temperature changes."""
if (
bulb := self._get_bulb_device()
@ -186,7 +187,7 @@ class Light(IotModule, LightInterface):
async def set_color_temp(
self, temp: int, *, brightness: int | None = None, transition: int | None = None
) -> dict:
) -> Annotated[dict, FeatureAttribute()]:
"""Set the color temperature of the device in kelvin.
Note, transition is not supported and will be ignored.
@ -242,17 +243,18 @@ class Light(IotModule, LightInterface):
return self._light_state
async def _post_update_hook(self) -> None:
if self._device.is_on is False:
device = self._device
if device.is_on is False:
state = LightState(light_on=False)
else:
state = LightState(light_on=True)
if self.is_dimmable:
if device._is_dimmable:
state.brightness = self.brightness
if self.is_color:
if device._is_color:
hsv = self.hsv
state.hue = hsv.hue
state.saturation = hsv.saturation
if self.is_variable_color_temp:
if device._is_variable_color_temp:
state.color_temp = self.color_temp
self._light_state = state

View File

@ -85,17 +85,19 @@ class LightPreset(IotModule, LightPresetInterface):
def preset(self) -> str:
"""Return current preset name."""
light = self._device.modules[Module.Light]
is_color = light.has_feature("hsv")
is_variable_color_temp = light.has_feature("color_temp")
brightness = light.brightness
color_temp = light.color_temp if light.is_variable_color_temp else None
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
color_temp = light.color_temp if is_variable_color_temp else None
h, s = (light.hsv.hue, light.hsv.saturation) if is_color else (None, None)
for preset_name, preset in self._presets.items():
if (
preset.brightness == brightness
and (
preset.color_temp == color_temp or not light.is_variable_color_temp
)
and (preset.hue == h or not light.is_color)
and (preset.saturation == s or not light.is_color)
and (preset.color_temp == color_temp or not is_variable_color_temp)
and (preset.hue == h or not is_color)
and (preset.saturation == s or not is_color)
):
return preset_name
return self.PRESET_NOT_SET
@ -107,7 +109,7 @@ class LightPreset(IotModule, LightPresetInterface):
"""Set a light preset for the device."""
light = self._device.modules[Module.Light]
if preset_name == self.PRESET_NOT_SET:
if light.is_color:
if light.has_feature("hsv"):
preset = LightState(hue=0, saturation=0, brightness=100)
else:
preset = LightState(brightness=100)

View File

@ -55,7 +55,7 @@ class Light(SmartModule, LightInterface):
:return: White temperature range in Kelvin (minimum, maximum)
"""
if not self.is_variable_color_temp:
if Module.ColorTemperature not in self._device.modules:
raise KasaException("Color temperature not supported")
return self._device.modules[Module.ColorTemperature].valid_temperature_range
@ -66,7 +66,7 @@ class Light(SmartModule, LightInterface):
:return: hue, saturation and value (degrees, %, %)
"""
if not self.is_color:
if Module.Color not in self._device.modules:
raise KasaException("Bulb does not support color.")
return self._device.modules[Module.Color].hsv
@ -74,7 +74,7 @@ class Light(SmartModule, LightInterface):
@property
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
"""Whether the bulb supports color temperature changes."""
if not self.is_variable_color_temp:
if Module.ColorTemperature not in self._device.modules:
raise KasaException("Bulb does not support colortemp.")
return self._device.modules[Module.ColorTemperature].color_temp
@ -82,7 +82,7 @@ class Light(SmartModule, LightInterface):
@property
def brightness(self) -> Annotated[int, FeatureAttribute()]:
"""Return the current brightness in percentage."""
if not self.is_dimmable: # pragma: no cover
if Module.Brightness not in self._device.modules:
raise KasaException("Bulb is not dimmable.")
return self._device.modules[Module.Brightness].brightness
@ -104,7 +104,7 @@ class Light(SmartModule, LightInterface):
:param int value: value between 1 and 100
:param int transition: transition in milliseconds.
"""
if not self.is_color:
if Module.Color not in self._device.modules:
raise KasaException("Bulb does not support color.")
return await self._device.modules[Module.Color].set_hsv(hue, saturation, value)
@ -119,7 +119,7 @@ class Light(SmartModule, LightInterface):
:param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds.
"""
if not self.is_variable_color_temp:
if Module.ColorTemperature not in self._device.modules:
raise KasaException("Bulb does not support colortemp.")
return await self._device.modules[Module.ColorTemperature].set_color_temp(
temp, brightness=brightness
@ -135,7 +135,7 @@ class Light(SmartModule, LightInterface):
:param int brightness: brightness in percent
:param int transition: transition in milliseconds.
"""
if not self.is_dimmable: # pragma: no cover
if Module.Brightness not in self._device.modules:
raise KasaException("Bulb is not dimmable.")
return await self._device.modules[Module.Brightness].set_brightness(brightness)
@ -167,16 +167,17 @@ class Light(SmartModule, LightInterface):
return self._light_state
async def _post_update_hook(self) -> None:
if self._device.is_on is False:
device = self._device
if device.is_on is False:
state = LightState(light_on=False)
else:
state = LightState(light_on=True)
if self.is_dimmable:
if Module.Brightness in device.modules:
state.brightness = self.brightness
if self.is_color:
if Module.Color in device.modules:
hsv = self.hsv
state.hue = hsv.hue
state.saturation = hsv.saturation
if self.is_variable_color_temp:
if Module.ColorTemperature in device.modules:
state.color_temp = self.color_temp
self._light_state = state

View File

@ -96,13 +96,18 @@ class LightPreset(SmartModule, LightPresetInterface):
"""Return current preset name."""
light = self._device.modules[SmartModule.Light]
brightness = light.brightness
color_temp = light.color_temp if light.is_variable_color_temp else None
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
color_temp = light.color_temp if light.has_feature("color_temp") else None
h, s = (
(light.hsv.hue, light.hsv.saturation)
if light.has_feature("hsv")
else (None, None)
)
for preset_name, preset in self._presets.items():
if (
preset.brightness == brightness
and (
preset.color_temp == color_temp or not light.is_variable_color_temp
preset.color_temp == color_temp
or not light.has_feature("color_temp")
)
and preset.hue == h
and preset.saturation == s
@ -117,7 +122,7 @@ class LightPreset(SmartModule, LightPresetInterface):
"""Set a light preset for the device."""
light = self._device.modules[SmartModule.Light]
if preset_name == self.PRESET_NOT_SET:
if light.is_color:
if light.has_feature("hsv"):
preset = LightState(hue=0, saturation=0, brightness=100)
else:
preset = LightState(brightness=100)

View File

@ -91,7 +91,9 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog):
monkeypatch.setitem(dev._sys_info, "model", "unknown bulb")
light = dev.modules.get(Module.Light)
assert light
assert light.valid_temperature_range == (2700, 5000)
color_temp_feat = light.get_feature("color_temp")
assert color_temp_feat
assert color_temp_feat.range == (2700, 5000)
assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text

View File

@ -469,7 +469,9 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt
async def test_smart_temp_range(dev: Device):
light = dev.modules.get(Module.Light)
assert light
assert light.valid_temperature_range
color_temp_feat = light.get_feature("color_temp")
assert color_temp_feat
assert color_temp_feat.range
@device_smart

View File

@ -25,7 +25,7 @@ async def test_hsv(dev: Device, turn_on):
light = dev.modules.get(Module.Light)
assert light
await handle_turn_on(dev, turn_on)
assert light.is_color
assert light.has_feature("hsv")
hue, saturation, brightness = light.hsv
assert 0 <= hue <= 360
@ -106,7 +106,7 @@ async def test_invalid_hsv(
light = dev.modules.get(Module.Light)
assert light
await handle_turn_on(dev, turn_on)
assert light.is_color
assert light.has_feature("hsv")
with pytest.raises(exception_cls, match=error):
await light.set_hsv(hue, sat, brightness)
@ -124,7 +124,7 @@ async def test_color_state_information(dev: Device):
async def test_hsv_on_non_color(dev: Device):
light = dev.modules.get(Module.Light)
assert light
assert not light.is_color
assert not light.has_feature("hsv")
with pytest.raises(KasaException):
await light.set_hsv(0, 0, 0)
@ -173,9 +173,6 @@ async def test_non_variable_temp(dev: Device):
with pytest.raises(KasaException):
await light.set_color_temp(2700)
with pytest.raises(KasaException):
print(light.valid_temperature_range)
with pytest.raises(KasaException):
print(light.color_temp)

View File

@ -12,6 +12,7 @@ from pytest_mock import MockerFixture
from kasa import (
AuthenticationError,
ColorTempRange,
Credentials,
Device,
DeviceError,
@ -523,7 +524,9 @@ async def test_emeter(dev: Device, mocker, runner):
async def test_brightness(dev: Device, runner):
res = await runner.invoke(brightness, obj=dev)
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable:
if not (light := dev.modules.get(Module.Light)) or not light.has_feature(
"brightness"
):
assert "This device does not support brightness." in res.output
return
@ -540,13 +543,16 @@ async def test_brightness(dev: Device, runner):
async def test_color_temperature(dev: Device, runner):
res = await runner.invoke(temperature, obj=dev)
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp:
if not (light := dev.modules.get(Module.Light)) or not (
color_temp_feat := light.get_feature("color_temp")
):
assert "Device does not support color temperature" in res.output
return
res = await runner.invoke(temperature, obj=dev)
assert f"Color temperature: {light.color_temp}" in res.output
valid_range = light.valid_temperature_range
valid_range = color_temp_feat.range
assert isinstance(valid_range, ColorTempRange)
assert f"(min: {valid_range.min}, max: {valid_range.max})" in res.output
val = int((valid_range.min + valid_range.max) / 2)
@ -572,7 +578,7 @@ async def test_color_temperature(dev: Device, runner):
async def test_color_hsv(dev: Device, runner: CliRunner):
res = await runner.invoke(hsv, obj=dev)
if not (light := dev.modules.get(Module.Light)) or not light.is_color:
if not (light := dev.modules.get(Module.Light)) or not light.has_feature("hsv"):
assert "Device does not support colors" in res.output
return

View File

@ -198,7 +198,7 @@ async def test_light_color_temp(dev: Device):
light = next(get_parent_and_child_modules(dev, Module.Light))
assert light
if not light.is_variable_color_temp:
if not light.has_feature("color_temp"):
pytest.skip(
"Some smart light strips have color_temperature"
" component but min and max are the same"

View File

@ -280,19 +280,19 @@ async def test_deprecated_light_attributes(dev: Device):
await _test_attribute(dev, "is_color", bool(light), "Light")
await _test_attribute(dev, "is_variable_color_temp", bool(light), "Light")
exc = KasaException if light and not light.is_dimmable else None
exc = KasaException if light and not light.has_feature("brightness") else None
await _test_attribute(dev, "brightness", bool(light), "Light", will_raise=exc)
await _test_attribute(
dev, "set_brightness", bool(light), "Light", 50, will_raise=exc
)
exc = KasaException if light and not light.is_color else None
exc = KasaException if light and not light.has_feature("hsv") else None
await _test_attribute(dev, "hsv", bool(light), "Light", will_raise=exc)
await _test_attribute(
dev, "set_hsv", bool(light), "Light", 50, 50, 50, will_raise=exc
)
exc = KasaException if light and not light.is_variable_color_temp else None
exc = KasaException if light and not light.has_feature("color_temp") else None
await _test_attribute(dev, "color_temp", bool(light), "Light", will_raise=exc)
await _test_attribute(
dev, "set_color_temp", bool(light), "Light", 2700, will_raise=exc