Skip to content
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

Add range_attribute parameter to NumberSettingDescriptor #1602

Merged
merged 4 commits into from
Nov 23, 2022
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
20 changes: 18 additions & 2 deletions docs/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -316,12 +316,28 @@ Numerical Settings
^^^^^^^^^^^^^^^^^^

The number descriptor allows defining a range of values and information about the steps.
The *max_value* is the only mandatory parameter. If not given, *min_value* defaults to ``0`` and *steps* to ``1``.
*range_attribute* can be used to define an attribute that is used to read the definitions,
which is useful when the values depend on a device model.

.. code-block::

class ExampleStatus(DeviceStatus):

@property
@setting(name="Color temperature", range_attribute="color_temperature_range")
def colortemp(): ...

class ExampleDevice(Device):
def color_temperature_range() -> ValidSettingRange:
return ValidSettingRange(0, 100, 5)

Alternatively, *min_value*, *max_value*, and *step* can be used.
The *max_value* is the only mandatory parameter. If not given, *min_value* defaults to ``0`` and *step* to ``1``.

.. code-block::

@property
@setting(name="Fan Speed", min_value=0, max_value=100, steps=5, setter_name="set_fan_speed")
@setting(name="Fan Speed", min_value=0, max_value=100, step=5, setter_name="set_fan_speed")
def fan_speed(self) -> int:
"""Return the current fan speed."""

Expand Down
16 changes: 15 additions & 1 deletion miio/descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@
import attr


@attr.s(auto_attribs=True)
class ValidSettingRange:
"""Describes a valid input range for a setting."""

min_value: int
max_value: int
step: int = 1


@attr.s(auto_attribs=True)
class ActionDescriptor:
"""Describes a button exposed by the device."""
Expand Down Expand Up @@ -93,9 +102,14 @@ class EnumSettingDescriptor(SettingDescriptor):

@attr.s(auto_attribs=True, kw_only=True)
class NumberSettingDescriptor(SettingDescriptor):
"""Presents a settable, numerical value."""
"""Presents a settable, numerical value.

If `range_attribute` is set, the named property that should return
:class:ValidSettingRange will be used to obtain {min,max}_value and step.
"""

min_value: int
max_value: int
step: int
range_attribute: Optional[str] = attr.ib(default=None)
type: SettingType = SettingType.Number
11 changes: 10 additions & 1 deletion miio/device.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import logging
from enum import Enum
from inspect import getmembers
from typing import Any, Dict, List, Optional, Union # noqa: F401
from typing import Any, Dict, List, Optional, Union, cast # noqa: F401

import click

from .click_common import DeviceGroupMeta, LiteralParamType, command, format_output
from .descriptors import (
ActionDescriptor,
EnumSettingDescriptor,
NumberSettingDescriptor,
SensorDescriptor,
SettingDescriptor,
SettingType,
)
from .deviceinfo import DeviceInfo
from .devicestatus import DeviceStatus
Expand Down Expand Up @@ -279,6 +281,13 @@ def settings(self) -> Dict[str, SettingDescriptor]:
):
retrieve_choices_function = getattr(self, setting.choices_attribute)
setting.choices = retrieve_choices_function() # This can do IO
if setting.type == SettingType.Number:
setting = cast(NumberSettingDescriptor, setting)
if setting.range_attribute is not None:
range_def = getattr(self, setting.range_attribute)
setting.min_value = range_def.min_value
setting.max_value = range_def.max_value
setting.step = range_def.step

return settings

Expand Down
6 changes: 5 additions & 1 deletion miio/devicestatus.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ def setting(
min_value: Optional[int] = None,
max_value: Optional[int] = None,
step: Optional[int] = None,
range_attribute: Optional[str] = None,
choices: Optional[Type[Enum]] = None,
choices_attribute: Optional[str] = None,
type: Optional[SettingType] = None,
Expand All @@ -192,6 +193,8 @@ def setting(
The interface is kept minimal, but you can pass any extra keyword arguments.
These extras are made accessible over :attr:`~miio.descriptors.SettingDescriptor.extras`,
and can be interpreted downstream users as they wish.

The `_attribute` suffixed options allow defining a property to be used to return the information dynamically.
"""

def decorator_setting(func):
Expand All @@ -215,12 +218,13 @@ def decorator_setting(func):
"extras": kwargs,
}

if min_value or max_value:
if min_value or max_value or range_attribute:
descriptor = NumberSettingDescriptor(
**common_values,
min_value=min_value or 0,
max_value=max_value,
step=step or 1,
range_attribute=range_attribute,
)
elif choices or choices_attribute:
descriptor = EnumSettingDescriptor(
Expand Down
101 changes: 56 additions & 45 deletions miio/integrations/light/yeelight/yeelight.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

import click

from miio import ColorTemperatureRange, LightInterface
from miio import LightInterface
from miio.click_common import command, format_output
from miio.descriptors import ValidSettingRange
from miio.device import Device, DeviceStatus
from miio.devicestatus import sensor, setting
from miio.utils import int_to_rgb, rgb_to_int
Expand All @@ -25,6 +26,37 @@
}


def cli_format_yeelight(result) -> str:
"""Return human readable sub lights string."""
s = f"Name: {result.name}\n"
s += f"Update default on change: {result.save_state_on_change}\n"
s += f"Delay in minute before off: {result.delay_off}\n"
if result.music_mode is not None:
s += f"Music mode: {result.music_mode}\n"
if result.developer_mode is not None:
s += f"Developer mode: {result.developer_mode}\n"
for light in result.lights:
s += f"{light.type.name} light\n"
s += f" Power: {light.is_on}\n"
s += f" Brightness: {light.brightness}\n"
s += f" Color mode: {light.color_mode}\n"
if light.color_mode == YeelightMode.RGB:
s += f" RGB: {light.rgb}\n"
elif light.color_mode == YeelightMode.HSV:
s += f" HSV: {light.hsv}\n"
else:
s += f" Temperature: {light.color_temp}\n"
s += f" Color flowing mode: {light.color_flowing}\n"
if light.color_flowing:
s += f" Color flowing parameters: {light.color_flow_params}\n"
if result.moonlight_mode is not None:
s += "Moonlight\n"
s += f" Is in mode: {result.moonlight_mode}\n"
s += f" Moonlight mode brightness: {result.moonlight_mode_brightness}\n"
s += "\n"
return s


class YeelightMode(IntEnum):
RGB = 1
ColorTemperature = 2
Expand Down Expand Up @@ -113,13 +145,19 @@ def __init__(self, data):
self.data = data

@property
@setting("Power", setter_name="set_power")
@setting("Power", setter_name="set_power", id="light:on")
def is_on(self) -> bool:
"""Return whether the light is on or off."""
return self.lights[0].is_on

@property
@setting("Brightness", unit="%", setter_name="set_brightness", max_value=100)
@setting(
"Brightness",
unit="%",
setter_name="set_brightness",
max_value=100,
id="light:brightness",
)
def brightness(self) -> int:
"""Return current brightness."""
return self.lights[0].brightness
Expand Down Expand Up @@ -147,9 +185,13 @@ def hsv(self) -> Optional[Tuple[int, int, int]]:
return self.lights[0].hsv

@property
@sensor(
"Color temperature", setter_name="set_color_temperature"
) # TODO: we need to allow ranges by attribute to fix this
@setting(
"Color temperature",
setter_name="set_color_temperature",
range_attribute="color_temperature_range",
id="light:color-temp",
unit="kelvin",
)
def color_temp(self) -> Optional[int]:
"""Return current color temperature, if applicable."""
return self.lights[0].color_temp
Expand Down Expand Up @@ -233,37 +275,6 @@ def lights(self) -> List[YeelightSubLight]:
)
return sub_lights

@property
def cli_format(self) -> str:
"""Return human readable sub lights string."""
s = f"Name: {self.name}\n"
s += f"Update default on change: {self.save_state_on_change}\n"
s += f"Delay in minute before off: {self.delay_off}\n"
if self.music_mode is not None:
s += f"Music mode: {self.music_mode}\n"
if self.developer_mode is not None:
s += f"Developer mode: {self.developer_mode}\n"
for light in self.lights:
s += f"{light.type.name} light\n"
s += f" Power: {light.is_on}\n"
s += f" Brightness: {light.brightness}\n"
s += f" Color mode: {light.color_mode}\n"
if light.color_mode == YeelightMode.RGB:
s += f" RGB: {light.rgb}\n"
elif light.color_mode == YeelightMode.HSV:
s += f" HSV: {light.hsv}\n"
else:
s += f" Temperature: {light.color_temp}\n"
s += f" Color flowing mode: {light.color_flowing}\n"
if light.color_flowing:
s += f" Color flowing parameters: {light.color_flow_params}\n"
if self.moonlight_mode is not None:
s += "Moonlight\n"
s += f" Is in mode: {self.moonlight_mode}\n"
s += f" Moonlight mode brightness: {self.moonlight_mode_brightness}\n"
s += "\n"
return s


class Yeelight(Device, LightInterface):
"""A rudimentary support for Yeelight bulbs.
Expand Down Expand Up @@ -294,9 +305,8 @@ def __init__(
self._model_info = Yeelight._spec_helper.get_model_info(self.model)
self._light_type = YeelightSubLightType.Main
self._light_info = self._model_info.lamps[self._light_type]
self._color_temp_range = self._light_info.color_temp

@command(default_output=format_output("", "{result.cli_format}"))
@command(default_output=format_output("", result_msg_fmt=cli_format_yeelight))
def status(self) -> YeelightStatus:
"""Retrieve properties."""
properties = [
Expand Down Expand Up @@ -336,15 +346,16 @@ def status(self) -> YeelightStatus:
return YeelightStatus(dict(zip(properties, values)))

@property
def valid_temperature_range(self) -> ColorTemperatureRange:
def valid_temperature_range(self) -> ValidSettingRange:
"""Return supported color temperature range."""
_LOGGER.warning("Deprecated, use color_temperature_range instead")
return self._color_temp_range
return self.color_temperature_range

@property
def color_temperature_range(self) -> Optional[ColorTemperatureRange]:
def color_temperature_range(self) -> ValidSettingRange:
"""Return supported color temperature range."""
return self._color_temp_range
temps = self._light_info.color_temp
return ValidSettingRange(min_value=temps[0], max_value=temps[1])

@command(
click.option("--transition", type=int, required=False, default=0),
Expand Down Expand Up @@ -415,8 +426,8 @@ def set_color_temp(self, level, transition=500):
def set_color_temperature(self, level, transition=500):
"""Set color temp in kelvin."""
if (
level > self.valid_temperature_range.max
or level < self.valid_temperature_range.min
level > self.color_temperature_range.max_value
or level < self.color_temperature_range.min_value
):
raise ValueError("Invalid color temperature: %s" % level)
if transition > 0:
Expand Down
4 changes: 3 additions & 1 deletion miio/interfaces/lightinterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from abc import abstractmethod
from typing import NamedTuple, Optional, Tuple

from miio.descriptors import ValidSettingRange


class ColorTemperatureRange(NamedTuple):
"""Color temperature range."""
Expand All @@ -22,7 +24,7 @@ def set_brightness(self, level: int, **kwargs):
"""Set the light brightness [0,100]."""

@property
def color_temperature_range(self) -> Optional[ColorTemperatureRange]:
def color_temperature_range(self) -> Optional[ValidSettingRange]:
"""Return the color temperature range, if supported."""
return None

Expand Down
52 changes: 51 additions & 1 deletion miio/tests/test_devicestatus.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import pytest

from miio import Device, DeviceStatus
from miio.descriptors import EnumSettingDescriptor, NumberSettingDescriptor
from miio.descriptors import (
EnumSettingDescriptor,
NumberSettingDescriptor,
ValidSettingRange,
)
from miio.devicestatus import sensor, setting


Expand Down Expand Up @@ -142,6 +146,52 @@ def level(self) -> int:
setter.assert_called_with(1)


def test_setting_decorator_number_range_attribute(mocker):
"""Tests for setting decorator with range_attribute.

This makes sure the range_attribute overrides {min,max}_value and step.
"""

class Settings(DeviceStatus):
@property
@setting(
name="Level",
unit="something",
setter_name="set_level",
min_value=0,
max_value=2,
step=1,
range_attribute="valid_range",
)
def level(self) -> int:
return 1

mocker.patch("miio.Device.send")
d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff")

# Patch status to return our class
mocker.patch.object(d, "status", return_value=Settings())
mocker.patch.object(d, "valid_range", create=True, new=ValidSettingRange(1, 100, 2))
# Patch to create a new setter as defined in the status class
setter = mocker.patch.object(d, "set_level", create=True)

settings = d.settings()
assert len(settings) == 1

desc = settings["level"]
assert isinstance(desc, NumberSettingDescriptor)

assert getattr(d.status(), desc.property) == 1

assert desc.name == "Level"
assert desc.min_value == 1
assert desc.max_value == 100
assert desc.step == 2

settings["level"].setter(50)
setter.assert_called_with(50)


def test_setting_decorator_enum(mocker):
"""Tests for setting decorator with enums."""

Expand Down