Skip to content

Commit

Permalink
Implement introspectable sensors (#1488)
Browse files Browse the repository at this point in the history
  • Loading branch information
rytilahti authored Aug 13, 2022
1 parent 015fe47 commit d872d55
Show file tree
Hide file tree
Showing 8 changed files with 293 additions and 42 deletions.
43 changes: 33 additions & 10 deletions docs/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,17 @@ downloading the description files and parsing them into more understandable form
Development checklist
---------------------

1. All device classes are derived from either :class:`miio.device.Device` (for MiIO)
or :class:`miio.miot_device.MiotDevice` (for MiOT) (:ref:`minimal_example`).
2. All commands and their arguments should be decorated with `@command` decorator,
1. All device classes are derived from either :class:`~miio.device.Device` (for MiIO)
or :class:`~miio.miot_device.MiotDevice` (for MiOT) (:ref:`minimal_example`).
2. All commands and their arguments should be decorated with :meth:`@command <miio.click_common.command>` decorator,
which will make them accessible to `miiocli` (:ref:`miiocli`).
3. All implementations must either include a model-keyed ``_mappings`` list (for MiOT),
or define ``Device._supported_models`` variable in the class (for MiIO).
listing the known models (as reported by `info()`).
4. Status containers is derived from `DeviceStatus` class and all properties should
have type annotations for their return values.
5. Creating tests (:ref:`adding_tests`).
3. All implementations must either include a model-keyed :obj:`~miio.device.Device._mappings` list (for MiOT),
or define :obj:`~miio.device.Device._supported_models` variable in the class (for MiIO).
listing the known models (as reported by :meth:`~miio.device.Device.info()`).
4. Status containers is derived from :class:`~miio.devicestatus.DeviceStatus` class and all properties should
have type annotations for their return values. The information that can be displayed
directly to users should be decorated using `@sensor` to make them discoverable (:ref:`status_containers`).
5. Add tests at least for the status container handling (:ref:`adding_tests`).
6. Updating documentation is generally not needed as the API documentation
will be generated automatically.

Expand Down Expand Up @@ -160,14 +161,36 @@ Produces a command ``miiocli example`` command requiring an argument
that is passed to the method as string, and an optional integer argument.


.. _status_containers:

Status containers
~~~~~~~~~~~~~~~~~

The status container (returned by `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.device.DeviceStatus` to ensure a generic :meth:`__repr__`.
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 with :meth:`@sensor <miio.devicestatus.sensor>` decorator to
define meta information that enables introspection and programatic creation of user interface elements.
This will create :class:`~miio.descriptors.SensorDescriptor` objects that are accessible
using :meth:`~miio.device.Device.sensors`.

.. code-block:: python
@property
@sensor(name="Voltage", unit="V")
def voltage(self) -> Optional[float]:
"""Return the voltage, if available."""
pass
Note, that all keywords not defined in the descriptor class will be contained
inside :attr:`~miio.descriptors.SensorDescriptor.extras` variable.
This information can be used to pass information to the downstream users,
see the source of :class:`miio.powerstrip.PowerStripStatus` for example of how to pass
device class information to Home Assistant.

.. _adding_tests:

Expand Down
3 changes: 2 additions & 1 deletion miio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

# isort: off

from miio.device import Device, DeviceStatus
from miio.device import Device
from miio.devicestatus import DeviceStatus
from miio.exceptions import DeviceError, DeviceException
from miio.miot_device import MiotDevice
from miio.deviceinfo import DeviceInfo
Expand Down
84 changes: 84 additions & 0 deletions miio/descriptors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""This module contains integration descriptors.
These can be used to make specifically interesting pieces of functionality
visible to downstream users.
TBD: Some descriptors are created automatically based on the status container classes,
but developers can override :func:buttons(), :func:sensors(), .. to expose more features.
"""
from dataclasses import dataclass
from enum import Enum, auto
from typing import Callable, Dict, List, Optional


@dataclass
class ButtonDescriptor:
id: str
name: str
method: Callable
extras: Optional[Dict] = None


@dataclass
class SensorDescriptor:
"""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.
"""

id: str
type: str
name: str
property: str
unit: Optional[str] = None
extras: Optional[Dict] = None


@dataclass
class SwitchDescriptor:
"""Presents toggleable switch."""

id: str
name: str
property: str
setter: Callable


@dataclass
class SettingDescriptor:
"""Presents a settable value."""

id: str
name: str
property: str
setter: Callable
unit: str


class SettingType(Enum):
Number = auto()
Boolean = auto()
Enum = auto()


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

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


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

min_value: int
max_value: int
step: int
type: SettingType = SettingType.Number
extras: Optional[Dict] = None
58 changes: 30 additions & 28 deletions miio/device.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import inspect
import logging
import warnings
from enum import Enum
from pprint import pformat as pf
from typing import Any, Dict, List, Optional # noqa: F401
from typing import Any, Dict, List, Optional, Union # noqa: F401

import click

from .click_common import DeviceGroupMeta, LiteralParamType, command, format_output
from .descriptors import (
ButtonDescriptor,
SensorDescriptor,
SettingDescriptor,
SwitchDescriptor,
)
from .deviceinfo import DeviceInfo
from .devicestatus import DeviceStatus
from .exceptions import DeviceInfoUnavailableException, PayloadDecodeException
from .miioprotocol import MiIOProtocol

Expand All @@ -22,31 +27,6 @@ class UpdateState(Enum):
Idle = "idle"


class DeviceStatus:
"""Base class for status containers.
All status container classes should inherit from this class. The __repr__
implementation returns all defined properties and their values.
"""

def __repr__(self):
props = inspect.getmembers(self.__class__, lambda o: isinstance(o, property))

s = f"<{self.__class__.__name__}"
for prop_tuple in props:
name, prop = prop_tuple
try:
# ignore deprecation warnings
with warnings.catch_warnings(record=True):
prop_value = prop.fget(self)
except Exception as ex:
prop_value = ex.__class__.__name__

s += f" {name}={prop_value}"
s += ">"
return s


class Device(metaclass=DeviceGroupMeta):
"""Base class for all device implementations.
Expand Down Expand Up @@ -347,5 +327,27 @@ def fail(x):

return "Done"

def status(self) -> DeviceStatus:
"""Return device status."""
raise NotImplementedError()

def buttons(self) -> List[ButtonDescriptor]:
"""Return a list of button-like, clickable actions of the device."""
return []

def settings(self) -> List[SettingDescriptor]:
"""Return list of settings."""
return []

def sensors(self) -> Dict[str, SensorDescriptor]:
"""Return list of sensors."""
# TODO: the latest status should be cached and re-used by all meta information getters
sensors = self.status().sensors()
return sensors

def switches(self) -> List[SwitchDescriptor]:
"""Return list of toggleable switches."""
return []

def __repr__(self):
return f"<{self.__class__.__name__ }: {self.ip} (token: {self.token})>"
100 changes: 100 additions & 0 deletions miio/devicestatus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import inspect
import logging
import warnings
from typing import Dict, Union, get_args, get_origin, get_type_hints

from .descriptors import SensorDescriptor

_LOGGER = logging.getLogger(__name__)


class _StatusMeta(type):
"""Meta class to provide introspectable properties."""

def __new__(metacls, name, bases, namespace, **kwargs):
cls = super().__new__(metacls, name, bases, namespace)
cls._sensors: Dict[str, SensorDescriptor] = {}
for n in namespace:
prop = getattr(namespace[n], "fget", None)
if prop:
sensor = getattr(prop, "_sensor", None)
if sensor:
_LOGGER.debug(f"Found sensor: {sensor} for {name}")
cls._sensors[n] = sensor

return cls


class DeviceStatus(metaclass=_StatusMeta):
"""Base class for status containers.
All status container classes should inherit from this class:
* This class allows downstream users to access the available information in an
introspectable way.
* The __repr__ implementation returns all defined properties and their values.
"""

def __repr__(self):
props = inspect.getmembers(self.__class__, lambda o: isinstance(o, property))

s = f"<{self.__class__.__name__}"
for prop_tuple in props:
name, prop = prop_tuple
try:
# ignore deprecation warnings
with warnings.catch_warnings(record=True):
prop_value = prop.fget(self)
except Exception as ex:
prop_value = ex.__class__.__name__

s += f" {name}={prop_value}"
s += ">"
return s

def sensors(self) -> Dict[str, SensorDescriptor]:
"""Return the dict of sensors exposed by the status container.
You can use @sensor decorator to define sensors inside your status class.
"""
return self._sensors # type: ignore[attr-defined]


def sensor(*, name: str, unit: str = "", **kwargs):
"""Syntactic sugar to create SensorDescriptor objects.
The information can be used by users of the library to programatically find out what
types of sensors are available for the device.
The interface is kept minimal, but you can pass any extra keyword arguments.
These extras are made accessible over :attr:`~miio.descriptors.SensorDescriptor.extras`,
and can be interpreted downstream users as they wish.
"""

def decorator_sensor(func):
property_name = func.__name__

def _sensor_type_for_return_type(func):
rtype = get_type_hints(func).get("return")
if get_origin(rtype) is Union: # Unwrap Optional[]
rtype, _ = get_args(rtype)

if rtype == bool:
return "binary"
else:
return "sensor"

sensor_type = _sensor_type_for_return_type(func)
descriptor = SensorDescriptor(
id=str(property_name),
property=str(property_name),
name=name,
unit=unit,
type=sensor_type,
extras=kwargs,
)
func._sensor = descriptor

return func

return decorator_sensor
Loading

0 comments on commit d872d55

Please sign in to comment.