From 91ecc98783d6fc035a49e8dd5a254e9ce0100c9e Mon Sep 17 00:00:00 2001 From: arturdobo Date: Sun, 7 Feb 2021 21:24:54 +0100 Subject: [PATCH] Add support for Xiaomi Air purifier 3C (#899) * split into two models * Add test for mb4 * Fixed missing import * fix favorite level and brightness setting * fixed favorite level in test * applied status review remarks * Removed unnecessary constructor Co-authored-by: Teemu R. * Improved comment Co-authored-by: Teemu R. * Removed unnecessary constructor Co-authored-by: Teemu R. * fixed linting * updated readme Co-authored-by: Teemu R. --- README.rst | 2 +- miio/__init__.py | 2 +- miio/airpurifier_miot.py | 313 ++++++++++++++++++------ miio/tests/test_airpurifier_miot_mb4.py | 139 +++++++++++ 4 files changed, 374 insertions(+), 82 deletions(-) create mode 100644 miio/tests/test_airpurifier_miot_mb4.py diff --git a/README.rst b/README.rst index 103fe0740..a93d1f401 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Supported devices - Xiaomi Mi Robot Vacuum V1, S5, M1S - Xiaomi Mi Home Air Conditioner Companion - Xiaomi Mi Smart Air Conditioner A (xiaomi.aircondition.mc1, mc2, mc4, mc5) -- Xiaomi Mi Air Purifier +- Xiaomi Mi Air Purifier 2, 3H, 3C, Pro (zhimi.airpurifier.m2, mb3, mb4, v7) - Xiaomi Mi Air (Purifier) Dog X3, X5, X7SM (airdog.airpurifier.x3, airdog.airpurifier.x5, airdog.airpurifier.x7sm) - Xiaomi Mi Air Humidifier - Xiaomi Aqara Camera diff --git a/miio/__init__.py b/miio/__init__.py index 02c43a180..e91175222 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -21,7 +21,7 @@ from miio.airhumidifier_mjjsq import AirHumidifierMjjsq from miio.airpurifier import AirPurifier from miio.airpurifier_airdog import AirDogX3, AirDogX5, AirDogX7SM -from miio.airpurifier_miot import AirPurifierMiot +from miio.airpurifier_miot import AirPurifierMB4, AirPurifierMiot from miio.airqualitymonitor import AirQualityMonitor from miio.airqualitymonitor_miot import AirQualityMonitorCGDN1 from miio.aqaracamera import AqaraCamera diff --git a/miio/airpurifier_miot.py b/miio/airpurifier_miot.py index 6d4e736b5..163b223ea 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -46,6 +46,27 @@ "app_extra": {"siid": 15, "piid": 1}, } +# https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-mb4:2 +_MODEL_AIRPURIFIER_MB4 = { + # Air Purifier + "power": {"siid": 2, "piid": 1}, + "mode": {"siid": 2, "piid": 4}, + # Environment + "aqi": {"siid": 3, "piid": 4}, + # Filter + "filter_life_remaining": {"siid": 4, "piid": 1}, + "filter_hours_used": {"siid": 4, "piid": 3}, + # Alarm + "buzzer": {"siid": 6, "piid": 1}, + # Screen + "led_brightness_level": {"siid": 7, "piid": 2}, + # Physical Control Locked + "child_lock": {"siid": 8, "piid": 1}, + # custom-service + "motor_speed": {"siid": 9, "piid": 1}, + "favorite_rpm": {"siid": 9, "piid": 3}, +} + class AirPurifierMiotException(DeviceException): pass @@ -64,7 +85,7 @@ class LedBrightness(enum.Enum): Off = 2 -class AirPurifierMiotStatus: +class BasicAirPurifierMiotStatus: """Container for status reports from the air purifier.""" def __init__(self, data: Dict[str, Any]) -> None: @@ -90,6 +111,43 @@ def aqi(self) -> int: return None return self.data["aqi"] + @property + def mode(self) -> OperationMode: + """Current operation mode.""" + return OperationMode(self.data["mode"]) + + @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 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 motor_speed(self) -> int: + """Speed of the motor.""" + return self.data["motor_speed"] + + +class AirPurifierMiotStatus(BasicAirPurifierMiotStatus): + """Container for status reports from the air purifier.""" + @property def average_aqi(self) -> int: """Average of the air quality index.""" @@ -113,11 +171,6 @@ 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.""" @@ -134,14 +187,6 @@ def led_brightness(self) -> Optional[LedBrightness]: 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 buzzer_volume(self) -> Optional[int]: """Return buzzer volume.""" @@ -150,27 +195,12 @@ def buzzer_volume(self) -> Optional[int]: 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.""" @@ -181,11 +211,6 @@ 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.""" @@ -253,7 +278,135 @@ def __repr__(self) -> str: return s -class AirPurifierMiot(MiotDevice): +class AirPurifierMB4Status(BasicAirPurifierMiotStatus): + """ + Container for status reports from the Mi Air Purifier 3C (zhimi.airpurifier.mb4). + + { + 'power': True, + 'mode': 1, + 'aqi': 2, + 'filter_life_remaining': 97, + 'filter_hours_used': 100, + 'buzzer': True, + 'led_brightness_level': 8, + 'child_lock': False, + 'motor_speed': 392, + 'favorite_rpm': 500 + } + + Response (MIoT format) + + [ + {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': True}, + {'did': 'mode', 'siid': 2, 'piid': 4, 'code': 0, 'value': 1}, + {'did': 'aqi', 'siid': 3, 'piid': 4, 'code': 0, 'value': 3}, + {'did': 'filter_life_remaining', 'siid': 4, 'piid': 1, 'code': 0, 'value': 97}, + {'did': 'filter_hours_used', 'siid': 4, 'piid': 3, 'code': 0, 'value': 100}, + {'did': 'buzzer', 'siid': 6, 'piid': 1, 'code': 0, 'value': True}, + {'did': 'led_brightness_level', 'siid': 7, 'piid': 2, 'code': 0, 'value': 8}, + {'did': 'child_lock', 'siid': 8, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'motor_speed', 'siid': 9, 'piid': 1, 'code': 0, 'value': 388}, + {'did': 'favorite_rpm', 'siid': 9, 'piid': 3, 'code': 0, 'value': 500} + ] + + """ + + @property + def led_brightness_level(self) -> int: + """Return brightness level.""" + return self.data["led_brightness_level"] + + @property + def favorite_rpm(self) -> int: + """Return favorite rpm level.""" + return self.data["favorite_rpm"] + + def __repr__(self) -> str: + s = ( + "" + % ( + self.power, + self.aqi, + self.mode, + self.led_brightness_level, + self.buzzer, + self.child_lock, + self.filter_life_remaining, + self.filter_hours_used, + self.motor_speed, + self.favorite_rpm, + ) + ) + return s + + +class BasicAirPurifierMiot(MiotDevice): + """Main class representing the air purifier which uses MIoT protocol.""" + + @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("rpm", type=int), + default_output=format_output("Setting favorite motor speed '{rpm}' rpm"), + ) + def set_favorite_rpm(self, rpm: int): + """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 favorite motor speed: %s. Must be between 300 and 2300 and divisible by 10" + % rpm + ) + return self.set_property("favorite_rpm", rpm) + + @command( + click.argument("mode", type=EnumType(OperationMode)), + 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("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) + + +class AirPurifierMiot(BasicAirPurifierMiot): """Main class representing the air purifier which uses MIoT protocol.""" def __init__( @@ -302,16 +455,6 @@ def status(self) -> AirPurifierMiotStatus: } ) - @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}'"), @@ -322,20 +465,6 @@ 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 favorite motor speed '{rpm}' rpm"), - ) - def set_favorite_rpm(self, rpm: int): - """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 favorite motor speed: %s. Must be between 300 and 2300 and divisible by 10" - % rpm - ) - return self.set_property("favorite_rpm", rpm) - @command( click.argument("volume", type=int), default_output=format_output("Setting sound volume to {volume}"), @@ -348,14 +477,6 @@ def set_volume(self, volume: int): ) return self.set_property("buzzer_volume", volume) - @command( - click.argument("mode", type=EnumType(OperationMode)), - 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}"), @@ -388,22 +509,54 @@ def set_led(self, led: bool): """Turn led on/off.""" return self.set_property("led", led) + +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 + ) + @command( - click.argument("buzzer", type=bool), default_output=format_output( - lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" - ), + "", + "Power: {result.power}\n" + "AQI: {result.aqi} μg/m³\n" + "Mode: {result.mode}\n" + "LED brightness level: {result.led_brightness_level}\n" + "Buzzer: {result.buzzer}\n" + "Child lock: {result.child_lock}\n" + "Filter life remaining: {result.filter_life_remaining} %\n" + "Filter hours used: {result.filter_hours_used}\n" + "Motor speed: {result.motor_speed} rpm\n" + "Favorite RPM: {result.favorite_rpm} rpm\n", + ) ) - def set_buzzer(self, buzzer: bool): - """Set buzzer on/off.""" - return self.set_property("buzzer", buzzer) + def status(self) -> AirPurifierMB4Status: + """Retrieve properties.""" + + return AirPurifierMB4Status( + { + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping() + } + ) @command( - click.argument("lock", type=bool), - default_output=format_output( - lambda lock: "Turning on child lock" if lock else "Turning off child lock" - ), + click.argument("level", type=int), + default_output=format_output("Setting LED brightness level to {level}"), ) - def set_child_lock(self, lock: bool): - """Set child lock on/off.""" - return self.set_property("child_lock", lock) + def set_led_brightness_level(self, level: int): + """Set led brightness level (0..8).""" + if level < 0 or level > 8: + raise AirPurifierMiotException("Invalid brightness level: %s" % level) + + return self.set_property("led_brightness_level", level) diff --git a/miio/tests/test_airpurifier_miot_mb4.py b/miio/tests/test_airpurifier_miot_mb4.py new file mode 100644 index 000000000..c95072e17 --- /dev/null +++ b/miio/tests/test_airpurifier_miot_mb4.py @@ -0,0 +1,139 @@ +from unittest import TestCase + +import pytest + +from miio import AirPurifierMB4 +from miio.airpurifier_miot import AirPurifierMiotException, OperationMode + +from .dummies import DummyMiotDevice + +_INITIAL_STATE = { + "power": True, + "mode": 0, + "aqi": 10, + "filter_life_remaining": 80, + "filter_hours_used": 682, + "buzzer": False, + "led_brightness_level": 4, + "child_lock": False, + "motor_speed": 354, + "favorite_rpm": 500, +} + + +class DummyAirPurifierMiot(DummyMiotDevice, AirPurifierMB4): + 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_buzzer": lambda x: self._set_state("buzzer", x), + "set_child_lock": lambda x: self._set_state("child_lock", x), + "set_favorite_rpm": lambda x: self._set_state("favorite_rpm", x), + "reset_filter1": lambda x: ( + self._set_state("f1_hour_used", [0]), + self._set_state("filter1_life", [100]), + ), + } + 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.mode == OperationMode(_INITIAL_STATE["mode"]) + assert status.led_brightness_level == _INITIAL_STATE["led_brightness_level"] + assert status.buzzer == _INITIAL_STATE["buzzer"] + assert status.child_lock == _INITIAL_STATE["child_lock"] + assert status.favorite_rpm == _INITIAL_STATE["favorite_rpm"] + assert status.filter_life_remaining == _INITIAL_STATE["filter_life_remaining"] + assert status.filter_hours_used == _INITIAL_STATE["filter_hours_used"] + assert status.motor_speed == _INITIAL_STATE["motor_speed"] + + 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_rpm(self): + def favorite_rpm(): + return self.device.status().favorite_rpm + + self.device.set_favorite_rpm(300) + assert favorite_rpm() == 300 + self.device.set_favorite_rpm(1000) + assert favorite_rpm() == 1000 + self.device.set_favorite_rpm(2300) + assert favorite_rpm() == 2300 + + with pytest.raises(AirPurifierMiotException): + self.device.set_favorite_rpm(301) + + with pytest.raises(AirPurifierMiotException): + self.device.set_favorite_rpm(290) + + with pytest.raises(AirPurifierMiotException): + self.device.set_favorite_rpm(2310) + + def test_set_led_brightness_level(self): + def led_brightness_level(): + return self.device.status().led_brightness_level + + self.device.set_led_brightness_level(0) + assert led_brightness_level() == 0 + + self.device.set_led_brightness_level(4) + assert led_brightness_level() == 4 + + self.device.set_led_brightness_level(8) + assert led_brightness_level() == 8 + + with pytest.raises(AirPurifierMiotException): + self.device.set_led_brightness_level(-1) + + with pytest.raises(AirPurifierMiotException): + self.device.set_led_brightness_level(9) + + 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