From c76ed7ac6b7e71442147376b12bebe39e4fe7975 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 7 Jan 2021 22:14:31 +0100 Subject: [PATCH 1/6] Improve MiotDevice API (get_property_by, set_property_by commands) * Also, code cleanup for passing the mapping. * Now the mapping is a class attribute, avoiding unnecessary __init__ overloading --- miio/airconditioner_miot.py | 10 +----- miio/airhumidifier_miot.py | 10 +----- miio/airpurifier_miot.py | 10 +----- miio/curtain_youpin.py | 10 +----- miio/fan_miot.py | 34 ++++--------------- miio/heater_miot.py | 10 +----- miio/huizuo.py | 12 ++++--- miio/miot_device.py | 65 +++++++++++++++++++++++++++++-------- 8 files changed, 70 insertions(+), 91 deletions(-) diff --git a/miio/airconditioner_miot.py b/miio/airconditioner_miot.py index 38079098f..e8ec37aff 100644 --- a/miio/airconditioner_miot.py +++ b/miio/airconditioner_miot.py @@ -337,15 +337,7 @@ def __json__(self): class AirConditionerMiot(MiotDevice): """Main class representing the air conditioner which uses MIoT protocol.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__(_MAPPING, ip, token, start_id, debug, lazy_discover) + mapping = _MAPPING @command( default_output=format_output( diff --git a/miio/airhumidifier_miot.py b/miio/airhumidifier_miot.py index 0ff82ecbf..9c9952dfc 100644 --- a/miio/airhumidifier_miot.py +++ b/miio/airhumidifier_miot.py @@ -277,15 +277,7 @@ def __repr__(self) -> str: class AirHumidifierMiot(MiotDevice): """Main class representing the air humidifier which uses MIoT protocol.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__(_MAPPING, ip, token, start_id, debug, lazy_discover) + mapping = _MAPPING @command( default_output=format_output( diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index 151dbc2d6..f3010c733 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -437,15 +437,7 @@ def set_child_lock(self, lock: bool): class AirPurifierMiot(BasicAirPurifierMiot): """Main class representing the air purifier which uses MIoT protocol.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__(_MAPPING, ip, token, start_id, debug, lazy_discover) + mapping = _MAPPING @command( default_output=format_output( diff --git a/miio/curtain_youpin.py b/miio/curtain_youpin.py index 1145e3378..52667dcf4 100644 --- a/miio/curtain_youpin.py +++ b/miio/curtain_youpin.py @@ -138,15 +138,7 @@ def __repr__(self) -> str: class CurtainMiot(MiotDevice): """Main class representing the lumi.curtain.hagl05 curtain.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__(_MAPPING, ip, token, start_id, debug, lazy_discover) + mapping = _MAPPING @command( default_output=format_output( diff --git a/miio/fan_miot.py b/miio/fan_miot.py index 779ad04ad..3ab4f173a 100644 --- a/miio/fan_miot.py +++ b/miio/fan_miot.py @@ -167,6 +167,8 @@ def __repr__(self) -> str: class FanMiot(MiotDevice): + mapping = MIOT_MAPPING[MODEL_FAN_P10] + def __init__( self, ip: str = None, @@ -179,9 +181,9 @@ def __init__( if model not in MIOT_MAPPING: raise FanException("Invalid FanMiot model: %s" % model) + super().__init__(ip, token, start_id, debug, lazy_discover) self.model = model - super().__init__(MIOT_MAPPING[model], ip, token, start_id, debug, lazy_discover) @command( default_output=format_output( @@ -320,36 +322,12 @@ def set_rotate(self, direction: MoveDirection): class FanP9(FanMiot): - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover, model=MODEL_FAN_P9) + mapping = MIOT_MAPPING[MODEL_FAN_P9] class FanP10(FanMiot): - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover, model=MODEL_FAN_P10) + mapping = MIOT_MAPPING[MODEL_FAN_P10] class FanP11(FanMiot): - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__(ip, token, start_id, debug, lazy_discover, model=MODEL_FAN_P11) + mapping = MIOT_MAPPING[MODEL_FAN_P11] diff --git a/miio/heater_miot.py b/miio/heater_miot.py index 8eeb2a1e8..684a09628 100644 --- a/miio/heater_miot.py +++ b/miio/heater_miot.py @@ -125,15 +125,7 @@ def __repr__(self) -> str: class HeaterMiot(MiotDevice): """Main class representing the Xiaomi Smart Space Heater S (zhimi.heater.mc2).""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__(_MAPPING, ip, token, start_id, debug, lazy_discover) + mapping = _MAPPING @command( default_output=format_output( diff --git a/miio/huizuo.py b/miio/huizuo.py index 358b72bc1..a6c90265a 100644 --- a/miio/huizuo.py +++ b/miio/huizuo.py @@ -233,6 +233,8 @@ class Huizuo(MiotDevice): If your device does't support some properties, the 'None' will be returned """ + mapping = _MAPPING + def __init__( self, ip: str = None, @@ -244,15 +246,15 @@ def __init__( ) -> None: if model in MODELS_WITH_FAN_WY: - _MAPPING.update(_ADDITIONAL_MAPPING_FAN_WY) + self.mapping.update(_ADDITIONAL_MAPPING_FAN_WY) if model in MODELS_WITH_FAN_WY2: - _MAPPING.update(_ADDITIONAL_MAPPING_FAN_WY2) + self.mapping.update(_ADDITIONAL_MAPPING_FAN_WY2) if model in MODELS_WITH_SCENES: - _MAPPING.update(_ADDITIONAL_MAPPING_SCENE) + self.mapping.update(_ADDITIONAL_MAPPING_SCENE) if model in MODELS_WITH_HEATER: - _MAPPING.update(_ADDITIONAL_MAPPING_HEATER) + self.mapping.update(_ADDITIONAL_MAPPING_HEATER) - super().__init__(_MAPPING, ip, token, start_id, debug, lazy_discover) + super().__init__(ip, token, start_id, debug, lazy_discover) if model in MODELS_SUPPORTED: self.model = model diff --git a/miio/miot_device.py b/miio/miot_device.py index 0930e1f03..28c240d0f 100644 --- a/miio/miot_device.py +++ b/miio/miot_device.py @@ -1,24 +1,26 @@ import logging +from enum import Enum +from typing import Any, Union +import click + +from .click_common import EnumType, command from .device import Device _LOGGER = logging.getLogger(__name__) +class MiotValueType(Enum): + Int = int + Float = float + Bool = bool + Str = str + + class MiotDevice(Device): """Main class representing a MIoT device.""" - def __init__( - self, - mapping: dict, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - self.mapping = mapping - super().__init__(ip, token, start_id, debug, lazy_discover) + mapping = None def get_properties_for_mapping(self) -> list: """Retrieve raw properties based on mapping.""" @@ -30,9 +32,46 @@ def get_properties_for_mapping(self) -> list: properties, property_getter="get_properties", max_properties=15 ) - def set_property(self, property_key: str, value): - """Sets property value.""" + @command( + click.argument("siid", type=int), + click.argument("piid", type=int), + ) + def get_property_by(self, siid: int, piid: int): + """Get a single property (siid/piid).""" + return self.send( + "get_properties", [{"did": f"{siid}-{piid}", "siid": siid, "piid": piid}] + ) + @command( + click.argument("siid", type=int), + click.argument("piid", type=int), + click.argument("value"), + click.argument( + "value_type", type=EnumType(MiotValueType), required=False, default=None + ), + ) + def set_property_by( + self, + siid: int, + piid: int, + value: Union[int, float, str, bool], + value_type: Any = None, + ): + """Set a single property (siid/piid) to given value. + + value_type can be given to convert the value to wanted type, + allowed types are: int, float, bool, str + """ + if value_type is not None: + value = value_type.value(value) + + return self.send( + "set_properties", + [{"did": f"set-{siid}-{piid}", "siid": siid, "piid": piid, "value": value}], + ) + + def set_property(self, property_key: str, value): + """Sets property value using the existing mapping.""" return self.send( "set_properties", [{"did": property_key, **self.mapping[property_key], "value": value}], From 1b991f8077db8a8515c75c56c22d95372f2181dc Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 16 Jan 2021 19:27:57 +0100 Subject: [PATCH 2/6] Add call_action(name,params) and call_action_by(siid,aiid,params) Also, fix passing booleans to set_property_by --- miio/miot_device.py | 46 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/miio/miot_device.py b/miio/miot_device.py index 28c240d0f..37399eade 100644 --- a/miio/miot_device.py +++ b/miio/miot_device.py @@ -1,19 +1,27 @@ import logging from enum import Enum +from functools import partial from typing import Any, Union import click -from .click_common import EnumType, command +from .click_common import EnumType, LiteralParamType, command from .device import Device +from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) +def _str2bool(x): + """Helper to convert string to boolean.""" + return x.lower() in ("true", "1") + + +# partial is required here for str2bool, see https://stackoverflow.com/a/40339397 class MiotValueType(Enum): Int = int Float = float - Bool = bool + Bool = partial(_str2bool) Str = str @@ -26,12 +34,44 @@ def get_properties_for_mapping(self) -> list: """Retrieve raw properties based on mapping.""" # We send property key in "did" because it's sent back via response and we can identify the property. - properties = [{"did": k, **v} for k, v in self.mapping.items()] + properties = [ + {"did": k, **v} for k, v in self.mapping.items() if "aiid" not in v + ] return self.get_properties( properties, property_getter="get_properties", max_properties=15 ) + @command( + click.argument("name", type=str), + click.argument("params", type=LiteralParamType(), required=False), + ) + def call_action(self, name: str, params=None): + """Call an action by a name in the mapping.""" + action = self.mapping.get(name) + if "siid" not in action or "aiid" not in action: + raise DeviceException(f"{name} is not an action (missing siid or aiid)") + + return self.call_action_by(action["siid"], action["aiid"], params) + + @command( + click.argument("siid", type=int), + click.argument("aiid", type=int), + click.argument("params", type=LiteralParamType(), required=False), + ) + def call_action_by(self, siid, aiid, params=None): + """Call an action.""" + if params is None: + params = [] + payload = { + "did": f"call-{siid}-{aiid}", + "siid": siid, + "aiid": aiid, + "in": params, + } + + return self.send("action", payload) + @command( click.argument("siid", type=int), click.argument("piid", type=int), From 8bdf1ce1ac8e17d035f5ae64a5ef899ebd90cf33 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 12 Feb 2021 16:56:21 +0100 Subject: [PATCH 3/6] Add some unittests --- miio/tests/test_miotdevice.py | 73 +++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 miio/tests/test_miotdevice.py diff --git a/miio/tests/test_miotdevice.py b/miio/tests/test_miotdevice.py new file mode 100644 index 000000000..1537d244a --- /dev/null +++ b/miio/tests/test_miotdevice.py @@ -0,0 +1,73 @@ +import pytest + +from miio import MiotDevice +from miio.miot_device import MiotValueType + + +@pytest.fixture(scope="module") +def dev(module_mocker): + device = MiotDevice("127.0.0.1", "68ffffffffffffffffffffffffffffff") + module_mocker.patch.object(device, "send") + return device + + +def test_get_property_by(dev): + siid = 1 + piid = 2 + _ = dev.get_property_by(siid, piid) + + dev.send.assert_called_with( + "get_properties", [{"did": f"{siid}-{piid}", "siid": siid, "piid": piid}] + ) + + +@pytest.mark.parametrize( + "value_type,value", + [ + (None, 1), + (MiotValueType.Int, "1"), + (MiotValueType.Float, "1.2"), + (MiotValueType.Str, "str"), + (MiotValueType.Bool, "1"), + ], +) +def test_set_property_by(dev, value_type, value): + siid = 1 + piid = 1 + _ = dev.set_property_by(siid, piid, value, value_type) + + if value_type is not None: + value = value_type.value(value) + + dev.send.assert_called_with( + "set_properties", + [{"did": f"set-{siid}-{piid}", "siid": siid, "piid": piid, "value": value}], + ) + + +def test_call_action_by(dev): + siid = 1 + aiid = 1 + + _ = dev.call_action_by(siid, aiid) + dev.send.assert_called_with( + "action", + { + "did": f"call-{siid}-{aiid}", + "siid": siid, + "aiid": aiid, + "in": [], + }, + ) + + params = {"test_param": 1} + _ = dev.call_action_by(siid, aiid, params) + dev.send.assert_called_with( + "action", + { + "did": f"call-{siid}-{aiid}", + "siid": siid, + "aiid": aiid, + "in": params, + }, + ) From f105264c7184e9cba6c0c28b440627c35c066a4b Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 12 Feb 2021 23:59:42 +0100 Subject: [PATCH 4/6] Convert a couple of missed/new classes to use new mapping --- miio/airpurifier_miot.py | 12 +----------- miio/airqualitymonitor_miot.py | 10 +--------- miio/yeelight_dual_switch.py | 10 +--------- 3 files changed, 3 insertions(+), 29 deletions(-) diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index f3010c733..617068d75 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -533,17 +533,7 @@ def set_led(self, led: bool): class AirPurifierMB4(BasicAirPurifierMiot): """Main class representing the air purifier which uses MIoT protocol.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__( - _MODEL_AIRPURIFIER_MB4, ip, token, start_id, debug, lazy_discover - ) + mapping = _MODEL_AIRPURIFIER_MB4 @command( default_output=format_output( diff --git a/miio/airqualitymonitor_miot.py b/miio/airqualitymonitor_miot.py index 0c46b5128..14293c3c7 100644 --- a/miio/airqualitymonitor_miot.py +++ b/miio/airqualitymonitor_miot.py @@ -198,15 +198,7 @@ def __repr__(self) -> str: class AirQualityMonitorCGDN1(MiotDevice): """Qingping Air Monitor Lite.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__(_MAPPING_CGDN1, ip, token, start_id, debug, lazy_discover) + mapping = _MAPPING_CGDN1 @command( default_output=format_output( diff --git a/miio/yeelight_dual_switch.py b/miio/yeelight_dual_switch.py index de0f1e9f5..b76a987cd 100644 --- a/miio/yeelight_dual_switch.py +++ b/miio/yeelight_dual_switch.py @@ -133,15 +133,7 @@ class YeelightDualControlModule(MiotDevice): """Main class representing the Yeelight Dual Control Module (yeelink.switch.sw1) which uses MIoT protocol.""" - def __init__( - self, - ip: str = None, - token: str = None, - start_id: int = 0, - debug: int = 0, - lazy_discover: bool = True, - ) -> None: - super().__init__(_MAPPING, ip, token, start_id, debug, lazy_discover) + mapping = _MAPPING @command( default_output=format_output( From fdb803643207c99e38d2d410988164744c12f080 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Mon, 8 Mar 2021 13:41:39 +0100 Subject: [PATCH 5/6] Fix linting --- docs/troubleshooting.rst | 5 +++-- miio/fan_miot.py | 1 - miio/miot_device.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 6321fd222..069e8dc52 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -62,12 +62,13 @@ The connectivity will get restored by device's internal watchdog restarting the Roborock Vacuum not detected ---------------------------- -It seems that a Roborock vacuum connected through the Roborock app (and not the Xiaomi Home app) won't allow control over local network, even with a valid token, leading to the following exception: +It seems that a Roborock vacuum connected through the Roborock app (and not the Xiaomi Home app) won't allow control over local network, + even with a valid token, leading to the following exception: .. code-block:: text mirobo.device.DeviceException: Unable to discover the device x.x.x.x - + Resetting the device's wifi and pairing it again with the Xiaomi Home app should solve the issue. .. hint:: diff --git a/miio/fan_miot.py b/miio/fan_miot.py index 3ab4f173a..353216d45 100644 --- a/miio/fan_miot.py +++ b/miio/fan_miot.py @@ -184,7 +184,6 @@ def __init__( super().__init__(ip, token, start_id, debug, lazy_discover) self.model = model - @command( default_output=format_output( "", diff --git a/miio/miot_device.py b/miio/miot_device.py index 37399eade..b7588ceb7 100644 --- a/miio/miot_device.py +++ b/miio/miot_device.py @@ -99,8 +99,8 @@ def set_property_by( ): """Set a single property (siid/piid) to given value. - value_type can be given to convert the value to wanted type, - allowed types are: int, float, bool, str + value_type can be given to convert the value to wanted type, allowed types are: + int, float, bool, str """ if value_type is not None: value = value_type.value(value) From c6220eef44731dfe681e5c09f692d0a4e876c37c Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Mon, 8 Mar 2021 13:52:43 +0100 Subject: [PATCH 6/6] make vacuum tests pass --- miio/tests/test_vacuum.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/miio/tests/test_vacuum.py b/miio/tests/test_vacuum.py index 0f9ab705b..0cecb92c8 100644 --- a/miio/tests/test_vacuum.py +++ b/miio/tests/test_vacuum.py @@ -46,9 +46,11 @@ def __init__(self, *args, **kwargs): "app_goto_target": lambda x: self.change_mode("goto"), "app_zoned_clean": lambda x: self.change_mode("zoned clean"), "app_charge": lambda x: self.change_mode("charge"), + "miIO.info": "dummy info", } super().__init__(args, kwargs) + self.model = None def change_mode(self, new_mode): if new_mode == "spot":