diff --git a/docs/contributing.rst b/docs/contributing.rst index d5c342cf7..2068a3a8c 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -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 ` 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. @@ -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 ` 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: diff --git a/miio/__init__.py b/miio/__init__.py index f16b29506..0ea3a6158 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -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 diff --git a/miio/descriptors.py b/miio/descriptors.py new file mode 100644 index 000000000..cbf884c55 --- /dev/null +++ b/miio/descriptors.py @@ -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 ` 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 diff --git a/miio/device.py b/miio/device.py index 3793c65bb..8699bca83 100644 --- a/miio/device.py +++ b/miio/device.py @@ -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 @@ -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. @@ -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})>" diff --git a/miio/devicestatus.py b/miio/devicestatus.py new file mode 100644 index 000000000..4689cf7ad --- /dev/null +++ b/miio/devicestatus.py @@ -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 diff --git a/miio/powerstrip.py b/miio/powerstrip.py index 159c6d80d..32e3fafc6 100644 --- a/miio/powerstrip.py +++ b/miio/powerstrip.py @@ -6,7 +6,8 @@ import click from .click_common import EnumType, command, format_output -from .device import Device, DeviceStatus +from .device import Device +from .devicestatus import DeviceStatus, sensor from .exceptions import DeviceException from .utils import deprecated @@ -65,16 +66,19 @@ def power(self) -> str: return self.data["power"] @property + @sensor(name="Power") def is_on(self) -> bool: """True if the device is turned on.""" return self.power == "on" @property + @sensor(name="Temperature", unit="C", device_class="temperature") def temperature(self) -> float: """Current temperature.""" return self.data["temperature"] @property + @sensor(name="Current", unit="A", device_class="current") def current(self) -> Optional[float]: """Current, if available. @@ -85,6 +89,7 @@ def current(self) -> Optional[float]: return None @property + @sensor(name="Load power", unit="W", device_class="power") def load_power(self) -> Optional[float]: """Current power load, if available.""" if self.data["power_consume_rate"] is not None: @@ -105,6 +110,7 @@ def wifi_led(self) -> Optional[bool]: return self.led @property + @sensor(name="LED", icon="mdi:led-outline") def led(self) -> Optional[bool]: """True if the wifi led is turned on.""" if "wifi_led" in self.data and self.data["wifi_led"] is not None: @@ -119,6 +125,7 @@ def power_price(self) -> Optional[int]: return None @property + @sensor(name="Leakage current", unit="A", device_class="current") def leakage_current(self) -> Optional[int]: """The leakage current, if available.""" if "elec_leakage" in self.data and self.data["elec_leakage"] is not None: @@ -126,6 +133,7 @@ def leakage_current(self) -> Optional[int]: return None @property + @sensor(name="Voltage", unit="V", device_class="voltage") def voltage(self) -> Optional[float]: """The voltage, if available.""" if "voltage" in self.data and self.data["voltage"] is not None: @@ -133,6 +141,7 @@ def voltage(self) -> Optional[float]: return None @property + @sensor(name="Power Factor", unit="%", device_class="power_factor") def power_factor(self) -> Optional[float]: """The power factor, if available.""" if "power_factor" in self.data and self.data["power_factor"] is not None: diff --git a/miio/tests/test_devicestatus.py b/miio/tests/test_devicestatus.py index 8a1cfa6d0..90dc048e6 100644 --- a/miio/tests/test_devicestatus.py +++ b/miio/tests/test_devicestatus.py @@ -1,4 +1,5 @@ from miio import DeviceStatus +from miio.devicestatus import sensor def test_multiple(): @@ -64,3 +65,33 @@ def return_none(self): return None assert repr(NoneStatus()) == "" + + +def test_sensor_decorator(): + class DecoratedProps(DeviceStatus): + @property + @sensor(name="Voltage", unit="V") + def all_kwargs(self): + pass + + @property + @sensor(name="Only name") + def only_name(self): + pass + + @property + @sensor(name="", unknown_kwarg="123") + def unknown(self): + pass + + status = DecoratedProps() + sensors = status.sensors() + assert len(sensors) == 3 + + all_kwargs = sensors["all_kwargs"] + assert all_kwargs.name == "Voltage" + assert all_kwargs.unit == "V" + + assert sensors["only_name"].name == "Only name" + + assert "unknown_kwarg" in sensors["unknown"].extras diff --git a/pyproject.toml b/pyproject.toml index fd16d4db5..9b8abf7e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,8 +105,9 @@ exclude_lines = [ "def __repr__" ] -[tool.check-manifest] -ignore = ["devtools/*"] +[tool.mypy] +# disables "Decorated property not supported", see https://github.com/python/mypy/issues/1362 +disable_error_code = "misc" [build-system] requires = ["poetry-core"]