Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement introspectable switches #1494

Merged
merged 4 commits into from
Aug 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 43 additions & 13 deletions docs/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,9 @@ Development checklist
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`).
have type annotations for their return values. The information that should be exposed directly
to end users should be decorated using appropriate decorators (e.g., `@sensor` or `@switch`) 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 @@ -172,25 +173,54 @@ 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`.
The properties can be decorated using special decorators to define meta information
that enables introspection and programatic creation of user interface elements.

.. note::

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


Sensors
"""""""

Use :meth:`@sensor <miio.devicestatus.sensor>` to create :class:`~miio.descriptors.SensorDescriptor`
objects for the status container.
This will make all decorated sensors accessible through :meth:`~miio.device.Device.sensors` for downstream users.

.. code-block:: python

@property
@sensor(name="Voltage", unit="V")
@sensor(name="Voltage", unit="V", some_kwarg_for_downstream="hi there")
def voltage(self) -> Optional[float]:
"""Return the voltage, if available."""
pass

.. note::

All keywords arguments not defined in the decorator signature will be available
through the :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.


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.

.. code-block::

@property
@switch(name="Power", setter_name="set_power")
def power(self) -> bool:
"""Return if device is turned on."""

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.
The mandatory *setter_name* will be used to bind the method to be accessible using
the :meth:`~miio.descriptors.SwitchDescriptor.setter` callable.

.. _adding_tests:

Expand Down
4 changes: 3 additions & 1 deletion miio/descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ class SwitchDescriptor:
id: str
name: str
property: str
setter: Callable
setter_name: str
setter: Optional[Callable] = None
extras: Optional[Dict] = None


@dataclass
Expand Down
13 changes: 9 additions & 4 deletions miio/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,14 +340,19 @@ def settings(self) -> List[SettingDescriptor]:
return []

def sensors(self) -> Dict[str, SensorDescriptor]:
"""Return list of sensors."""
"""Return 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 switches(self) -> Dict[str, SwitchDescriptor]:
"""Return toggleable switches."""
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)

return switches

def __repr__(self):
return f"<{self.__class__.__name__ }: {self.ip} (token: {self.token})>"
43 changes: 42 additions & 1 deletion miio/devicestatus.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import warnings
from typing import Dict, Union, get_args, get_origin, get_type_hints

from .descriptors import SensorDescriptor
from .descriptors import SensorDescriptor, SwitchDescriptor

_LOGGER = logging.getLogger(__name__)

Expand All @@ -14,6 +14,7 @@ class _StatusMeta(type):
def __new__(metacls, name, bases, namespace, **kwargs):
cls = super().__new__(metacls, name, bases, namespace)
cls._sensors: Dict[str, SensorDescriptor] = {}
cls._switches: Dict[str, SwitchDescriptor] = {}
for n in namespace:
prop = getattr(namespace[n], "fget", None)
if prop:
Expand All @@ -22,6 +23,11 @@ def __new__(metacls, name, bases, namespace, **kwargs):
_LOGGER.debug(f"Found sensor: {sensor} for {name}")
cls._sensors[n] = sensor

switch = getattr(prop, "_switch", None)
if switch:
_LOGGER.debug(f"Found switch {switch} for {name}")
cls._switches[n] = switch

return cls


Expand Down Expand Up @@ -59,6 +65,13 @@ def sensors(self) -> Dict[str, SensorDescriptor]:
"""
return self._sensors # type: ignore[attr-defined]

def switches(self) -> Dict[str, SwitchDescriptor]:
"""Return the dict of sensors exposed by the status container.

You can use @sensor decorator to define sensors inside your status class.
"""
return self._switches # type: ignore[attr-defined]


def sensor(*, name: str, unit: str = "", **kwargs):
"""Syntactic sugar to create SensorDescriptor objects.
Expand Down Expand Up @@ -98,3 +111,31 @@ def _sensor_type_for_return_type(func):
return func

return decorator_sensor


def switch(*, name: str, setter_name: str, **kwargs):
"""Syntactic sugar to create SwitchDescriptor 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.SwitchDescriptor.extras`,
and can be interpreted downstream users as they wish.
"""

def decorator_sensor(func):
property_name = func.__name__

descriptor = SwitchDescriptor(
id=str(property_name),
property=str(property_name),
name=name,
setter_name=setter_name,
extras=kwargs,
)
func._switch = descriptor

return func

return decorator_sensor
15 changes: 12 additions & 3 deletions miio/powerstrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from .click_common import EnumType, command, format_output
from .device import Device
from .devicestatus import DeviceStatus, sensor
from .devicestatus import DeviceStatus, sensor, switch
from .exceptions import DeviceException
from .utils import deprecated

Expand Down Expand Up @@ -66,7 +66,7 @@ def power(self) -> str:
return self.data["power"]

@property
@sensor(name="Power")
@switch(name="Power", setter_name="set_power", device_class="outlet")
def is_on(self) -> bool:
"""True if the device is turned on."""
return self.power == "on"
Expand Down Expand Up @@ -110,7 +110,9 @@ def wifi_led(self) -> Optional[bool]:
return self.led

@property
@sensor(name="LED", icon="mdi:led-outline")
@switch(
name="LED", icon="mdi:led-outline", setter_name="set_led", device_class="switch"
)
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:
Expand Down Expand Up @@ -178,6 +180,13 @@ def status(self) -> PowerStripStatus:

return PowerStripStatus(defaultdict(lambda: None, zip(properties, values)))

@command(click.argument("power", type=bool))
def set_power(self, power: bool):
"""Set the power on or off."""
if power:
return self.on()
return self.off()

@command(default_output=format_output("Powering on"))
def on(self):
"""Power on."""
Expand Down
27 changes: 25 additions & 2 deletions miio/tests/test_devicestatus.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from miio import DeviceStatus
from miio.devicestatus import sensor
from miio import Device, DeviceStatus
from miio.devicestatus import sensor, switch


def test_multiple():
Expand Down Expand Up @@ -95,3 +95,26 @@ def unknown(self):
assert sensors["only_name"].name == "Only name"

assert "unknown_kwarg" in sensors["unknown"].extras


def test_switch_decorator(mocker):
class DecoratedSwitches(DeviceStatus):
@property
@switch(name="Power", setter_name="set_power")
def power(self):
pass

mocker.patch("miio.Device.send")
d = Device("127.0.0.1", "68ffffffffffffffffffffffffffffff")

# Patch status to return our class
mocker.patch.object(d, "status", return_value=DecoratedSwitches())
# Patch to create a new setter as defined in the status class
set_power = mocker.patch.object(d, "set_power", create=True, return_value=1)

sensors = d.switches()
assert len(sensors) == 1
assert sensors["power"].name == "Power"

sensors["power"].setter(True)
set_power.assert_called_with(True)