From 7e6c577da352c78818a0b54f4befd09815fbdc80 Mon Sep 17 00:00:00 2001 From: Petr Kotek Date: Wed, 27 Nov 2019 20:53:53 +1100 Subject: [PATCH 01/14] Add basic support for Xiaomi Mi Air Purifier 3/3H. In order to support that, also implement MiotDevice class with basic support for MIoT protocol. --- miio/__init__.py | 1 + miio/airpurifier_miot.py | 405 +++++++++++++++++++++++++++++++++++++++ miio/discovery.py | 3 + miio/miot_device.py | 53 +++++ 4 files changed, 462 insertions(+) create mode 100644 miio/airpurifier_miot.py create mode 100644 miio/miot_device.py diff --git a/miio/__init__.py b/miio/__init__.py index b3cb19f8b..17bfd870f 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -9,6 +9,7 @@ from miio.airhumidifier import AirHumidifier, AirHumidifierCA1, AirHumidifierCB1 from miio.airhumidifier_mjjsq import AirHumidifierMjjsq from miio.airpurifier import AirPurifier +from miio.airpurifier_miot import AirPurifierMiot from miio.airqualitymonitor import AirQualityMonitor from miio.aqaracamera import AqaraCamera from miio.ceil import Ceil diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py new file mode 100644 index 000000000..bf4c28b7e --- /dev/null +++ b/miio/airpurifier_miot.py @@ -0,0 +1,405 @@ +import enum +import logging +import re +from typing import Any, Dict, Optional + +import click + +from .click_common import EnumType, command, format_output +from .device import DeviceException +from .miot_device import MiotDevice + +_LOGGER = logging.getLogger(__name__) + + +class AirPurifierMiotException(DeviceException): + pass + + +class OperationMode(enum.Enum): + Auto = 0 + Silent = 1 + Favorite = 2 + Fan = 3 + + +class LedBrightness(enum.Enum): + Bright = 0 + Dim = 1 + Off = 2 + + +class FilterType(enum.Enum): + Regular = "regular" + AntiBacterial = "anti-bacterial" + AntiFormaldehyde = "anti-formaldehyde" + Unknown = "unknown" + + +FILTER_TYPE_RE = ( + (re.compile(r"^\d+:\d+:41:30$"), FilterType.AntiBacterial), + (re.compile(r"^\d+:\d+:(30|0|00):31$"), FilterType.AntiFormaldehyde), + (re.compile(r".*"), FilterType.Regular), +) + + +class AirPurifierMiotStatus: + """Container for status reports from the air purifier.""" + + _filter_type_cache = {} + + def __init__(self, data: Dict[str, Any]) -> None: + self.data = data + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.data["power"] + + @property + def power(self) -> str: + """Power state.""" + return "on" if self.is_on else "off" + + @property + def aqi(self) -> int: + """Air quality index.""" + return self.data["aqi"] + + @property + def average_aqi(self) -> int: + """Average of the air quality index.""" + return self.data["average_aqi"] + + @property + def humidity(self) -> int: + """Current humidity.""" + return self.data["humidity"] + + @property + def temperature(self) -> Optional[float]: + """Current temperature, if available.""" + if self.data["temperature"] is not None: + return self.data["temperature"] + + return None + + @property + def fan_level(self) -> int: + """Current fan level.""" + return self.data["fan_level"] + + @property + def mode(self) -> OperationMode: + """Current operation mode.""" + return OperationMode(self.data["mode"]) + + @property + def led(self) -> bool: + """Return True if LED is on.""" + return self.data["led"] + + @property + def led_brightness(self) -> Optional[LedBrightness]: + """Brightness of the LED.""" + if self.data["led_brightness"] is not None: + try: + return LedBrightness(self.data["led_brightness"]) + except ValueError: + return None + + return None + + @property + def buzzer(self) -> Optional[bool]: + """Return True if buzzer is on.""" + if self.data["buzzer"] is not None: + return self.data["buzzer"] + + return None + + @property + def child_lock(self) -> bool: + """Return True if child lock is on.""" + return self.data["child_lock"] + + @property + def favorite_level(self) -> int: + """Return favorite level, which is used if the mode is ``favorite``.""" + # Favorite level used when the mode is `favorite`. + return self.data["favorite_level"] + + @property + def filter_life_remaining(self) -> int: + """Time until the filter should be changed.""" + return self.data["filter_life_remaining"] + + @property + def filter_hours_used(self) -> int: + """How long the filter has been in use.""" + return self.data["filter_hours_used"] + + @property + def use_time(self) -> int: + """How long the device has been active in seconds.""" + return self.data["use_time"] + + @property + def purify_volume(self) -> int: + """The volume of purified air in cubic meter.""" + return self.data["purify_volume"] + + @property + def motor_speed(self) -> int: + """Speed of the motor.""" + return self.data["motor_speed"] + + @property + def filter_rfid_product_id(self) -> Optional[str]: + """RFID product ID of installed filter.""" + return self.data["filter_rfid_product_id"] + + @property + def filter_rfid_tag(self) -> Optional[str]: + """RFID tag ID of installed filter.""" + return self.data["filter_rfid_tag"] + + @property + def filter_type(self) -> Optional[FilterType]: + """Type of installed filter.""" + if self.filter_rfid_tag is None: + return None + if self.filter_rfid_tag == "0:0:0:0:0:0:0": + return FilterType.Unknown + if self.filter_rfid_product_id is None: + return FilterType.Regular + return self._get_filter_type(self.filter_rfid_product_id) + + @property + def button_pressed(self) -> Optional[str]: + """Last pressed button.""" + return self.data["button_pressed"] + + @classmethod + def _get_filter_type(cls, product_id: str) -> FilterType: + ft = cls._filter_type_cache.get(product_id, None) + if ft is None: + for filter_re, filter_type in FILTER_TYPE_RE: + if filter_re.match(product_id): + ft = cls._filter_type_cache[product_id] = filter_type + break + return ft + + def __repr__(self) -> str: + s = ( + "" + % ( + self.power, + self.aqi, + self.average_aqi, + self.temperature, + self.humidity, + self.fan_level, + self.mode, + self.led, + self.led_brightness, + self.buzzer, + self.child_lock, + self.favorite_level, + self.filter_life_remaining, + self.filter_hours_used, + self.use_time, + self.purify_volume, + self.motor_speed, + self.filter_rfid_product_id, + self.filter_rfid_tag, + self.filter_type, + ) + ) + return s + + def __json__(self): + return self.data + + +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__( + { + # Air Purifier (siid=2) + "power": {"siid": 2, "piid": 2}, + "fan_level": {"siid": 2, "piid": 4}, + "mode": {"siid": 2, "piid": 5}, + # Environment (siid=3) + "humidity": {"siid": 3, "piid": 7}, + "temperature": {"siid": 3, "piid": 8}, + "aqi": {"siid": 3, "piid": 6}, + # Filter (siid=4) + "filter_life_remaining": {"siid": 4, "piid": 3}, + "filter_hours_used": {"siid": 4, "piid": 5}, + # Alarm (siid=5) + "buzzer": {"siid": 5, "piid": 1}, + # Indicator Light (siid=6) + "led_brightness": {"siid": 6, "piid": 1}, + "led": {"siid": 6, "piid": 6}, + # Physical Control Locked (siid=7) + "child_lock": {"siid": 7, "piid": 1}, + # Motor Speed (siid=10) + "favorite_level": {"siid": 10, "piid": 10}, + "motor_speed": {"siid": 10, "piid": 8}, + # Use time (siid=12) + "use_time": {"siid": 12, "piid": 1}, + # AQI (siid=13) + "purify_volume": {"siid": 13, "piid": 1}, + "average_aqi": {"siid": 13, "piid": 2}, + # RFID (siid=14) + "filter_rfid_tag": {"siid": 14, "piid": 1}, + "filter_rfid_product_id": {"siid": 14, "piid": 3}, + # Other (siid=15) + "app_extra": {"siid": 15, "piid": 1}, + }, + ip, + token, + start_id, + debug, + lazy_discover, + ) + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "AQI: {result.aqi} μg/m³\n" + "Average AQI: {result.average_aqi} μg/m³\n" + "Humidity: {result.humidity} %\n" + "Temperature: {result.temperature} °C\n" + "Fan Level: {result.fan_level}\n" + "Mode: {result.mode}\n" + "LED: {result.led}\n" + "LED brightness: {result.led_brightness}\n" + "Buzzer: {result.buzzer}\n" + "Child lock: {result.child_lock}\n" + "Favorite level: {result.favorite_level}\n" + "Filter life remaining: {result.filter_life_remaining} %\n" + "Filter hours used: {result.filter_hours_used}\n" + "Use time: {result.use_time} s\n" + "Purify volume: {result.purify_volume} m³\n" + "Motor speed: {result.motor_speed} rpm\n" + "Filter RFID product id: {result.filter_rfid_product_id}\n" + "Filter RFID tag: {result.filter_rfid_tag}\n" + "Filter type: {result.filter_type}\n", + ) + ) + def status(self) -> AirPurifierMiotStatus: + """Retrieve properties.""" + + return AirPurifierMiotStatus( + {prop["did"]: prop["value"] for prop in self.get_properties()} + ) + + @command(default_output=format_output("Powering on")) + def on(self): + """Power on.""" + return self.set_property("power", True) + + @command(default_output=format_output("Powering off")) + def off(self): + """Power off.""" + return self.set_property("power", False) + + @command( + click.argument("level", type=int), + default_output=format_output("Setting fan level to '{level}'"), + ) + 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) + + @command( + click.argument("mode", type=EnumType(OperationMode, False)), + default_output=format_output("Setting mode to '{mode.value}'"), + ) + def set_mode(self, mode: OperationMode): + """Set mode.""" + return self.set_property("mode", mode.value) + + @command( + click.argument("level", type=int), + default_output=format_output("Setting favorite level to {level}"), + ) + def set_favorite_level(self, level: int): + """Set favorite level.""" + if level < 0 or level > 14: + raise AirPurifierMiotException("Invalid favorite level: %s" % level) + + # Set the favorite level used when the mode is `favorite`, + # should be between 0 and 14. + return self.set_property("favorite_level", level) + + @command( + click.argument("brightness", type=EnumType(LedBrightness, False)), + default_output=format_output("Setting LED brightness to {brightness}"), + ) + def set_led_brightness(self, brightness: LedBrightness): + """Set led brightness.""" + return self.set_property("led_brightness", brightness.value) + + @command( + click.argument("led", type=bool), + default_output=format_output( + lambda led: "Turning on LED" if led else "Turning off LED" + ), + ) + def set_led(self, led: bool): + """Turn led on/off.""" + return self.set_property("led", led) + + @command( + click.argument("buzzer", type=bool), + default_output=format_output( + lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" + ), + ) + def set_buzzer(self, buzzer: bool): + """Set buzzer on/off.""" + return self.set_property("buzzer", buzzer) + + @command( + click.argument("lock", type=bool), + default_output=format_output( + lambda lock: "Turning on child lock" if lock else "Turning off child lock" + ), + ) + def set_child_lock(self, lock: bool): + """Set child lock on/off.""" + return self.set_property("child_lock", lock) diff --git a/miio/discovery.py b/miio/discovery.py index 22d0f7956..2ca05a74c 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -14,6 +14,7 @@ AirHumidifier, AirHumidifierMjjsq, AirPurifier, + AirPurifierMiot, AirQualityMonitor, AqaraCamera, Ceil, @@ -108,6 +109,8 @@ "zhimi-airpurifier-v6": AirPurifier, # v6 "zhimi-airpurifier-v7": AirPurifier, # v7 "zhimi-airpurifier-mc1": AirPurifier, # mc1 + "zhimi-airpurifier-mb3": AirPurifierMiot, # mb3 (3/3H) + "zhimi-airpurifier-ma4": AirPurifierMiot, # ma4 (3) "chuangmi.camera.ipc009": ChuangmiCamera, "chuangmi-ir-v2": ChuangmiIr, "chuangmi-remote-h102a03_": ChuangmiIr, diff --git a/miio/miot_device.py b/miio/miot_device.py new file mode 100644 index 000000000..2e41edd79 --- /dev/null +++ b/miio/miot_device.py @@ -0,0 +1,53 @@ +import logging + +from .device import Device + +_LOGGER = logging.getLogger(__name__) + + +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(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()] + + # A single request is limited to 16 properties. Therefore the + # properties are divided into multiple requests + _props = properties.copy() + values = [] + while _props: + values.extend(self.send("get_properties", _props[:15])) + _props[:] = _props[15:] + + properties_count = len(properties) + values_count = len(values) + if properties_count != values_count: + _LOGGER.debug( + "Count (%s) of requested properties does not match the " + "count (%s) of received values.", + properties_count, + values_count, + ) + + return values + + def set_property(self, property_key: str, value): + return self.send( + "set_properties", + [{"did": property_key, **self.mapping[property_key], "value": value}], + ) From 49101c34106983f33316a1bfa465b8ed5274b67d Mon Sep 17 00:00:00 2001 From: Petr Kotek Date: Mon, 2 Dec 2019 23:22:21 +1100 Subject: [PATCH 02/14] Extract functionality for determining Xiaomi air filter type into a separate util class --- miio/airfilter.py | 36 ++++++++++++++++++++++++++++++++++++ miio/airpurifier.py | 29 +++-------------------------- miio/airpurifier_miot.py | 31 +++---------------------------- 3 files changed, 42 insertions(+), 54 deletions(-) create mode 100644 miio/airfilter.py diff --git a/miio/airfilter.py b/miio/airfilter.py new file mode 100644 index 000000000..bd02c9331 --- /dev/null +++ b/miio/airfilter.py @@ -0,0 +1,36 @@ +import enum +import re + + +class FilterType(enum.Enum): + Regular = "regular" + AntiBacterial = "anti-bacterial" + AntiFormaldehyde = "anti-formaldehyde" + Unknown = "unknown" + + +FILTER_TYPE_RE = ( + (re.compile(r"^\d+:\d+:41:30$"), FilterType.AntiBacterial), + (re.compile(r"^\d+:\d+:(30|0|00):31$"), FilterType.AntiFormaldehyde), + (re.compile(r".*"), FilterType.Regular), +) + + +class FilterTypeUtil: + """Utility class for determining xiaomi air filter type.""" + + _filter_type_cache = {} + + def determine_filter_type(self, product_id: str) -> FilterType: + """ + Determine Xiaomi air filter type based on its product ID. + + :param product_id: Product ID such as "0:0:30:33" + """ + ft = self._filter_type_cache.get(product_id, None) + if ft is None: + for filter_re, filter_type in FILTER_TYPE_RE: + if filter_re.match(product_id): + ft = self._filter_type_cache[product_id] = filter_type + break + return ft diff --git a/miio/airpurifier.py b/miio/airpurifier.py index ed36992e3..599de7686 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -1,11 +1,11 @@ import enum import logging -import re from collections import defaultdict from typing import Any, Dict, Optional import click +from .airfilter import FilterType, FilterTypeUtil from .click_common import EnumType, command, format_output from .device import Device from .exceptions import DeviceException @@ -42,20 +42,6 @@ class LedBrightness(enum.Enum): Off = 2 -class FilterType(enum.Enum): - Regular = "regular" - AntiBacterial = "anti-bacterial" - AntiFormaldehyde = "anti-formaldehyde" - Unknown = "unknown" - - -FILTER_TYPE_RE = ( - (re.compile(r"^\d+:\d+:41:30$"), FilterType.AntiBacterial), - (re.compile(r"^\d+:\d+:(30|0|00):31$"), FilterType.AntiFormaldehyde), - (re.compile(r".*"), FilterType.Regular), -) - - class AirPurifierStatus: """Container for status reports from the air purifier.""" @@ -102,6 +88,7 @@ def __init__(self, data: Dict[str, Any]) -> None: A request is limited to 16 properties. """ + self.filter_type_util = FilterTypeUtil() self.data = data @property @@ -245,7 +232,7 @@ def filter_type(self) -> Optional[FilterType]: return FilterType.Unknown if self.filter_rfid_product_id is None: return FilterType.Regular - return self._get_filter_type(self.filter_rfid_product_id) + return self.filter_type_util.determine_filter_type(self.filter_rfid_product_id) @property def learn_mode(self) -> bool: @@ -284,16 +271,6 @@ def button_pressed(self) -> Optional[str]: """Last pressed button.""" return self.data["button_pressed"] - @classmethod - def _get_filter_type(cls, product_id: str) -> FilterType: - ft = cls._filter_type_cache.get(product_id, None) - if ft is None: - for filter_re, filter_type in FILTER_TYPE_RE: - if filter_re.match(product_id): - ft = cls._filter_type_cache[product_id] = filter_type - break - return ft - def __repr__(self) -> str: s = ( " None: + self.filter_type_util = FilterTypeUtil() self.data = data @property @@ -173,23 +158,13 @@ def filter_type(self) -> Optional[FilterType]: return FilterType.Unknown if self.filter_rfid_product_id is None: return FilterType.Regular - return self._get_filter_type(self.filter_rfid_product_id) + return self.filter_type_util.determine_filter_type(self.filter_rfid_product_id) @property def button_pressed(self) -> Optional[str]: """Last pressed button.""" return self.data["button_pressed"] - @classmethod - def _get_filter_type(cls, product_id: str) -> FilterType: - ft = cls._filter_type_cache.get(product_id, None) - if ft is None: - for filter_re, filter_type in FILTER_TYPE_RE: - if filter_re.match(product_id): - ft = cls._filter_type_cache[product_id] = filter_type - break - return ft - def __repr__(self) -> str: s = ( " Date: Tue, 3 Dec 2019 22:50:37 +1100 Subject: [PATCH 03/14] Rename airfilter.py to airfilter_util.py to indicate it's not referring to an actual device --- miio/{airfilter.py => airfilter_util.py} | 0 miio/airpurifier.py | 2 +- miio/airpurifier_miot.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename miio/{airfilter.py => airfilter_util.py} (100%) diff --git a/miio/airfilter.py b/miio/airfilter_util.py similarity index 100% rename from miio/airfilter.py rename to miio/airfilter_util.py diff --git a/miio/airpurifier.py b/miio/airpurifier.py index 599de7686..aa3ca5430 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -5,7 +5,7 @@ import click -from .airfilter import FilterType, FilterTypeUtil +from .airfilter_util import FilterType, FilterTypeUtil from .click_common import EnumType, command, format_output from .device import Device from .exceptions import DeviceException diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index 221a0ba3e..f81eadd98 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -4,7 +4,7 @@ import click -from .airfilter import FilterType, FilterTypeUtil +from .airfilter_util import FilterType, FilterTypeUtil from .click_common import EnumType, command, format_output from .device import DeviceException from .miot_device import MiotDevice From be5878a2674c68197575c38c213f3aa70fc05bcd Mon Sep 17 00:00:00 2001 From: Petr Kotek Date: Thu, 5 Dec 2019 01:02:50 +1100 Subject: [PATCH 04/14] Tests for miot purifier # Conflicts: # miio/ceil_cli.py # miio/device.py # miio/philips_eyecare_cli.py # miio/plug_cli.py # miio/tests/dummies.py # miio/tests/test_airconditioningcompanion.py # miio/tests/test_wifirepeater.py # miio/vacuum.py # miio/vacuum_cli.py --- miio/airpurifier_miot.py | 85 ++++++------ miio/tests/dummies.py | 18 +++ miio/tests/test_airfilter_util.py | 38 ++++++ miio/tests/test_airpurifier_miot.py | 194 ++++++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 43 deletions(-) create mode 100644 miio/tests/test_airfilter_util.py create mode 100644 miio/tests/test_airpurifier_miot.py diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index f81eadd98..8ead90f85 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -6,10 +6,45 @@ from .airfilter_util import FilterType, FilterTypeUtil from .click_common import EnumType, command, format_output -from .device import DeviceException +from .exceptions import DeviceException from .miot_device import MiotDevice _LOGGER = logging.getLogger(__name__) +_MAPPING = { + # Air Purifier (siid=2) + "power": {"siid": 2, "piid": 2}, + "fan_level": {"siid": 2, "piid": 4}, + "mode": {"siid": 2, "piid": 5}, + # Environment (siid=3) + "humidity": {"siid": 3, "piid": 7}, + "temperature": {"siid": 3, "piid": 8}, + "aqi": {"siid": 3, "piid": 6}, + # Filter (siid=4) + "filter_life_remaining": {"siid": 4, "piid": 3}, + "filter_hours_used": {"siid": 4, "piid": 5}, + # Alarm (siid=5) + "buzzer": {"siid": 5, "piid": 1}, + # Indicator Light (siid=6) + "led_brightness": {"siid": 6, "piid": 1}, + "led": {"siid": 6, "piid": 6}, + # Physical Control Locked (siid=7) + "child_lock": {"siid": 7, "piid": 1}, + # Button (siid=8) + "button_pressed": {"siid": 8, "piid": 1}, + # Motor Speed (siid=10) + "favorite_level": {"siid": 10, "piid": 10}, + "motor_speed": {"siid": 10, "piid": 8}, + # Use time (siid=12) + "use_time": {"siid": 12, "piid": 1}, + # AQI (siid=13) + "purify_volume": {"siid": 13, "piid": 1}, + "average_aqi": {"siid": 13, "piid": 2}, + # RFID (siid=14) + "filter_rfid_tag": {"siid": 14, "piid": 1}, + "filter_rfid_product_id": {"siid": 14, "piid": 3}, + # Other (siid=15) + "app_extra": {"siid": 15, "piid": 1}, +} class AirPurifierMiotException(DeviceException): @@ -186,7 +221,8 @@ def __repr__(self) -> str: "motor_speed=%s, " "filter_rfid_product_id=%s, " "filter_rfid_tag=%s, " - "filter_type=%s>" + "filter_type=%s, " + "button_pressed=%s>" % ( self.power, self.aqi, @@ -208,6 +244,7 @@ def __repr__(self) -> str: self.filter_rfid_product_id, self.filter_rfid_tag, self.filter_type, + self.button_pressed, ) ) return s @@ -227,46 +264,7 @@ def __init__( debug: int = 0, lazy_discover: bool = True, ) -> None: - super().__init__( - { - # Air Purifier (siid=2) - "power": {"siid": 2, "piid": 2}, - "fan_level": {"siid": 2, "piid": 4}, - "mode": {"siid": 2, "piid": 5}, - # Environment (siid=3) - "humidity": {"siid": 3, "piid": 7}, - "temperature": {"siid": 3, "piid": 8}, - "aqi": {"siid": 3, "piid": 6}, - # Filter (siid=4) - "filter_life_remaining": {"siid": 4, "piid": 3}, - "filter_hours_used": {"siid": 4, "piid": 5}, - # Alarm (siid=5) - "buzzer": {"siid": 5, "piid": 1}, - # Indicator Light (siid=6) - "led_brightness": {"siid": 6, "piid": 1}, - "led": {"siid": 6, "piid": 6}, - # Physical Control Locked (siid=7) - "child_lock": {"siid": 7, "piid": 1}, - # Motor Speed (siid=10) - "favorite_level": {"siid": 10, "piid": 10}, - "motor_speed": {"siid": 10, "piid": 8}, - # Use time (siid=12) - "use_time": {"siid": 12, "piid": 1}, - # AQI (siid=13) - "purify_volume": {"siid": 13, "piid": 1}, - "average_aqi": {"siid": 13, "piid": 2}, - # RFID (siid=14) - "filter_rfid_tag": {"siid": 14, "piid": 1}, - "filter_rfid_product_id": {"siid": 14, "piid": 3}, - # Other (siid=15) - "app_extra": {"siid": 15, "piid": 1}, - }, - ip, - token, - start_id, - debug, - lazy_discover, - ) + super().__init__(_MAPPING, ip, token, start_id, debug, lazy_discover) @command( default_output=format_output( @@ -290,7 +288,8 @@ def __init__( "Motor speed: {result.motor_speed} rpm\n" "Filter RFID product id: {result.filter_rfid_product_id}\n" "Filter RFID tag: {result.filter_rfid_tag}\n" - "Filter type: {result.filter_type}\n", + "Filter type: {result.filter_type}\n" + "Last button pressed: {result.button_pressed}\n", ) ) def status(self) -> AirPurifierMiotStatus: diff --git a/miio/tests/dummies.py b/miio/tests/dummies.py index fe4a013dc..c488c8cd0 100644 --- a/miio/tests/dummies.py +++ b/miio/tests/dummies.py @@ -53,3 +53,21 @@ def _set_state(self, var, value): def _get_state(self, props): """Return wanted properties""" return [self.state[x] for x in props if x in self.state] + + +class DummyMiotDevice(DummyDevice): + """Main class representing a MIoT device.""" + + def __init__(self, *args, **kwargs): + # {prop["did"]: prop["value"] for prop in self.miot_client.get_properties()} + self.state = [{"did": k, "value": v} for k, v in self.state.items()] + super().__init__(*args, **kwargs) + + def get_properties(self): + return self.state + + def set_property(self, property_key: str, value): + for prop in self.state: + if prop["did"] == property_key: + prop["value"] = value + return None diff --git a/miio/tests/test_airfilter_util.py b/miio/tests/test_airfilter_util.py new file mode 100644 index 000000000..a36e7879a --- /dev/null +++ b/miio/tests/test_airfilter_util.py @@ -0,0 +1,38 @@ +from unittest import TestCase + +import pytest + +from miio.airfilter_util import FilterType, FilterTypeUtil + + +@pytest.fixture(scope="class") +def airfilter_util(request): + request.cls.filter_type_util = FilterTypeUtil() + + +@pytest.mark.usefixtures("airfilter_util") +class TestAirFilterUtil(TestCase): + def test_determine_filter_type__recognises_antibacterial_filter(self): + assert ( + self.filter_type_util.determine_filter_type("12:34:41:30") + is FilterType.AntiBacterial + ) + + def test_determine_filter_type__recognises_antiformaldehyde_filter(self): + assert ( + self.filter_type_util.determine_filter_type("12:34:00:31") + is FilterType.AntiFormaldehyde + ) + + def test_determine_filter_type__falls_back_to_regular_filter(self): + regular_filters = [ + "12:34:56:78", + "12:34:56:31", + "12:34:56:31:11:11", + "CO:FF:FF:EE", + ] + for product_id in regular_filters: + assert ( + self.filter_type_util.determine_filter_type(product_id) + is FilterType.Regular + ) diff --git a/miio/tests/test_airpurifier_miot.py b/miio/tests/test_airpurifier_miot.py new file mode 100644 index 000000000..01a5ea45e --- /dev/null +++ b/miio/tests/test_airpurifier_miot.py @@ -0,0 +1,194 @@ +from unittest import TestCase + +import pytest + +from miio import AirPurifierMiot +from miio.airfilter_util import FilterType +from miio.airpurifier_miot import AirPurifierMiotException, LedBrightness, OperationMode + +from .dummies import DummyDevice, DummyMiotDevice + +_INITIAL_STATE = { + "power": True, + "aqi": 10, + "average_aqi": 8, + "humidity": 62, + "temperature": 18.6, + "fan_level": 2, + "mode": 0, + "led": True, + "led_brightness": 1, + "buzzer": False, + "child_lock": False, + "favorite_level": 10, + "filter_life_remaining": 80, + "filter_hours_used": 682, + "use_time": 2457000, + "purify_volume": 25262, + "motor_speed": 354, + "filter_rfid_product_id": "0:0:41:30", + "filter_rfid_tag": "10:20:30:40:50:60:7", + "button_pressed": "power", +} + + +class DummyAirPurifierMiot(DummyMiotDevice, AirPurifierMiot): + def __init__(self, *args, **kwargs): + self.state = _INITIAL_STATE + self.return_values = { + "get_prop": self._get_state, + "set_power": lambda x: self._set_state("power", x), + "set_mode": lambda x: self._set_state("mode", x), + "set_led": lambda x: self._set_state("led", x), + "set_buzzer": lambda x: self._set_state("buzzer", x), + "set_child_lock": lambda x: self._set_state("child_lock", x), + "set_level_favorite": lambda x: self._set_state("favorite_level", x), + "set_led_b": lambda x: self._set_state("led_b", x), + "set_volume": lambda x: self._set_state("volume", x), + "set_act_sleep": lambda x: self._set_state("act_sleep", x), + "reset_filter1": lambda x: ( + self._set_state("f1_hour_used", [0]), + self._set_state("filter1_life", [100]), + ), + "set_act_det": lambda x: self._set_state("act_det", x), + "set_app_extra": lambda x: self._set_state("app_extra", x), + } + super().__init__(*args, **kwargs) + + +@pytest.fixture(scope="function") +def airpurifier(request): + request.cls.device = DummyAirPurifierMiot() + + +@pytest.mark.usefixtures("airpurifier") +class TestAirPurifier(TestCase): + def test_on(self): + self.device.off() # ensure off + assert self.device.status().is_on is False + + self.device.on() + assert self.device.status().is_on is True + + def test_off(self): + self.device.on() # ensure on + assert self.device.status().is_on is True + + self.device.off() + assert self.device.status().is_on is False + + def test_status(self): + status = self.device.status() + assert status.is_on is _INITIAL_STATE["power"] + assert status.aqi == _INITIAL_STATE["aqi"] + assert status.average_aqi == _INITIAL_STATE["average_aqi"] + assert status.humidity == _INITIAL_STATE["humidity"] + assert status.temperature == _INITIAL_STATE["temperature"] + assert status.fan_level == _INITIAL_STATE["fan_level"] + assert status.mode == OperationMode(_INITIAL_STATE["mode"]) + assert status.led == _INITIAL_STATE["led"] + assert status.led_brightness == LedBrightness(_INITIAL_STATE["led_brightness"]) + assert status.buzzer == _INITIAL_STATE["buzzer"] + assert status.child_lock == _INITIAL_STATE["child_lock"] + assert status.favorite_level == _INITIAL_STATE["favorite_level"] + assert status.filter_life_remaining == _INITIAL_STATE["filter_life_remaining"] + assert status.filter_hours_used == _INITIAL_STATE["filter_hours_used"] + assert status.use_time == _INITIAL_STATE["use_time"] + assert status.purify_volume == _INITIAL_STATE["purify_volume"] + assert status.motor_speed == _INITIAL_STATE["motor_speed"] + assert status.filter_rfid_product_id == _INITIAL_STATE["filter_rfid_product_id"] + assert status.filter_type == FilterType.AntiBacterial + assert status.button_pressed == _INITIAL_STATE["button_pressed"] + + def test_set_fan_level(self): + def fan_level(): + return self.device.status().fan_level + + self.device.set_fan_level(1) + assert fan_level() == 1 + self.device.set_fan_level(2) + assert fan_level() == 2 + self.device.set_fan_level(3) + assert fan_level() == 3 + + with pytest.raises(AirPurifierMiotException): + self.device.set_fan_level(0) + + with pytest.raises(AirPurifierMiotException): + self.device.set_fan_level(4) + + def test_set_mode(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationMode.Auto) + assert mode() == OperationMode.Auto + + self.device.set_mode(OperationMode.Silent) + assert mode() == OperationMode.Silent + + self.device.set_mode(OperationMode.Favorite) + assert mode() == OperationMode.Favorite + + self.device.set_mode(OperationMode.Fan) + assert mode() == OperationMode.Fan + + def test_set_favorite_level(self): + def favorite_level(): + return self.device.status().favorite_level + + self.device.set_favorite_level(0) + assert favorite_level() == 0 + self.device.set_favorite_level(6) + assert favorite_level() == 6 + self.device.set_favorite_level(14) + assert favorite_level() == 14 + + with pytest.raises(AirPurifierMiotException): + self.device.set_favorite_level(-1) + + with pytest.raises(AirPurifierMiotException): + self.device.set_favorite_level(15) + + def test_set_led_brightness(self): + def led_brightness(): + return self.device.status().led_brightness + + self.device.set_led_brightness(LedBrightness.Bright) + assert led_brightness() == LedBrightness.Bright + + self.device.set_led_brightness(LedBrightness.Dim) + assert led_brightness() == LedBrightness.Dim + + self.device.set_led_brightness(LedBrightness.Off) + assert led_brightness() == LedBrightness.Off + + def test_set_led(self): + def led(): + return self.device.status().led + + self.device.set_led(True) + assert led() is True + + self.device.set_led(False) + assert led() is False + + def test_set_buzzer(self): + def buzzer(): + return self.device.status().buzzer + + self.device.set_buzzer(True) + assert buzzer() is True + + self.device.set_buzzer(False) + assert buzzer() is False + + def test_set_child_lock(self): + def child_lock(): + return self.device.status().child_lock + + self.device.set_child_lock(True) + assert child_lock() is True + + self.device.set_child_lock(False) + assert child_lock() is False From 0c39c4c9523cf0b4dbc3836d963f908a162fa4c3 Mon Sep 17 00:00:00 2001 From: Petr Kotek Date: Tue, 17 Dec 2019 00:27:58 +1100 Subject: [PATCH 05/14] MIoT Air Purifier: add support for fine-tuning favourite mode by "set_favorite_rpm" --- miio/airpurifier_miot.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index 8ead90f85..c41491963 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -33,6 +33,7 @@ "button_pressed": {"siid": 8, "piid": 1}, # Motor Speed (siid=10) "favorite_level": {"siid": 10, "piid": 10}, + "set_favorite_rpm": {"siid": 10, "piid": 7}, "motor_speed": {"siid": 10, "piid": 8}, # Use time (siid=12) "use_time": {"siid": 12, "piid": 1}, @@ -319,6 +320,20 @@ def set_fan_level(self, level: int): raise AirPurifierMiotException("Invalid fan level: %s" % level) return self.set_property("fan_level", level) + @command( + click.argument("rpm", type=int), + default_output=format_output("Setting motor speed '{rpm}' rpm"), + ) + def set_favorite_rpm(self, rpm: int): + """Set motor speed.""" + # Note: documentation says the maximum is 2300, however, the purifier may return an error for rpm over 2200. + if rpm < 300 or rpm > 2300 or rpm % 10 != 0: + raise AirPurifierMiotException( + "Invalid motor speed: %s. Must be between 300 and 2300 and divisible by 10" + % rpm + ) + return self.set_property("set_favorite_rpm", rpm) + @command( click.argument("mode", type=EnumType(OperationMode, False)), default_output=format_output("Setting mode to '{mode.value}'"), From 05bf1bc734fc189fe82c49c6a0a4454f022efd33 Mon Sep 17 00:00:00 2001 From: Petr Kotek Date: Tue, 17 Dec 2019 00:33:44 +1100 Subject: [PATCH 06/14] MIoT Air Purifier: improve comments --- miio/airpurifier_miot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index c41491963..5f3836e28 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -322,14 +322,14 @@ def set_fan_level(self, level: int): @command( click.argument("rpm", type=int), - default_output=format_output("Setting motor speed '{rpm}' rpm"), + default_output=format_output("Setting favorite motor speed '{rpm}' rpm"), ) def set_favorite_rpm(self, rpm: int): - """Set motor speed.""" + """Set favorite motor speed.""" # Note: documentation says the maximum is 2300, however, the purifier may return an error for rpm over 2200. if rpm < 300 or rpm > 2300 or rpm % 10 != 0: raise AirPurifierMiotException( - "Invalid motor speed: %s. Must be between 300 and 2300 and divisible by 10" + "Invalid favorite motor speed: %s. Must be between 300 and 2300 and divisible by 10" % rpm ) return self.set_property("set_favorite_rpm", rpm) From f41c4e7d633729ad5a898843c96b19ee8d12df2d Mon Sep 17 00:00:00 2001 From: Petr Kotek Date: Tue, 7 Jan 2020 23:34:17 +1100 Subject: [PATCH 07/14] airpurifier_miot: don't try to retrieve "button_pressed" as it errors out if no button was pressed since purifier started up --- miio/airpurifier_miot.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index 5f3836e28..66b0e336c 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -29,8 +29,6 @@ "led": {"siid": 6, "piid": 6}, # Physical Control Locked (siid=7) "child_lock": {"siid": 7, "piid": 1}, - # Button (siid=8) - "button_pressed": {"siid": 8, "piid": 1}, # Motor Speed (siid=10) "favorite_level": {"siid": 10, "piid": 10}, "set_favorite_rpm": {"siid": 10, "piid": 7}, @@ -196,11 +194,6 @@ def filter_type(self) -> Optional[FilterType]: return FilterType.Regular return self.filter_type_util.determine_filter_type(self.filter_rfid_product_id) - @property - def button_pressed(self) -> Optional[str]: - """Last pressed button.""" - return self.data["button_pressed"] - def __repr__(self) -> str: s = ( " str: "motor_speed=%s, " "filter_rfid_product_id=%s, " "filter_rfid_tag=%s, " - "filter_type=%s, " - "button_pressed=%s>" + "filter_type=%s>" % ( self.power, self.aqi, @@ -245,7 +237,6 @@ def __repr__(self) -> str: self.filter_rfid_product_id, self.filter_rfid_tag, self.filter_type, - self.button_pressed, ) ) return s @@ -289,8 +280,7 @@ def __init__( "Motor speed: {result.motor_speed} rpm\n" "Filter RFID product id: {result.filter_rfid_product_id}\n" "Filter RFID tag: {result.filter_rfid_tag}\n" - "Filter type: {result.filter_type}\n" - "Last button pressed: {result.button_pressed}\n", + "Filter type: {result.filter_type}\n", ) ) def status(self) -> AirPurifierMiotStatus: From 14d65881a060fb9468b6c69447c36fa1bd13b4e2 Mon Sep 17 00:00:00 2001 From: "Andrey F. Kupreychik" Date: Tue, 4 Feb 2020 23:26:24 +0700 Subject: [PATCH 08/14] Version --- miio/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/miio/version.py b/miio/version.py index a2587b2e2..cf75937d1 100644 --- a/miio/version.py +++ b/miio/version.py @@ -1,2 +1,2 @@ # flake8: noqa -__version__ = "0.4.8" +__version__ = "0.5.0" From 6feed4298c616a2f266305ee56d4c32298331591 Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Thu, 20 Feb 2020 14:39:39 +0700 Subject: [PATCH 09/14] Fix MIOT purifier tests --- miio/tests/test_airpurifier_miot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/miio/tests/test_airpurifier_miot.py b/miio/tests/test_airpurifier_miot.py index 01a5ea45e..32bbcf549 100644 --- a/miio/tests/test_airpurifier_miot.py +++ b/miio/tests/test_airpurifier_miot.py @@ -6,7 +6,7 @@ from miio.airfilter_util import FilterType from miio.airpurifier_miot import AirPurifierMiotException, LedBrightness, OperationMode -from .dummies import DummyDevice, DummyMiotDevice +from .dummies import DummyMiotDevice _INITIAL_STATE = { "power": True, @@ -98,7 +98,6 @@ def test_status(self): assert status.motor_speed == _INITIAL_STATE["motor_speed"] assert status.filter_rfid_product_id == _INITIAL_STATE["filter_rfid_product_id"] assert status.filter_type == FilterType.AntiBacterial - assert status.button_pressed == _INITIAL_STATE["button_pressed"] def test_set_fan_level(self): def fan_level(): From 31b024885730cd0f0dad22f264dfe0f80bacba22 Mon Sep 17 00:00:00 2001 From: "Andrey F. Kupreychik" Date: Sun, 1 Mar 2020 01:20:54 +0700 Subject: [PATCH 10/14] Added buzzer_volumw and handling missing features for miot --- miio/airpurifier_miot.py | 34 ++++++++++++++++++++++++++--- miio/tests/dummies.py | 2 +- miio/tests/test_airpurifier_miot.py | 1 + 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index 66b0e336c..f557022d3 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -24,6 +24,7 @@ "filter_hours_used": {"siid": 4, "piid": 5}, # Alarm (siid=5) "buzzer": {"siid": 5, "piid": 1}, + "buzzer_volume": {"siid": 5, "piid": 2}, # Indicator Light (siid=6) "led_brightness": {"siid": 6, "piid": 1}, "led": {"siid": 6, "piid": 6}, @@ -31,7 +32,7 @@ "child_lock": {"siid": 7, "piid": 1}, # Motor Speed (siid=10) "favorite_level": {"siid": 10, "piid": 10}, - "set_favorite_rpm": {"siid": 10, "piid": 7}, + "favorite_rpm": {"siid": 10, "piid": 7}, "motor_speed": {"siid": 10, "piid": 8}, # Use time (siid=12) "use_time": {"siid": 12, "piid": 1}, @@ -137,6 +138,14 @@ def buzzer(self) -> Optional[bool]: return None + @property + def buzzer_volume(self) -> Optional[int]: + """Return buzzer volume.""" + if self.data["buzzer_volume"] is not None: + return self.data["buzzer_volume"] + + return None + @property def child_lock(self) -> bool: """Return True if child lock is on.""" @@ -206,6 +215,7 @@ def __repr__(self) -> str: "led=%s, " "led_brightness=%s, " "buzzer=%s, " + "buzzer_volume=%s, " "child_lock=%s, " "favorite_level=%s, " "filter_life_remaining=%s, " @@ -227,6 +237,7 @@ def __repr__(self) -> str: self.led, self.led_brightness, self.buzzer, + self.buzzer_volume, self.child_lock, self.favorite_level, self.filter_life_remaining, @@ -271,6 +282,7 @@ def __init__( "LED: {result.led}\n" "LED brightness: {result.led_brightness}\n" "Buzzer: {result.buzzer}\n" + "Buzzer vol.: {result.buzzer_volume}\n" "Child lock: {result.child_lock}\n" "Favorite level: {result.favorite_level}\n" "Filter life remaining: {result.filter_life_remaining} %\n" @@ -287,7 +299,10 @@ def status(self) -> AirPurifierMiotStatus: """Retrieve properties.""" return AirPurifierMiotStatus( - {prop["did"]: prop["value"] for prop in self.get_properties()} + { + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties() + } ) @command(default_output=format_output("Powering on")) @@ -322,7 +337,20 @@ 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("set_favorite_rpm", rpm) + return self.set_property("favorite_rpm", rpm) + + @command( + click.argument("volume", type=int), + default_output=format_output("Setting buzzer volume '{volume}'"), + ) + def set_buzzer_volume(self, volume: int): + """Set favorite motor speed.""" + # Note: documentation says the maximum is 2300, however, the purifier may return an error for rpm over 2200. + if volume < 0 or volume > 100: + raise AirPurifierMiotException( + "Invalid buzzer volume: %s. Must be between 0 and 100" % volume + ) + return self.set_property("buzzer_volume", volume) @command( click.argument("mode", type=EnumType(OperationMode, False)), diff --git a/miio/tests/dummies.py b/miio/tests/dummies.py index c488c8cd0..2ca1c6ad2 100644 --- a/miio/tests/dummies.py +++ b/miio/tests/dummies.py @@ -60,7 +60,7 @@ class DummyMiotDevice(DummyDevice): def __init__(self, *args, **kwargs): # {prop["did"]: prop["value"] for prop in self.miot_client.get_properties()} - self.state = [{"did": k, "value": v} for k, v in self.state.items()] + self.state = [{"did": k, "value": v, "code": 0} for k, v in self.state.items()] super().__init__(*args, **kwargs) def get_properties(self): diff --git a/miio/tests/test_airpurifier_miot.py b/miio/tests/test_airpurifier_miot.py index 32bbcf549..dddd0e120 100644 --- a/miio/tests/test_airpurifier_miot.py +++ b/miio/tests/test_airpurifier_miot.py @@ -19,6 +19,7 @@ "led": True, "led_brightness": 1, "buzzer": False, + "buzzer_volume": 0, "child_lock": False, "favorite_level": 10, "filter_life_remaining": 80, From 981cb239973757e79b4fb2652e291695ae3b778d Mon Sep 17 00:00:00 2001 From: "Andrey F. Kupreychik" Date: Sun, 1 Mar 2020 23:29:57 +0700 Subject: [PATCH 11/14] Fixed volume setter to be same as in non-miot purifier --- miio/airpurifier.py | 2 +- miio/airpurifier_miot.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/miio/airpurifier.py b/miio/airpurifier.py index aa3ca5430..8f1626df4 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -515,7 +515,7 @@ def set_child_lock(self, lock: bool): @command( click.argument("volume", type=int), - default_output=format_output("Setting favorite level to {volume}"), + default_output=format_output("Setting sound volume to {volume}"), ) def set_volume(self, volume: int): """Set volume of sound notifications [0-100].""" diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index f557022d3..d3c41d623 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -341,14 +341,13 @@ def set_favorite_rpm(self, rpm: int): @command( click.argument("volume", type=int), - default_output=format_output("Setting buzzer volume '{volume}'"), + default_output=format_output("Setting sound volume to {volume}"), ) - def set_buzzer_volume(self, volume: int): + def set_volume(self, volume: int): """Set favorite motor speed.""" - # Note: documentation says the maximum is 2300, however, the purifier may return an error for rpm over 2200. if volume < 0 or volume > 100: raise AirPurifierMiotException( - "Invalid buzzer volume: %s. Must be between 0 and 100" % volume + "Invalid volume: %s. Must be between 0 and 100" % volume ) return self.set_property("buzzer_volume", volume) From d18a7e91384e453def0616d0fc4b9d54de62e25a Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Mon, 2 Mar 2020 11:17:48 +0700 Subject: [PATCH 12/14] Fixed incorrect comment body --- miio/airpurifier_miot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index d3c41d623..0c165a038 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -344,7 +344,7 @@ def set_favorite_rpm(self, rpm: int): default_output=format_output("Setting sound volume to {volume}"), ) def set_volume(self, volume: int): - """Set favorite motor speed.""" + """Set buzzer volume.""" if volume < 0 or volume > 100: raise AirPurifierMiotException( "Invalid volume: %s. Must be between 0 and 100" % volume From becd8a0b6fa4dfe18bb2f344d933808f7e34de53 Mon Sep 17 00:00:00 2001 From: "Andrey F. Kupreychik" Date: Wed, 11 Mar 2020 23:27:29 +0700 Subject: [PATCH 13/14] Review comments fixed --- miio/airfilter_util.py | 13 ++++++++++++- miio/airpurifier.py | 10 +++------- miio/airpurifier_miot.py | 17 +++++++---------- miio/tests/test_airfilter_util.py | 19 ++++++++++++++++--- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/miio/airfilter_util.py b/miio/airfilter_util.py index bd02c9331..c74fc7c5f 100644 --- a/miio/airfilter_util.py +++ b/miio/airfilter_util.py @@ -1,5 +1,6 @@ import enum import re +from typing import Optional class FilterType(enum.Enum): @@ -21,12 +22,22 @@ class FilterTypeUtil: _filter_type_cache = {} - def determine_filter_type(self, product_id: str) -> FilterType: + def determine_filter_type( + self, rfid_tag: Optional[str], product_id: Optional[str] + ) -> Optional[FilterType]: """ Determine Xiaomi air filter type based on its product ID. + :param rfid_tag: RFID tag value :param product_id: Product ID such as "0:0:30:33" """ + if rfid_tag is None: + return None + if rfid_tag == "0:0:0:0:0:0:0": + return FilterType.Unknown + if product_id is None: + return FilterType.Regular + ft = self._filter_type_cache.get(product_id, None) if ft is None: for filter_re, filter_type in FILTER_TYPE_RE: diff --git a/miio/airpurifier.py b/miio/airpurifier.py index 8f1626df4..80ccdc72b 100644 --- a/miio/airpurifier.py +++ b/miio/airpurifier.py @@ -226,13 +226,9 @@ def filter_rfid_tag(self) -> Optional[str]: @property def filter_type(self) -> Optional[FilterType]: """Type of installed filter.""" - if self.filter_rfid_tag is None: - return None - if self.filter_rfid_tag == "0:0:0:0:0:0:0": - return FilterType.Unknown - if self.filter_rfid_product_id is None: - return FilterType.Regular - return self.filter_type_util.determine_filter_type(self.filter_rfid_product_id) + return self.filter_type_util.determine_filter_type( + self.filter_rfid_tag, self.filter_rfid_product_id + ) @property def learn_mode(self) -> bool: diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index 0c165a038..c547675c1 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -195,13 +195,9 @@ def filter_rfid_tag(self) -> Optional[str]: @property def filter_type(self) -> Optional[FilterType]: """Type of installed filter.""" - if self.filter_rfid_tag is None: - return None - if self.filter_rfid_tag == "0:0:0:0:0:0:0": - return FilterType.Unknown - if self.filter_rfid_product_id is None: - return FilterType.Regular - return self.filter_type_util.determine_filter_type(self.filter_rfid_product_id) + return self.filter_type_util.determine_filter_type( + self.filter_rfid_tag, self.filter_rfid_product_id + ) def __repr__(self) -> str: s = ( @@ -364,12 +360,13 @@ def set_mode(self, mode: OperationMode): default_output=format_output("Setting favorite level to {level}"), ) def set_favorite_level(self, level: int): - """Set favorite level.""" + """Set favorite level. + Set the favorite level used when the mode is `favorite`, + should be between 0 and 14. + """ if level < 0 or level > 14: raise AirPurifierMiotException("Invalid favorite level: %s" % level) - # Set the favorite level used when the mode is `favorite`, - # should be between 0 and 14. return self.set_property("favorite_level", level) @command( diff --git a/miio/tests/test_airfilter_util.py b/miio/tests/test_airfilter_util.py index a36e7879a..d8ff62dbf 100644 --- a/miio/tests/test_airfilter_util.py +++ b/miio/tests/test_airfilter_util.py @@ -12,15 +12,25 @@ def airfilter_util(request): @pytest.mark.usefixtures("airfilter_util") class TestAirFilterUtil(TestCase): + def test_determine_filter_type__recognises_unknown_filter(self): + assert ( + self.filter_type_util.determine_filter_type("0:0:0:0:0:0:0", None) + is FilterType.Unknown + ) + def test_determine_filter_type__recognises_antibacterial_filter(self): assert ( - self.filter_type_util.determine_filter_type("12:34:41:30") + self.filter_type_util.determine_filter_type( + "80:64:d1:ba:4f:5f:4", "12:34:41:30" + ) is FilterType.AntiBacterial ) def test_determine_filter_type__recognises_antiformaldehyde_filter(self): assert ( - self.filter_type_util.determine_filter_type("12:34:00:31") + self.filter_type_util.determine_filter_type( + "80:64:d1:ba:4f:5f:4", "12:34:00:31" + ) is FilterType.AntiFormaldehyde ) @@ -30,9 +40,12 @@ def test_determine_filter_type__falls_back_to_regular_filter(self): "12:34:56:31", "12:34:56:31:11:11", "CO:FF:FF:EE", + None, ] for product_id in regular_filters: assert ( - self.filter_type_util.determine_filter_type(product_id) + self.filter_type_util.determine_filter_type( + "80:64:d1:ba:4f:5f:4", product_id + ) is FilterType.Regular ) From d549082b9a86caccd402985ad6f53f38a9a03a7b Mon Sep 17 00:00:00 2001 From: "Andrey F. Kupreychik" Date: Thu, 12 Mar 2020 01:21:51 +0700 Subject: [PATCH 14/14] Expanded documentation --- docs/miio.rst | 16 ++++++++++++++++ miio/airpurifier_miot.py | 3 +-- miio/miot_device.py | 2 ++ miio/version.py | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/miio.rst b/docs/miio.rst index f966e7f1b..f97a397ca 100644 --- a/docs/miio.rst +++ b/docs/miio.rst @@ -36,6 +36,14 @@ miio\.airpurifier module :show-inheritance: :undoc-members: +miio\.airpurifier_miot module +----------------------------- + +.. automodule:: miio.airpurifier_miot + :members: + :show-inheritance: + :undoc-members: + miio\.airqualitymonitor module ------------------------------ @@ -93,6 +101,14 @@ miio\.device module :show-inheritance: :undoc-members: +miio\.miot_device module +------------------------ + +.. automodule:: miio.miot_device + :members: + :show-inheritance: + :undoc-members: + miio\.discovery module ---------------------- diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index c547675c1..c03396092 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -360,8 +360,7 @@ def set_mode(self, mode: OperationMode): default_output=format_output("Setting favorite level to {level}"), ) def set_favorite_level(self, level: int): - """Set favorite level. - Set the favorite level used when the mode is `favorite`, + """Set the favorite level used when the mode is `favorite`, should be between 0 and 14. """ if level < 0 or level > 14: diff --git a/miio/miot_device.py b/miio/miot_device.py index 2e41edd79..9b45e3ee7 100644 --- a/miio/miot_device.py +++ b/miio/miot_device.py @@ -47,6 +47,8 @@ def get_properties(self) -> list: return values def set_property(self, property_key: str, value): + """Sets property value.""" + return self.send( "set_properties", [{"did": property_key, **self.mapping[property_key], "value": value}], diff --git a/miio/version.py b/miio/version.py index cf75937d1..a2587b2e2 100644 --- a/miio/version.py +++ b/miio/version.py @@ -1,2 +1,2 @@ # flake8: noqa -__version__ = "0.5.0" +__version__ = "0.4.8"