Skip to content

Commit

Permalink
Generalize settings and sensors into properties
Browse files Browse the repository at this point in the history
This removes the unnecessary division between sensors and settings in favor of just having "properties" that can have different access flags.

This will greatly simplify the API as all properties are alike, the difference is just in the access flags.

The earlier API to get settable properties (settings) and readable properties (sensors) is kept intact for the time being.

* SettingDescriptor is no more, all readable and writable properties are described using PropertyDescriptors.
    * SettingType is replaced with PropertyConstraint to allow signaling allowed ranges or choices.
    * EnumSettingDescriptor is now EnumDescriptor
    * NumberSettingDescriptor is now RangeDescriptor

* Add 'access' to Descriptor
    * This will also allow for generic `descriptors` interface in the future, if needed.

* Add 'property' to Descriptor
    * None for actions, allwos more generic interface for properties.

* Add 'properties' method to 'Device' to get all property descriptors. 'settings' and 'sensors' return a filtered dict based on the properties for backwards compat.
  • Loading branch information
rytilahti committed Mar 5, 2023
1 parent 69a5ff9 commit d01acfa
Show file tree
Hide file tree
Showing 11 changed files with 293 additions and 309 deletions.
96 changes: 50 additions & 46 deletions miio/descriptors.py
Expand Up @@ -2,37 +2,54 @@
The descriptors contain information that can be used to provide generic, dynamic user-interfaces.
If you are a downstream developer, use :func:`~miio.device.Device.sensors()`,
:func:`~miio.device.Device.settings()`, and
If you are a downstream developer, use :func:`~miio.device.Device.properties()`,
:func:`~miio.device.Device.actions()` to access the functionality exposed by the integration developer.
If you are developing an integration, prefer :func:`~miio.devicestatus.sensor`, :func:`~miio.devicestatus.setting`, and
:func:`~miio.devicestatus.action` decorators over creating the descriptors manually.
If needed, you can override the methods listed to add more descriptors to your integration.
"""
from enum import Enum, auto
from enum import Enum, Flag, auto
from typing import Any, Callable, Dict, List, Optional, Type

import attr


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

min_value: int
max_value: int
step: int = 1


class AccessFlags(Flag):
"""Defines the access rights for the property behind the descriptor."""

Read = auto()
Write = auto()
Execute = auto()

def __str__(self):
"""Return pretty printable string representation."""
s = ""
s += "r" if self & AccessFlags.Read else "-"
s += "w" if self & AccessFlags.Write else "-"
s += "x" if self & AccessFlags.Execute else "-"
s += ""
return s


@attr.s(auto_attribs=True)
class Descriptor:
"""Base class for all descriptors."""

id: str
name: str
type: Optional[type] = None
property: Optional[str] = None
extras: Dict = attr.ib(factory=dict, repr=False)
access: AccessFlags = attr.ib(default=AccessFlags.Read | AccessFlags.Write)


@attr.s(auto_attribs=True)
Expand All @@ -43,68 +60,55 @@ class ActionDescriptor(Descriptor):
method: Optional[Callable] = attr.ib(default=None, repr=False)
inputs: Optional[List[Any]] = attr.ib(default=None, repr=True)

access: AccessFlags = attr.ib(default=AccessFlags.Execute)

@attr.s(auto_attribs=True, kw_only=True)
class SensorDescriptor(Descriptor):
"""Describes a sensor exposed by the device.
This information can be used by library users to programatically
access information what types of data is available to display to users.

Prefer :meth:`@sensor <miio.devicestatus.sensor>` for constructing these.
"""
class PropertyConstraint(Enum):
"""Defines constraints for integer based properties."""

property: str
unit: Optional[str] = None
Unset = auto()
Range = auto()
Choice = auto()


class SettingType(Enum):
Undefined = auto()
Number = auto()
Boolean = auto()
Enum = auto()
@attr.s(auto_attribs=True, kw_only=True)
class PropertyDescriptor(Descriptor):
"""Describes a property exposed by the device.
This information can be used by library users to programmatically
access information what types of data is available to display to users.
@attr.s(auto_attribs=True, kw_only=True)
class SettingDescriptor(Descriptor):
"""Presents a settable value."""
Prefer :meth:`@sensor <miio.devicestatus.sensor>` or
:meth:`@setting <miio.devicestatus.setting>`for constructing these.
"""

#: The name of the property to use to access the value from a status container.
property: str
#: Sensors are read-only and settings are (usually) read-write.
access: AccessFlags = attr.ib(default=AccessFlags.Read)
unit: Optional[str] = None
setting_type = SettingType.Undefined

#: Constraint type defining the allowed values for an integer property.
constraint: PropertyConstraint = attr.ib(default=PropertyConstraint.Unset)
#: Callable to set the value of the property.
setter: Optional[Callable] = attr.ib(default=None, repr=False)
#: Name of the method in the device class that can be used to set the value.
#: This will be used to bind the setter callable.
setter_name: Optional[str] = attr.ib(default=None, repr=False)

def cast_value(self, value: int):
"""Casts value to the expected type."""
cast_map = {
SettingType.Boolean: bool,
SettingType.Enum: int,
SettingType.Number: int,
}
return cast_map[self.setting_type](int(value))


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

type: type = bool
setting_type: SettingType = SettingType.Boolean


@attr.s(auto_attribs=True, kw_only=True)
class EnumSettingDescriptor(SettingDescriptor):
class EnumDescriptor(PropertyDescriptor):
"""Presents a settable, enum-based value."""

setting_type: SettingType = SettingType.Enum
constraint: PropertyConstraint = PropertyConstraint.Choice
choices_attribute: Optional[str] = attr.ib(default=None, repr=False)
choices: Optional[Type[Enum]] = attr.ib(default=None, repr=False)


@attr.s(auto_attribs=True, kw_only=True)
class NumberSettingDescriptor(SettingDescriptor):
"""Presents a settable, numerical value.
class RangeDescriptor(PropertyDescriptor):
"""Presents a settable, numerical value constrained by min, max, and step.
If `range_attribute` is set, the named property that should return
:class:ValidSettingRange will be used to obtain {min,max}_value and step.
Expand All @@ -115,4 +119,4 @@ class NumberSettingDescriptor(SettingDescriptor):
step: int
range_attribute: Optional[str] = attr.ib(default=None)
type: type = int
setting_type: SettingType = SettingType.Number
constraint: PropertyConstraint = PropertyConstraint.Range
125 changes: 63 additions & 62 deletions miio/device.py
@@ -1,18 +1,18 @@
import logging
from enum import Enum
from inspect import getmembers
from typing import Any, Dict, List, Optional, Union, cast # noqa: F401
from typing import Any, Dict, List, Optional, Union, cast, final # noqa: F401

import click

from .click_common import DeviceGroupMeta, LiteralParamType, command, format_output
from .descriptors import (
AccessFlags,
ActionDescriptor,
EnumSettingDescriptor,
NumberSettingDescriptor,
SensorDescriptor,
SettingDescriptor,
SettingType,
EnumDescriptor,
PropertyConstraint,
PropertyDescriptor,
RangeDescriptor,
)
from .deviceinfo import DeviceInfo
from .devicestatus import DeviceStatus
Expand Down Expand Up @@ -88,8 +88,7 @@ def __init__(
self._model: Optional[str] = model
self._info: Optional[DeviceInfo] = None
self._actions: Optional[Dict[str, ActionDescriptor]] = None
self._settings: Optional[Dict[str, SettingDescriptor]] = None
self._sensors: Optional[Dict[str, SensorDescriptor]] = None
self._properties: Optional[Dict[str, PropertyDescriptor]] = None
timeout = timeout if timeout is not None else self.timeout
self._debug = debug
self._protocol = MiIOProtocol(
Expand Down Expand Up @@ -180,56 +179,44 @@ def _fetch_info(self) -> DeviceInfo:
"Unable to request miIO.info from the device"
) from ex

def _setting_descriptors_from_status(
def _set_constraints_from_attributes(
self, status: DeviceStatus
) -> Dict[str, SettingDescriptor]:
) -> Dict[str, PropertyDescriptor]:
"""Get the setting descriptors from a DeviceStatus."""
settings = status.settings()
properties = status.properties()
unsupported_settings = []
for key, setting in settings.items():
if setting.setter_name is not None:
setting.setter = getattr(self, setting.setter_name)
if setting.setter is None:
raise Exception(
f"Neither setter or setter_name was defined for {setting}"
)

if setting.setting_type == SettingType.Enum:
setting = cast(EnumSettingDescriptor, setting)
if setting.choices_attribute is not None:
retrieve_choices_function = getattr(self, setting.choices_attribute)
for key, prop in properties.items():
if prop.setter_name is not None:
prop.setter = getattr(self, prop.setter_name)
if prop.setter is None:
raise Exception(f"Neither setter or setter_name was defined for {prop}")

if prop.constraint == PropertyConstraint.Choice:
prop = cast(EnumDescriptor, prop)
if prop.choices_attribute is not None:
retrieve_choices_function = getattr(self, prop.choices_attribute)
try:
setting.choices = retrieve_choices_function()
prop.choices = retrieve_choices_function()
except UnsupportedFeatureException:
# TODO: this should not be done here
unsupported_settings.append(key)
continue

elif setting.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

elif setting.setting_type == SettingType.Boolean:
pass # just to exhaust known types
elif prop.constraint == PropertyConstraint.Range:
prop = cast(RangeDescriptor, prop)
if prop.range_attribute is not None:
range_def = getattr(self, prop.range_attribute)
prop.min_value = range_def.min_value
prop.max_value = range_def.max_value
prop.step = range_def.step

else:
raise NotImplementedError(
"Unknown setting type: %s" % setting.setting_type
)
_LOGGER.debug("Got a regular setting without constraints: %s", prop)

for unsupp_key in unsupported_settings:
settings.pop(unsupp_key)

return settings
properties.pop(unsupp_key)

def _sensor_descriptors_from_status(
self, status: DeviceStatus
) -> Dict[str, SensorDescriptor]:
"""Get the sensor descriptors from a DeviceStatus."""
return status.sensors()
return properties

def _action_descriptors(self) -> Dict[str, ActionDescriptor]:
"""Get the action descriptors from a DeviceStatus."""
Expand All @@ -238,22 +225,20 @@ def _action_descriptors(self) -> Dict[str, ActionDescriptor]:
method_name, method = action_tuple
action = method._action
action.method = method # bind the method
actions[method_name] = action
actions[action.id] = action

return actions

def _initialize_descriptors(self) -> None:
"""Cache all the descriptors once on the first call."""

status = self.status()

self._sensors = self._sensor_descriptors_from_status(status)
self._settings = self._setting_descriptors_from_status(status)
self._properties = self._set_constraints_from_attributes(status)
self._actions = self._action_descriptors()

@property
def device_id(self) -> int:
"""Return device id (did), if available."""
"""Return the device id (did)."""
if not self._protocol._device_id:
self.send_handshake()
return int.from_bytes(self._protocol._device_id, byteorder="big")
Expand Down Expand Up @@ -346,25 +331,41 @@ def actions(self) -> Dict[str, ActionDescriptor]:
"""Return device actions."""
if self._actions is None:
self._initialize_descriptors()
self._actions = cast(Dict[str, ActionDescriptor], self._actions)

return self._actions
# TODO: we ignore the return value for now as these should always be initialized
return self._actions # type: ignore[return-value]

def properties(self) -> Dict[str, PropertyDescriptor]:
"""Return all device properties."""
if self._properties is None:
self._initialize_descriptors()

# TODO: we ignore the return value for now as these should always be initialized
return self._properties # type: ignore[return-value]

def settings(self) -> Dict[str, SettingDescriptor]:
"""Return device settings."""
if self._settings is None:
@final
def settings(self) -> Dict[str, PropertyDescriptor]:
"""Return settable properties."""
if self._properties is None:
self._initialize_descriptors()
self._settings = cast(Dict[str, SettingDescriptor], self._settings)

return self._settings
return {
prop.id: prop
for prop in self.properties().values()
if prop.access & AccessFlags.Write
}

def sensors(self) -> Dict[str, SensorDescriptor]:
"""Return device sensors."""
if self._sensors is None:
@final
def sensors(self) -> Dict[str, PropertyDescriptor]:
"""Return read-only properties."""
if self._properties is None:
self._initialize_descriptors()
self._sensors = cast(Dict[str, SensorDescriptor], self._sensors)

return self._sensors
return {
prop.id: prop
for prop in self.properties().values()
if prop.access ^ AccessFlags.Write
}

def supports_miot(self) -> bool:
"""Return True if the device supports miot commands.
Expand Down

0 comments on commit d01acfa

Please sign in to comment.