From fa2096b277b20d143b95481cfd659974464d2dc4 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 18 Apr 2020 19:01:09 +0200 Subject: [PATCH 1/5] improve miotdevice with some helpers * add miot_info() to get the common information from the device * this includes manufacturer, model, firmeware version and serial number * add get_properties_for_dataclass(cls) which allows easy implementation for get_properties mappings * each field() can define metadata containing siid and piid, these are mapped automatically to the response container * _siid can be used to define common siid, _max_properties can be used to set number of maximum properties per request * get_properties_for_mapping() requires explicit passing of the mapping, no more passing over __init__ --- miio/airpurifier_miot.py | 12 +--------- miio/miot_device.py | 50 ++++++++++++++++++++++++++++++++++++---- miio/tests/dummies.py | 2 +- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index edda98647..b62f3a853 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -255,16 +255,6 @@ def __json__(self): class AirPurifierMiot(MiotDevice): """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) - @command( default_output=format_output( "", @@ -297,7 +287,7 @@ def status(self) -> AirPurifierMiotStatus: return AirPurifierMiotStatus( { prop["did"]: prop["value"] if prop["code"] == 0 else None - for prop in self.get_properties_for_mapping() + for prop in self.get_properties_for_mapping(_MAPPING) } ) diff --git a/miio/miot_device.py b/miio/miot_device.py index aaf96b183..73220a742 100644 --- a/miio/miot_device.py +++ b/miio/miot_device.py @@ -1,32 +1,72 @@ import logging +from dataclasses import dataclass, field +from .click_common import command from .device import Device _LOGGER = logging.getLogger(__name__) +@dataclass +class MiotInfo: + """Container for common MiotInfo service.""" + + _siid = 1 + _max_properties = 1 + + manufacturer: str = field(metadata={"piid": 1}) + model: str = field(metadata={"piid": 2}) + serial_number: str = field(metadata={"piid": 3}) + firmware_version: str = field(metadata={"piid": 4}) + + 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) - def get_properties_for_mapping(self) -> list: + @command() + def miot_info(self) -> MiotInfo: + """Return common miot information.""" + return self.get_properties_for_dataclass(MiotInfo) + + def get_properties_for_dataclass(self, cls): + """Run a query to fill property container.""" + fields = cls.__dataclass_fields__ + property_mapping = {} + + for field_name in fields: + field_meta = fields[field_name].metadata + siid = field_meta.get("siid", cls._siid) + piid = field_meta["piid"] + property_mapping[field_name] = {"siid": siid, "piid": piid} + + response = { + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping( + property_mapping, max_properties=cls._max_properties + ) + } + + return cls(**response) + + def get_properties_for_mapping( + self, property_mapping, *, max_properties=15 + ) -> 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 property_mapping.items()] - return self.get_properties(properties, max_properties=15) + return self.get_properties(properties, max_properties=max_properties) def set_property(self, property_key: str, value): """Sets property value.""" diff --git a/miio/tests/dummies.py b/miio/tests/dummies.py index f7a9f2714..ed59f3acd 100644 --- a/miio/tests/dummies.py +++ b/miio/tests/dummies.py @@ -63,7 +63,7 @@ def __init__(self, *args, **kwargs): self.state = [{"did": k, "value": v, "code": 0} for k, v in self.state.items()] super().__init__(*args, **kwargs) - def get_properties_for_mapping(self): + def get_properties_for_mapping(self, mapping=None): return self.state def set_property(self, property_key: str, value): From c7ba649d3d4766fca7053639cb8a851a2e916365 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 18 Apr 2020 19:20:38 +0200 Subject: [PATCH 2/5] raise an exception for missing siid, ignore fields without piid --- miio/miot_device.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/miio/miot_device.py b/miio/miot_device.py index 73220a742..d62cd52fe 100644 --- a/miio/miot_device.py +++ b/miio/miot_device.py @@ -3,6 +3,7 @@ from .click_common import command from .device import Device +from .exceptions import DeviceException _LOGGER = logging.getLogger(__name__) @@ -12,7 +13,7 @@ class MiotInfo: """Container for common MiotInfo service.""" _siid = 1 - _max_properties = 1 + _max_properties = 1 # some devices respond with broken json otherwise manufacturer: str = field(metadata={"piid": 1}) model: str = field(metadata={"piid": 2}) @@ -45,8 +46,17 @@ def get_properties_for_dataclass(self, cls): for field_name in fields: field_meta = fields[field_name].metadata - siid = field_meta.get("siid", cls._siid) + + if "piid" not in field_meta: + continue piid = field_meta["piid"] + + siid = field_meta.get("siid", getattr(cls, "_siid", None)) + if siid is None: + raise DeviceException( + f"no siid defined for {field_name} or for class {cls}" + ) + property_mapping[field_name] = {"siid": siid, "piid": piid} response = { From 93fb155ebf93e2faf7dc0b267523eef71aad3a12 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 18 Apr 2020 19:25:30 +0200 Subject: [PATCH 3/5] miotdevice: require mapping for set_property calls --- miio/airpurifier_miot.py | 22 +++++++++++----------- miio/miot_device.py | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index b62f3a853..270f15083 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -294,12 +294,12 @@ def status(self) -> AirPurifierMiotStatus: @command(default_output=format_output("Powering on")) def on(self): """Power on.""" - return self.set_property("power", True) + return self.set_property(_MAPPING, "power", True) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" - return self.set_property("power", False) + return self.set_property(_MAPPING, "power", False) @command( click.argument("level", type=int), @@ -309,7 +309,7 @@ def set_fan_level(self, level: int): """Set fan level.""" if level < 1 or level > 3: raise AirPurifierMiotException("Invalid fan level: %s" % level) - return self.set_property("fan_level", level) + return self.set_property(_MAPPING, "fan_level", level) @command( click.argument("rpm", type=int), @@ -323,7 +323,7 @@ def set_favorite_rpm(self, rpm: int): "Invalid favorite motor speed: %s. Must be between 300 and 2300 and divisible by 10" % rpm ) - return self.set_property("favorite_rpm", rpm) + return self.set_property(_MAPPING, "favorite_rpm", rpm) @command( click.argument("volume", type=int), @@ -335,7 +335,7 @@ def set_volume(self, volume: int): raise AirPurifierMiotException( "Invalid volume: %s. Must be between 0 and 100" % volume ) - return self.set_property("buzzer_volume", volume) + return self.set_property(_MAPPING, "buzzer_volume", volume) @command( click.argument("mode", type=EnumType(OperationMode, False)), @@ -343,7 +343,7 @@ def set_volume(self, volume: int): ) def set_mode(self, mode: OperationMode): """Set mode.""" - return self.set_property("mode", mode.value) + return self.set_property(_MAPPING, "mode", mode.value) @command( click.argument("level", type=int), @@ -356,7 +356,7 @@ def set_favorite_level(self, level: int): if level < 0 or level > 14: raise AirPurifierMiotException("Invalid favorite level: %s" % level) - return self.set_property("favorite_level", level) + return self.set_property(_MAPPING, "favorite_level", level) @command( click.argument("brightness", type=EnumType(LedBrightness, False)), @@ -364,7 +364,7 @@ def set_favorite_level(self, level: int): ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" - return self.set_property("led_brightness", brightness.value) + return self.set_property(_MAPPING, "led_brightness", brightness.value) @command( click.argument("led", type=bool), @@ -374,7 +374,7 @@ def set_led_brightness(self, brightness: LedBrightness): ) def set_led(self, led: bool): """Turn led on/off.""" - return self.set_property("led", led) + return self.set_property(_MAPPING, "led", led) @command( click.argument("buzzer", type=bool), @@ -384,7 +384,7 @@ def set_led(self, led: bool): ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" - return self.set_property("buzzer", buzzer) + return self.set_property(_MAPPING, "buzzer", buzzer) @command( click.argument("lock", type=bool), @@ -394,4 +394,4 @@ def set_buzzer(self, buzzer: bool): ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" - return self.set_property("child_lock", lock) + return self.set_property(_MAPPING, "child_lock", lock) diff --git a/miio/miot_device.py b/miio/miot_device.py index d62cd52fe..d12bfa151 100644 --- a/miio/miot_device.py +++ b/miio/miot_device.py @@ -78,10 +78,10 @@ def get_properties_for_mapping( return self.get_properties(properties, max_properties=max_properties) - def set_property(self, property_key: str, value): + def set_property(self, property_mapping, property_key: str, value): """Sets property value.""" return self.send( "set_properties", - [{"did": property_key, **self.mapping[property_key], "value": value}], + [{"did": property_key, **property_mapping[property_key], "value": value}], ) From 63d6dc0ceb878195f2e5e5bd069b187b21509e29 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 18 Apr 2020 20:13:24 +0200 Subject: [PATCH 4/5] Add more helpers to miotdevice * rename old set_property to set_property_from_mapping * add set_properties_from_dataclass (allows passing a mapping object with all wanted values at once) ``` device.set_properties_from_dataclass(GosundPlugStatus(state=True, some_other_property=123) ``` * new set_property helper which takes kwargs that are used automatically with the help of the class-given _MAPPING: ``` device.set_property(state=True) ``` --- miio/airpurifier_miot.py | 24 ++++++++++--------- miio/miot_device.py | 50 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index 270f15083..f4027b595 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -294,12 +294,12 @@ def status(self) -> AirPurifierMiotStatus: @command(default_output=format_output("Powering on")) def on(self): """Power on.""" - return self.set_property(_MAPPING, "power", True) + return self.set_property_from_mapping(_MAPPING, "power", True) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" - return self.set_property(_MAPPING, "power", False) + return self.set_property_from_mapping(_MAPPING, "power", False) @command( click.argument("level", type=int), @@ -309,7 +309,7 @@ def set_fan_level(self, level: int): """Set fan level.""" if level < 1 or level > 3: raise AirPurifierMiotException("Invalid fan level: %s" % level) - return self.set_property(_MAPPING, "fan_level", level) + return self.set_property_from_mapping(_MAPPING, "fan_level", level) @command( click.argument("rpm", type=int), @@ -323,7 +323,7 @@ def set_favorite_rpm(self, rpm: int): "Invalid favorite motor speed: %s. Must be between 300 and 2300 and divisible by 10" % rpm ) - return self.set_property(_MAPPING, "favorite_rpm", rpm) + return self.set_property_from_mapping(_MAPPING, "favorite_rpm", rpm) @command( click.argument("volume", type=int), @@ -335,7 +335,7 @@ def set_volume(self, volume: int): raise AirPurifierMiotException( "Invalid volume: %s. Must be between 0 and 100" % volume ) - return self.set_property(_MAPPING, "buzzer_volume", volume) + return self.set_property_from_mapping(_MAPPING, "buzzer_volume", volume) @command( click.argument("mode", type=EnumType(OperationMode, False)), @@ -343,7 +343,7 @@ def set_volume(self, volume: int): ) def set_mode(self, mode: OperationMode): """Set mode.""" - return self.set_property(_MAPPING, "mode", mode.value) + return self.set_property_from_mapping(_MAPPING, "mode", mode.value) @command( click.argument("level", type=int), @@ -356,7 +356,7 @@ def set_favorite_level(self, level: int): if level < 0 or level > 14: raise AirPurifierMiotException("Invalid favorite level: %s" % level) - return self.set_property(_MAPPING, "favorite_level", level) + return self.set_property_from_mapping(_MAPPING, "favorite_level", level) @command( click.argument("brightness", type=EnumType(LedBrightness, False)), @@ -364,7 +364,9 @@ def set_favorite_level(self, level: int): ) def set_led_brightness(self, brightness: LedBrightness): """Set led brightness.""" - return self.set_property(_MAPPING, "led_brightness", brightness.value) + return self.set_property_from_mapping( + _MAPPING, "led_brightness", brightness.value + ) @command( click.argument("led", type=bool), @@ -374,7 +376,7 @@ def set_led_brightness(self, brightness: LedBrightness): ) def set_led(self, led: bool): """Turn led on/off.""" - return self.set_property(_MAPPING, "led", led) + return self.set_property_from_mapping(_MAPPING, "led", led) @command( click.argument("buzzer", type=bool), @@ -384,7 +386,7 @@ def set_led(self, led: bool): ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" - return self.set_property(_MAPPING, "buzzer", buzzer) + return self.set_property_from_mapping(_MAPPING, "buzzer", buzzer) @command( click.argument("lock", type=bool), @@ -394,4 +396,4 @@ def set_buzzer(self, buzzer: bool): ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" - return self.set_property(_MAPPING, "child_lock", lock) + return self.set_property_from_mapping(_MAPPING, "child_lock", lock) diff --git a/miio/miot_device.py b/miio/miot_device.py index d12bfa151..783fabfe5 100644 --- a/miio/miot_device.py +++ b/miio/miot_device.py @@ -68,6 +68,54 @@ def get_properties_for_dataclass(self, cls): return cls(**response) + def set_property(self, **kwargs): + """Helper to set properties using the device specific mapping.""" + if getattr(self, "_MAPPING") is None: + raise DeviceException("Device class does not have _MAPPING") + + return self.set_properties_from_dataclass(self._MAPPING(**kwargs)) + + def set_properties_from_dataclass(self, obj): + """Set properties as defined in the given dataclass object.""" + # TODO: this needs cleanup. + fields = obj.__dataclass_fields__ + properties_to_set = [] + + for field_name in fields: + field_meta = fields[field_name].metadata + + if "piid" not in field_meta: + continue + + piid = field_meta["piid"] + + siid = field_meta.get("siid", getattr(obj, "_siid", None)) + if siid is None: + raise DeviceException( + f"no siid defined for {field_name} or for class {obj.__class__}" + ) + + value = obj.__getattribute__(field_name) + if value is None: + continue + + property_set = { + "siid": siid, + "piid": piid, + "did": field_name, + "value": value, + } + + properties_to_set.append(property_set) + + if not properties_to_set: + raise DeviceException("No values to set!") + + # TODO: handle splitting based on _max_properties + + _LOGGER.debug("Going to set %s" % properties_to_set) + return self.send("set_properties", properties_to_set) + def get_properties_for_mapping( self, property_mapping, *, max_properties=15 ) -> list: @@ -78,7 +126,7 @@ def get_properties_for_mapping( return self.get_properties(properties, max_properties=max_properties) - def set_property(self, property_mapping, property_key: str, value): + def set_property_from_mapping(self, property_mapping, property_key: str, value): """Sets property value.""" return self.send( From 6e40d90ccfee217359a7e3e965eafebc67943045 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 18 Apr 2020 20:14:39 +0200 Subject: [PATCH 5/5] Add support for gosund miot plug (cuco.plug.cp1) this is just a poc to show how the new api could function --- miio/__init__.py | 1 + miio/gosund_plug.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 miio/gosund_plug.py diff --git a/miio/__init__.py b/miio/__init__.py index 6ef91b254..004887a6e 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -22,6 +22,7 @@ from miio.exceptions import DeviceError, DeviceException from miio.fan import Fan, FanP5, FanSA1, FanV2, FanZA1, FanZA4 from miio.gateway import Gateway +from miio.gosund_plug import GosundPlug from miio.heater import Heater from miio.philips_bulb import PhilipsBulb, PhilipsWhiteBulb from miio.philips_eyecare import PhilipsEyecare diff --git a/miio/gosund_plug.py b/miio/gosund_plug.py new file mode 100644 index 000000000..c2fb8fc1b --- /dev/null +++ b/miio/gosund_plug.py @@ -0,0 +1,35 @@ +import logging +from dataclasses import dataclass, field + +from .click_common import command, format_output +from .miot_device import MiotDevice + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class GosundPlugStatus: + _max_properties = 1 + state: bool = field(metadata={"siid": 2, "piid": 1}) + + +class GosundPlug(MiotDevice): + """Support for gosund miot plug (cuco.plug.cp1).""" + + _MAPPING = GosundPlugStatus + + @command() + def status(self) -> GosundPlugStatus: + """Return current state of the plug.""" + + return self.get_properties_for_dataclass(GosundPlugStatus) + + @command(default_output=format_output("Powering on")) + def on(self): + """Power on.""" + return self.set_property(state=True) + + @command(default_output=format_output("Powering off")) + def off(self): + """Power off.""" + return self.set_property(state=False)