From 0d8014c09541184b85467b7eb8aed4cd7f076300 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 11 Aug 2022 21:02:10 +0200 Subject: [PATCH 01/11] Initial implementation of introspectable device interfaces --- miio/descriptors.py | 76 ++++++++++++++++++++++++++++++ miio/device.py | 109 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 miio/descriptors.py diff --git a/miio/descriptors.py b/miio/descriptors.py new file mode 100644 index 000000000..56521d4a3 --- /dev/null +++ b/miio/descriptors.py @@ -0,0 +1,76 @@ +"""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, List, Optional + + +@dataclass +class ButtonDescriptor: + id: str + name: str + method: Callable + icon: Optional[str] = None + + +@dataclass +class SensorDescriptor: + id: str + type: str + name: str + property: str + icon: Optional[str] = None + unit: Optional[str] = None + + +@dataclass +class SwitchDescriptor: + """Presents toggleable switch.""" + + id: str + name: str + property: str + setter: Callable + icon: Optional[str] = None + + +@dataclass +class SettingDescriptor: + """Presents a settable value.""" + + id: str + name: str + property: str + setter: Callable + unit: str + icon: 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 + + +@dataclass +class NumberSettingDescriptor(SettingDescriptor): + """Presents a settable, numerical value.""" + + min_value: int + max_value: int + step: int + type: SettingType = SettingType.Number diff --git a/miio/device.py b/miio/device.py index 3793c65bb..f2eba5ebd 100644 --- a/miio/device.py +++ b/miio/device.py @@ -3,11 +3,26 @@ import warnings from enum import Enum from pprint import pformat as pf -from typing import Any, Dict, List, Optional # noqa: F401 +from typing import ( # noqa: F401 + Any, + Dict, + List, + Optional, + Union, + get_args, + get_origin, + get_type_hints, +) import click from .click_common import DeviceGroupMeta, LiteralParamType, command, format_output +from .descriptors import ( + ButtonDescriptor, + SensorDescriptor, + SettingDescriptor, + SwitchDescriptor, +) from .deviceinfo import DeviceInfo from .exceptions import DeviceInfoUnavailableException, PayloadDecodeException from .miioprotocol import MiIOProtocol @@ -22,11 +37,68 @@ class UpdateState(Enum): Idle = "idle" -class DeviceStatus: +def sensor(*, name: str, icon: str = "mdi:sensor", unit: str = "", **kwargs): + """Decorator helper to create sensordescriptors for status classes. + + The information can be used by users of the library to programatically find out what + types of sensors are available for the device. + """ + + 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, + icon=icon, + unit=unit, + type=sensor_type, + **kwargs, + ) + func._sensor = descriptor + + return func + + return decorator_sensor + + +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 = [] + 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.append(sensor) + + return cls + + +class DeviceStatus(metaclass=_StatusMeta): """Base class for status containers. - All status container classes should inherit from this class. The __repr__ - implementation returns all defined properties and their values. + 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): @@ -46,6 +118,13 @@ def __repr__(self): s += ">" return s + def sensors(self): + """Return the list of sensors exposed by the status container. + + You can use @sensor decorator to define sensors inside your status class. + """ + return self._sensors + class Device(metaclass=DeviceGroupMeta): """Base class for all device implementations. @@ -347,5 +426,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) -> List[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})>" From 4f712f178d827bcd3c21955d5688fe45ca1c9cd3 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 11 Aug 2022 21:09:53 +0200 Subject: [PATCH 02/11] Convert powerstrip to expose its sensors --- miio/powerstrip.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/miio/powerstrip.py b/miio/powerstrip.py index 159c6d80d..2fe3bbfb0 100644 --- a/miio/powerstrip.py +++ b/miio/powerstrip.py @@ -6,7 +6,7 @@ import click from .click_common import EnumType, command, format_output -from .device import Device, DeviceStatus +from .device import Device, DeviceStatus, sensor from .exceptions import DeviceException from .utils import deprecated @@ -65,16 +65,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", icon="mdi:thermometer") def temperature(self) -> float: """Current temperature.""" return self.data["temperature"] @property + @sensor(name="Current", unit="A", icon="mdi:current-ac") def current(self) -> Optional[float]: """Current, if available. @@ -85,6 +88,7 @@ def current(self) -> Optional[float]: return None @property + @sensor(name="Load power", unit="W") def load_power(self) -> Optional[float]: """Current power load, if available.""" if self.data["power_consume_rate"] is not None: @@ -105,6 +109,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 +124,7 @@ def power_price(self) -> Optional[int]: return None @property + @sensor(name="Leakage current", unit="A") 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 +132,7 @@ def leakage_current(self) -> Optional[int]: return None @property + @sensor(name="Voltage", unit="V") def voltage(self) -> Optional[float]: """The voltage, if available.""" if "voltage" in self.data and self.data["voltage"] is not None: @@ -133,6 +140,7 @@ def voltage(self) -> Optional[float]: return None @property + @sensor(name="Power Factor", unit="%") 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: From 7b645f715e6c97626e37d2cc76525862aec0e291 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 11 Aug 2022 21:26:20 +0200 Subject: [PATCH 03/11] Move DeviceStatus and its helpers to separate class --- miio/device.py | 103 +------------------------------------------ miio/devicestatus.py | 97 ++++++++++++++++++++++++++++++++++++++++ miio/powerstrip.py | 3 +- 3 files changed, 101 insertions(+), 102 deletions(-) create mode 100644 miio/devicestatus.py diff --git a/miio/device.py b/miio/device.py index f2eba5ebd..4ce27d0b4 100644 --- a/miio/device.py +++ b/miio/device.py @@ -1,18 +1,7 @@ -import inspect import logging -import warnings from enum import Enum from pprint import pformat as pf -from typing import ( # noqa: F401 - Any, - Dict, - List, - Optional, - Union, - get_args, - get_origin, - get_type_hints, -) +from typing import Any, Dict, List, Optional, Union # noqa: F401 import click @@ -24,6 +13,7 @@ SwitchDescriptor, ) from .deviceinfo import DeviceInfo +from .devicestatus import DeviceStatus from .exceptions import DeviceInfoUnavailableException, PayloadDecodeException from .miioprotocol import MiIOProtocol @@ -37,95 +27,6 @@ class UpdateState(Enum): Idle = "idle" -def sensor(*, name: str, icon: str = "mdi:sensor", unit: str = "", **kwargs): - """Decorator helper to create sensordescriptors for status classes. - - The information can be used by users of the library to programatically find out what - types of sensors are available for the device. - """ - - 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, - icon=icon, - unit=unit, - type=sensor_type, - **kwargs, - ) - func._sensor = descriptor - - return func - - return decorator_sensor - - -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 = [] - 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.append(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): - """Return the list of sensors exposed by the status container. - - You can use @sensor decorator to define sensors inside your status class. - """ - return self._sensors - - class Device(metaclass=DeviceGroupMeta): """Base class for all device implementations. diff --git a/miio/devicestatus.py b/miio/devicestatus.py new file mode 100644 index 000000000..29356f724 --- /dev/null +++ b/miio/devicestatus.py @@ -0,0 +1,97 @@ +import inspect +import logging +import warnings +from typing import 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 = [] + 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.append(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): + """Return the list of sensors exposed by the status container. + + You can use @sensor decorator to define sensors inside your status class. + """ + return self._sensors + + +def sensor(*, name: str, icon: str = "mdi:sensor", unit: str = "", **kwargs): + """Decorator helper to create sensordescriptors for status classes. + + The information can be used by users of the library to programatically find out what + types of sensors are available for the device. + """ + + 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, + icon=icon, + unit=unit, + type=sensor_type, + **kwargs, + ) + func._sensor = descriptor + + return func + + return decorator_sensor diff --git a/miio/powerstrip.py b/miio/powerstrip.py index 2fe3bbfb0..2b43dcfae 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, sensor +from .device import Device +from .devicestatus import DeviceStatus, sensor from .exceptions import DeviceException from .utils import deprecated From 5c684a90bd7d93ec5bcb3dd5e50313122a7cf9f1 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 13 Aug 2022 05:02:47 +0200 Subject: [PATCH 04/11] Disable mypy's misc errors completely This fixes "Decorated property not supported" errors for the time being without hopefully many downsides. The impact is hard to measure as the list of misc errors is not to be found. This should be revisited if/when https://github.com/python/mypy/issues/1362 gets solved. --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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"] From 7e694a6c2b3b2bf249899c80bd3038775b10d899 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 13 Aug 2022 17:58:01 +0200 Subject: [PATCH 05/11] Add docs --- docs/contributing.rst | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index d5c342cf7..740d5c0ce 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,12 +161,29 @@ 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 From 3ee5879c846b0b81b91835e43cdc6e8571d7c113 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 13 Aug 2022 18:19:58 +0200 Subject: [PATCH 06/11] Store extraneous sensor kwargs inside extras attribute --- miio/__init__.py | 3 ++- miio/descriptors.py | 3 ++- miio/devicestatus.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) 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 index 56521d4a3..2dada6d56 100644 --- a/miio/descriptors.py +++ b/miio/descriptors.py @@ -8,7 +8,7 @@ """ from dataclasses import dataclass from enum import Enum, auto -from typing import Callable, List, Optional +from typing import Callable, Dict, List, Optional @dataclass @@ -27,6 +27,7 @@ class SensorDescriptor: property: str icon: Optional[str] = None unit: Optional[str] = None + extras: Optional[Dict] = None @dataclass diff --git a/miio/devicestatus.py b/miio/devicestatus.py index 29356f724..930db70c1 100644 --- a/miio/devicestatus.py +++ b/miio/devicestatus.py @@ -88,7 +88,7 @@ def _sensor_type_for_return_type(func): icon=icon, unit=unit, type=sensor_type, - **kwargs, + extras=kwargs, ) func._sensor = descriptor From 7e1bbb8291766c6328593f591abf3c1ef0ade51c Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 13 Aug 2022 18:22:31 +0200 Subject: [PATCH 07/11] Expose sensors as a dict --- miio/device.py | 2 +- miio/devicestatus.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/miio/device.py b/miio/device.py index 4ce27d0b4..8699bca83 100644 --- a/miio/device.py +++ b/miio/device.py @@ -339,7 +339,7 @@ def settings(self) -> List[SettingDescriptor]: """Return list of settings.""" return [] - def sensors(self) -> List[SensorDescriptor]: + 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() diff --git a/miio/devicestatus.py b/miio/devicestatus.py index 930db70c1..81faf5de9 100644 --- a/miio/devicestatus.py +++ b/miio/devicestatus.py @@ -1,7 +1,7 @@ import inspect import logging import warnings -from typing import Union, get_args, get_origin, get_type_hints +from typing import Dict, Union, get_args, get_origin, get_type_hints from .descriptors import SensorDescriptor @@ -13,14 +13,14 @@ class _StatusMeta(type): def __new__(metacls, name, bases, namespace, **kwargs): cls = super().__new__(metacls, name, bases, namespace) - cls._sensors = [] + 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.append(sensor) + cls._sensors[n] = sensor return cls @@ -52,12 +52,12 @@ def __repr__(self): s += ">" return s - def sensors(self): - """Return the list of sensors exposed by the status container. + 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 + return self._sensors # type: ignore[attr-defined] def sensor(*, name: str, icon: str = "mdi:sensor", unit: str = "", **kwargs): From 140efbad0d958ea8c20dcbe0eb2fee6fe0a48687 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 13 Aug 2022 18:22:59 +0200 Subject: [PATCH 08/11] Add tests to sensor decorator --- miio/tests/test_devicestatus.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/miio/tests/test_devicestatus.py b/miio/tests/test_devicestatus.py index 8a1cfa6d0..5feedf9a9 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,34 @@ def return_none(self): return None assert repr(NoneStatus()) == "" + + +def test_sensor_decorator(): + class DecoratedProps(DeviceStatus): + @property + @sensor(name="Voltage", unit="V", icon="foo") + 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 all_kwargs.icon == "foo" + + assert sensors["only_name"].name == "Only name" + + assert "unknown_kwarg" in sensors["unknown"].extras From b1ea714ca03dcfd751a478311d39ac53257a8d83 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 13 Aug 2022 18:44:02 +0200 Subject: [PATCH 09/11] Remove icon as it can be passed as extra --- miio/descriptors.py | 7 +++---- miio/devicestatus.py | 1 - miio/tests/test_devicestatus.py | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/miio/descriptors.py b/miio/descriptors.py index 2dada6d56..4101468e7 100644 --- a/miio/descriptors.py +++ b/miio/descriptors.py @@ -16,7 +16,7 @@ class ButtonDescriptor: id: str name: str method: Callable - icon: Optional[str] = None + extras: Optional[Dict] = None @dataclass @@ -25,7 +25,6 @@ class SensorDescriptor: type: str name: str property: str - icon: Optional[str] = None unit: Optional[str] = None extras: Optional[Dict] = None @@ -38,7 +37,6 @@ class SwitchDescriptor: name: str property: str setter: Callable - icon: Optional[str] = None @dataclass @@ -50,7 +48,6 @@ class SettingDescriptor: property: str setter: Callable unit: str - icon: str class SettingType(Enum): @@ -65,6 +62,7 @@ class EnumSettingDescriptor(SettingDescriptor): choices: List type: SettingType = SettingType.Enum + extras: Optional[Dict] = None @dataclass @@ -75,3 +73,4 @@ class NumberSettingDescriptor(SettingDescriptor): max_value: int step: int type: SettingType = SettingType.Number + extras: Optional[Dict] = None diff --git a/miio/devicestatus.py b/miio/devicestatus.py index 81faf5de9..efe45b558 100644 --- a/miio/devicestatus.py +++ b/miio/devicestatus.py @@ -85,7 +85,6 @@ def _sensor_type_for_return_type(func): id=str(property_name), property=str(property_name), name=name, - icon=icon, unit=unit, type=sensor_type, extras=kwargs, diff --git a/miio/tests/test_devicestatus.py b/miio/tests/test_devicestatus.py index 5feedf9a9..90dc048e6 100644 --- a/miio/tests/test_devicestatus.py +++ b/miio/tests/test_devicestatus.py @@ -70,7 +70,7 @@ def return_none(self): def test_sensor_decorator(): class DecoratedProps(DeviceStatus): @property - @sensor(name="Voltage", unit="V", icon="foo") + @sensor(name="Voltage", unit="V") def all_kwargs(self): pass @@ -91,7 +91,6 @@ def unknown(self): all_kwargs = sensors["all_kwargs"] assert all_kwargs.name == "Voltage" assert all_kwargs.unit == "V" - assert all_kwargs.icon == "foo" assert sensors["only_name"].name == "Only name" From c141f6c973725956b78c46d976c05f2ede8bc7cb Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 13 Aug 2022 18:44:31 +0200 Subject: [PATCH 10/11] Prefer device_class extra over icon to inform homeassistant --- miio/powerstrip.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/miio/powerstrip.py b/miio/powerstrip.py index 2b43dcfae..32e3fafc6 100644 --- a/miio/powerstrip.py +++ b/miio/powerstrip.py @@ -72,13 +72,13 @@ def is_on(self) -> bool: return self.power == "on" @property - @sensor(name="Temperature", unit="C", icon="mdi:thermometer") + @sensor(name="Temperature", unit="C", device_class="temperature") def temperature(self) -> float: """Current temperature.""" return self.data["temperature"] @property - @sensor(name="Current", unit="A", icon="mdi:current-ac") + @sensor(name="Current", unit="A", device_class="current") def current(self) -> Optional[float]: """Current, if available. @@ -89,7 +89,7 @@ def current(self) -> Optional[float]: return None @property - @sensor(name="Load power", unit="W") + @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: @@ -125,7 +125,7 @@ def power_price(self) -> Optional[int]: return None @property - @sensor(name="Leakage current", unit="A") + @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: @@ -133,7 +133,7 @@ def leakage_current(self) -> Optional[int]: return None @property - @sensor(name="Voltage", unit="V") + @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: @@ -141,7 +141,7 @@ def voltage(self) -> Optional[float]: return None @property - @sensor(name="Power Factor", unit="%") + @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: From 72c322e15d1f92ad7a5495830f98d7b1c7785ac6 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 13 Aug 2022 19:00:25 +0200 Subject: [PATCH 11/11] Clarify documentation --- docs/contributing.rst | 5 +++++ miio/descriptors.py | 8 ++++++++ miio/devicestatus.py | 8 ++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 740d5c0ce..2068a3a8c 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -186,6 +186,11 @@ using :meth:`~miio.device.Device.sensors`. 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/descriptors.py b/miio/descriptors.py index 4101468e7..cbf884c55 100644 --- a/miio/descriptors.py +++ b/miio/descriptors.py @@ -21,6 +21,14 @@ class ButtonDescriptor: @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 diff --git a/miio/devicestatus.py b/miio/devicestatus.py index efe45b558..4689cf7ad 100644 --- a/miio/devicestatus.py +++ b/miio/devicestatus.py @@ -60,11 +60,15 @@ def sensors(self) -> Dict[str, SensorDescriptor]: return self._sensors # type: ignore[attr-defined] -def sensor(*, name: str, icon: str = "mdi:sensor", unit: str = "", **kwargs): - """Decorator helper to create sensordescriptors for status classes. +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):