Skip to content

Commit

Permalink
Implement introspectable settings (#1500)
Browse files Browse the repository at this point in the history
  • Loading branch information
rytilahti authored Aug 15, 2022
1 parent b432c7a commit 0991f97
Show file tree
Hide file tree
Showing 8 changed files with 324 additions and 66 deletions.
74 changes: 65 additions & 9 deletions docs/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -167,20 +167,24 @@ that is passed to the method as string, and an optional integer argument.
Status containers
~~~~~~~~~~~~~~~~~

The status container (returned by `status()` method of the device class)
The status container (returned by the :meth:`~miio.device.Device.status` method of the device class)
is the main way for library users to access properties exposed by the device.
The status container should inherit :class:`~miio.devicestatus.DeviceStatus`.
This ensures a generic :meth:`__repr__` that is helpful for debugging,
and allows defining properties that are especially interesting for end users.

The properties can be decorated using special decorators to define meta information
that enables introspection and programatic creation of user interface elements.
Doing so ensures that a developer-friendly :meth:`~miio.devicestatus.DeviceStatus.__repr__` based on the defined
properties is there to help with debugging.
Furthermore, it allows defining meta information about properties that are especially interesting for end users.

.. note::

The helper decorators are just syntactic sugar to create the corresponding descriptor classes
and binding them to the status class.

.. note::

The descriptors are merely hints to downstream users about the device capabilities.
In practice this means that neither the input nor the output values of functions decorated with
the descriptors are enforced automatically by this library.


Sensors
"""""""
Expand Down Expand Up @@ -210,7 +214,7 @@ Switches
""""""""

Use :meth:`@switch <miio.devicestatus.switch>` to create :class:`~miio.descriptors.SwitchDescriptor` objects.
This will make all decorated sensors accessible through :meth:`~miio.device.Device.switches` for downstream users.
This will make all decorated switches accessible through :meth:`~miio.device.Device.switches` for downstream users.

.. code-block::
Expand All @@ -219,8 +223,60 @@ This will make all decorated sensors accessible through :meth:`~miio.device.Devi
def power(self) -> bool:
"""Return if device is turned on."""
The mandatory *setter_name* will be used to bind the method to be accessible using
the :meth:`~miio.descriptors.SwitchDescriptor.setter` callable.
You can either use *setter* to define a callable that can be used to adjust the value of the property,
or alternatively define *setter_name* which will be used to bind the method during the initialization
to the the :meth:`~miio.descriptors.SwitchDescriptor.setter` callable.


Settings
""""""""

Use :meth:`@switch <miio.devicestatus.setting>` to create :meth:`~miio.descriptors.SettingDescriptor` objects.
This will make all decorated settings accessible through :meth:`~miio.device.Device.settings` for downstream users.

The type of the descriptor depends on the input parameters:

* Passing *min_value* or *max_value* will create a :class:`~miio.descriptors.NumberSettingDescriptor`,
which is useful for presenting ranges of values.
* Passing an Enum object using *choices* will create a :class:`~miio.descriptors.EnumSettingDescriptor`,
which is useful for presenting a fixed set of options.


You can either use *setter* to define a callable that can be used to adjust the value of the property,
or alternatively define *setter_name* which will be used to bind the method during the initialization
to the the :meth:`~miio.descriptors.SettingDescriptor.setter` callable.

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``.

.. code-block::
@property
@switch(name="Fan Speed", min_value=0, max_value=100, steps=5, setter_name="set_fan_speed")
def fan_speed(self) -> int:
"""Return the current fan speed."""
Enum-based Settings
^^^^^^^^^^^^^^^^^^^

If the device has a setting with some pre-defined values, you want to use this.

.. code-block::
class LedBrightness(Enum):
Dim = 0
Bright = 1
Off = 2
@property
@switch(name="LED Brightness", choices=SomeEnum, setter_name="set_led_brightness")
def led_brightness(self) -> LedBrightness:
"""Return the LED brightness."""
.. _adding_tests:

Expand Down
64 changes: 32 additions & 32 deletions miio/cooker.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,113 +323,113 @@ def __init__(self, settings: str = None):
Bit 5-8: Unused
"""
if settings is None:
self.settings = [0, 4]
self._settings = [0, 4]
else:
self.settings = [
self._settings = [
int(settings[i : i + 2], 16) for i in range(0, len(settings), 2)
]

@property
def pressure_supported(self) -> bool:
return self.settings[0] & 1 != 0
return self._settings[0] & 1 != 0

@pressure_supported.setter
def pressure_supported(self, supported: bool):
if supported:
self.settings[0] |= 1
self._settings[0] |= 1
else:
self.settings[0] &= 254
self._settings[0] &= 254

@property
def led_on(self) -> bool:
return self.settings[0] & 2 != 0
return self._settings[0] & 2 != 0

@led_on.setter
def led_on(self, on: bool):
if on:
self.settings[0] |= 2
self._settings[0] |= 2
else:
self.settings[0] &= 253
self._settings[0] &= 253

@property
def auto_keep_warm(self) -> bool:
return self.settings[0] & 4 != 0
return self._settings[0] & 4 != 0

@auto_keep_warm.setter
def auto_keep_warm(self, keep_warm: bool):
if keep_warm:
self.settings[0] |= 4
self._settings[0] |= 4
else:
self.settings[0] &= 251
self._settings[0] &= 251

@property
def lid_open_warning(self) -> bool:
return self.settings[0] & 8 != 0
return self._settings[0] & 8 != 0

@lid_open_warning.setter
def lid_open_warning(self, alarm: bool):
if alarm:
self.settings[0] |= 8
self._settings[0] |= 8
else:
self.settings[0] &= 247
self._settings[0] &= 247

@property
def lid_open_warning_delayed(self) -> bool:
return self.settings[0] & 16 != 0
return self._settings[0] & 16 != 0

@lid_open_warning_delayed.setter
def lid_open_warning_delayed(self, alarm: bool):
if alarm:
self.settings[0] |= 16
self._settings[0] |= 16
else:
self.settings[0] &= 239
self._settings[0] &= 239

@property
def jingzhu_auto_keep_warm(self) -> bool:
return self.settings[1] & 1 != 0
return self._settings[1] & 1 != 0

@jingzhu_auto_keep_warm.setter
def jingzhu_auto_keep_warm(self, auto_keep_warm: bool):
if auto_keep_warm:
self.settings[1] |= 1
self._settings[1] |= 1
else:
self.settings[1] &= 254
self._settings[1] &= 254

@property
def kuaizhu_auto_keep_warm(self) -> bool:
return self.settings[1] & 2 != 0
return self._settings[1] & 2 != 0

@kuaizhu_auto_keep_warm.setter
def kuaizhu_auto_keep_warm(self, auto_keep_warm: bool):
if auto_keep_warm:
self.settings[1] |= 2
self._settings[1] |= 2
else:
self.settings[1] &= 253
self._settings[1] &= 253

@property
def zhuzhou_auto_keep_warm(self) -> bool:
return self.settings[1] & 4 != 0
return self._settings[1] & 4 != 0

@zhuzhou_auto_keep_warm.setter
def zhuzhou_auto_keep_warm(self, auto_keep_warm: bool):
if auto_keep_warm:
self.settings[1] |= 4
self._settings[1] |= 4
else:
self.settings[1] &= 251
self._settings[1] &= 251

@property
def favorite_auto_keep_warm(self) -> bool:
return self.settings[1] & 8 != 0
return self._settings[1] & 8 != 0

@favorite_auto_keep_warm.setter
def favorite_auto_keep_warm(self, auto_keep_warm: bool):
if auto_keep_warm:
self.settings[1] |= 8
self._settings[1] |= 8
else:
self.settings[1] &= 247
self._settings[1] &= 247

def __str__(self) -> str:
return "".join([f"{value:02x}" for value in self.settings])
return "".join([f"{value:02x}" for value in self._settings])


class CookerStatus(DeviceStatus):
Expand Down Expand Up @@ -540,7 +540,7 @@ def duration(self) -> int:
return int(self.data["t_cook"])

@property
def settings(self) -> CookerSettings:
def cooker_settings(self) -> CookerSettings:
"""Settings of the cooker."""
return CookerSettings(self.data["setting"])

Expand Down Expand Up @@ -593,7 +593,7 @@ class Cooker(Device):
"Remaining: {result.remaining}\n"
"Cooking delayed: {result.cooking_delayed}\n"
"Duration: {result.duration}\n"
"Settings: {result.settings}\n"
"Settings: {result.cooker_settings}\n"
"Interaction timeouts: {result.interaction_timeouts}\n"
"Hardware version: {result.hardware_version}\n"
"Firmware version: {result.firmware_version}\n"
Expand Down
23 changes: 15 additions & 8 deletions miio/descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@
"""
from dataclasses import dataclass
from enum import Enum, auto
from typing import Callable, Dict, List, Optional
from typing import Callable, Dict, Optional

from attrs import define


@dataclass
class ButtonDescriptor:
"""Describes a button exposed by the device."""

id: str
name: str
method: Callable
method_name: str
method: Optional[Callable] = None
extras: Optional[Dict] = None


Expand Down Expand Up @@ -44,20 +49,21 @@ class SwitchDescriptor:
id: str
name: str
property: str
setter_name: str
setter_name: Optional[str] = None
setter: Optional[Callable] = None
extras: Optional[Dict] = None


@dataclass
@define(kw_only=True)
class SettingDescriptor:
"""Presents a settable value."""

id: str
name: str
property: str
setter: Callable
unit: str
setter: Optional[Callable] = None
setter_name: Optional[str] = None


class SettingType(Enum):
Expand All @@ -66,16 +72,17 @@ class SettingType(Enum):
Enum = auto()


@dataclass
@define(kw_only=True)
class EnumSettingDescriptor(SettingDescriptor):
"""Presents a settable, enum-based value."""

choices: List
type: SettingType = SettingType.Enum
choices_attribute: Optional[str] = None
choices: Optional[Enum] = None
extras: Optional[Dict] = None


@dataclass
@define(kw_only=True)
class NumberSettingDescriptor(SettingDescriptor):
"""Presents a settable, numerical value."""

Expand Down
23 changes: 20 additions & 3 deletions miio/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,20 @@ def buttons(self) -> List[ButtonDescriptor]:
"""Return a list of button-like, clickable actions of the device."""
return []

def settings(self) -> List[SettingDescriptor]:
def settings(self) -> Dict[str, SettingDescriptor]:
"""Return list of settings."""
return []
settings = self.status().settings()
for setting in settings.values():
# TODO: Bind setter methods, this should probably done only once during init.
if setting.setter is None and setting.setter_name is not None:
setting.setter = getattr(self, setting.setter_name)
else:
# TODO: this is ugly, how to fix the issue where setter_name is optional and thus not acceptable for getattr?
raise Exception(
f"Neither setter or setter_name was defined for {setting}"
)

return settings

def sensors(self) -> Dict[str, SensorDescriptor]:
"""Return sensors."""
Expand All @@ -350,7 +361,13 @@ def switches(self) -> Dict[str, SwitchDescriptor]:
switches = self.status().switches()
for switch in switches.values():
# TODO: Bind setter methods, this should probably done only once during init.
switch.setter = getattr(self, switch.setter_name)
if switch.setter is None and switch.setter_name is not None:
switch.setter = getattr(self, switch.setter_name)
else:
# TODO: this is ugly, how to fix the issue where setter_name is optional and thus not acceptable for getattr?
raise Exception(
f"Neither setter or setter_name was defined for {switch}"
)

return switches

Expand Down
Loading

0 comments on commit 0991f97

Please sign in to comment.