diff --git a/docs/contributing.rst b/docs/contributing.rst index 2068a3a8c..04b6dbdb5 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -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. @@ -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 ` 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 ` 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 ` 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: diff --git a/miio/descriptors.py b/miio/descriptors.py index cbf884c55..129a36b2b 100644 --- a/miio/descriptors.py +++ b/miio/descriptors.py @@ -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 diff --git a/miio/device.py b/miio/device.py index 8699bca83..7d668fde0 100644 --- a/miio/device.py +++ b/miio/device.py @@ -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})>" diff --git a/miio/devicestatus.py b/miio/devicestatus.py index 4689cf7ad..583f5eaee 100644 --- a/miio/devicestatus.py +++ b/miio/devicestatus.py @@ -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__) @@ -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: @@ -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 @@ -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. @@ -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 diff --git a/miio/powerstrip.py b/miio/powerstrip.py index 32e3fafc6..989e14e41 100644 --- a/miio/powerstrip.py +++ b/miio/powerstrip.py @@ -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 @@ -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" @@ -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: @@ -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.""" diff --git a/miio/tests/test_devicestatus.py b/miio/tests/test_devicestatus.py index 90dc048e6..9e056beb0 100644 --- a/miio/tests/test_devicestatus.py +++ b/miio/tests/test_devicestatus.py @@ -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(): @@ -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)