diff --git a/README.rst b/README.rst index f6f8995f9..7ef7e88e2 100644 --- a/README.rst +++ b/README.rst @@ -103,7 +103,7 @@ Supported devices - Xiaomi Mi Robot Vacuum V1, S4, S4 MAX, S5, S5 MAX, S6 Pure, M1S, S7 - Xiaomi Mi Home Air Conditioner Companion - Xiaomi Mi Smart Air Conditioner A (xiaomi.aircondition.mc1, mc2, mc4, mc5) -- Xiaomi Mi Air Purifier 2, 3H, 3C, Pro, Pro H (zhimi.airpurifier.m2, mb3, mb4, v7, vb2) +- Xiaomi Mi Air Purifier 2, 3H, 3C, Pro, Pro H, 4 Pro (zhimi.airpurifier.m2, mb3, mb4, v7, vb2, va2) - 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/airpurifier_miot.py b/miio/airpurifier_miot.py index b5d427d2a..53bfe1255 100644 --- a/miio/airpurifier_miot.py +++ b/miio/airpurifier_miot.py @@ -4,23 +4,13 @@ import click +from miio.utils import deprecated + from .airfilter_util import FilterType, FilterTypeUtil from .click_common import EnumType, command, format_output from .exceptions import DeviceException from .miot_device import DeviceStatus, MiotDevice -SUPPORTED_MODELS = [ - "zhimi.airpurifier.ma4", # airpurifier 3 - "zhimi.airpurifier.mb3", # airpurifier 3h - "zhimi.airpurifier.va1", # airpurifier proh - "zhimi.airpurifier.vb2", # airpurifier proh -] - -SUPPORTED_MODELS_MB4 = [ - "zhimi.airpurifier.mb4", # airpurifier 3c - "zhimi.airp.mb4a", # airpurifier 3c -] - _LOGGER = logging.getLogger(__name__) _MAPPING = { # Air Purifier (siid=2) @@ -60,7 +50,7 @@ } # https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-mb4:2 -_MODEL_AIRPURIFIER_MB4 = { +_MAPPING_MB4 = { # Air Purifier "power": {"siid": 2, "piid": 1}, "mode": {"siid": 2, "piid": 4}, @@ -80,6 +70,50 @@ "favorite_rpm": {"siid": 9, "piid": 3}, } +# https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-va2:2 +_MAPPING_VA2 = { + # Air Purifier + "power": {"siid": 2, "piid": 1}, + "mode": {"siid": 2, "piid": 4}, + "fan_level": {"siid": 2, "piid": 5}, + "anion": {"siid": 2, "piid": 6}, + # Environment + "humidity": {"siid": 3, "piid": 1}, + "aqi": {"siid": 3, "piid": 4}, + "temperature": {"siid": 3, "piid": 7}, + # Filter + "filter_life_remaining": {"siid": 4, "piid": 1}, + "filter_hours_used": {"siid": 4, "piid": 3}, + "filter_left_time": {"siid": 4, "piid": 4}, + # Alarm + "buzzer": {"siid": 6, "piid": 1}, + # Physical Control Locked + "child_lock": {"siid": 8, "piid": 1}, + # custom-service + "motor_speed": {"siid": 9, "piid": 1}, + "favorite_rpm": {"siid": 9, "piid": 3}, + "favorite_level": {"siid": 9, "piid": 5}, + # aqi + "purify_volume": {"siid": 11, "piid": 1}, + "average_aqi": {"siid": 11, "piid": 2}, + "aqi_realtime_update_duration": {"siid": 11, "piid": 4}, + # RFID + "filter_rfid_tag": {"siid": 12, "piid": 1}, + "filter_rfid_product_id": {"siid": 12, "piid": 3}, + # Screen + "led_brightness": {"siid": 13, "piid": 2}, +} + +_MAPPINGS = { + "zhimi.airpurifier.ma4": _MAPPING, # airpurifier 3 + "zhimi.airpurifier.mb3": _MAPPING, # airpurifier 3h + "zhimi.airpurifier.va1": _MAPPING, # airpurifier proh + "zhimi.airpurifier.vb2": _MAPPING, # airpurifier proh + "zhimi.airpurifier.mb4": _MAPPING_MB4, # airpurifier 3c + "zhimi.airp.mb4a": _MAPPING_MB4, # airpurifier 3c + "zhimi.airp.va2": _MAPPING_VA2, # airpurifier 4 pro +} + class AirPurifierMiotException(DeviceException): pass @@ -99,12 +133,41 @@ class LedBrightness(enum.Enum): Off = 2 -class BasicAirPurifierMiotStatus(DeviceStatus): - """Container for status reports from the air purifier.""" +class AirPurifierMiotStatus(DeviceStatus): + """Container for status reports from the air purifier. - def __init__(self, data: Dict[str, Any]) -> None: + Mi Air Purifier 3/3H (zhimi.airpurifier.mb3) response (MIoT format) + + [ + {'did': 'power', 'siid': 2, 'piid': 2, 'code': 0, 'value': True}, + {'did': 'fan_level', 'siid': 2, 'piid': 4, 'code': 0, 'value': 1}, + {'did': 'mode', 'siid': 2, 'piid': 5, 'code': 0, 'value': 2}, + {'did': 'humidity', 'siid': 3, 'piid': 7, 'code': 0, 'value': 38}, + {'did': 'temperature', 'siid': 3, 'piid': 8, 'code': 0, 'value': 22.299999}, + {'did': 'aqi', 'siid': 3, 'piid': 6, 'code': 0, 'value': 2}, + {'did': 'filter_life_remaining', 'siid': 4, 'piid': 3, 'code': 0, 'value': 45}, + {'did': 'filter_hours_used', 'siid': 4, 'piid': 5, 'code': 0, 'value': 1915}, + {'did': 'buzzer', 'siid': 5, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'buzzer_volume', 'siid': 5, 'piid': 2, 'code': -4001}, + {'did': 'led_brightness', 'siid': 6, 'piid': 1, 'code': 0, 'value': 1}, + {'did': 'led', 'siid': 6, 'piid': 6, 'code': 0, 'value': True}, + {'did': 'child_lock', 'siid': 7, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'favorite_level', 'siid': 10, 'piid': 10, 'code': 0, 'value': 2}, + {'did': 'favorite_rpm', 'siid': 10, 'piid': 7, 'code': 0, 'value': 770}, + {'did': 'motor_speed', 'siid': 10, 'piid': 8, 'code': 0, 'value': 769}, + {'did': 'use_time', 'siid': 12, 'piid': 1, 'code': 0, 'value': 6895800}, + {'did': 'purify_volume', 'siid': 13, 'piid': 1, 'code': 0, 'value': 222564}, + {'did': 'average_aqi', 'siid': 13, 'piid': 2, 'code': 0, 'value': 2}, + {'did': 'filter_rfid_tag', 'siid': 14, 'piid': 1, 'code': 0, 'value': '81:6b:3f:32:84:4b:4'}, + {'did': 'filter_rfid_product_id', 'siid': 14, 'piid': 3, 'code': 0, 'value': '0:0:31:31'}, + {'did': 'app_extra', 'siid': 15, 'piid': 1, 'code': 0, 'value': 0} + ] + """ + + def __init__(self, data: Dict[str, Any], model: str) -> None: self.filter_type_util = FilterTypeUtil() self.data = data + self.model = model @property def is_on(self) -> bool: @@ -117,9 +180,9 @@ def power(self) -> str: return "on" if self.is_on else "off" @property - def aqi(self) -> int: + def aqi(self) -> Optional[int]: """Air quality index.""" - return self.data["aqi"] + return self.data.get("aqi") @property def mode(self) -> OperationMode: @@ -134,102 +197,69 @@ def mode(self) -> OperationMode: @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 + return self.data.get("buzzer") @property - def child_lock(self) -> bool: + def child_lock(self) -> Optional[bool]: """Return True if child lock is on.""" - return self.data["child_lock"] + return self.data.get("child_lock") @property - def filter_life_remaining(self) -> int: + def filter_life_remaining(self) -> Optional[int]: """Time until the filter should be changed.""" - return self.data["filter_life_remaining"] + return self.data.get("filter_life_remaining") @property - def filter_hours_used(self) -> int: + def filter_hours_used(self) -> Optional[int]: """How long the filter has been in use.""" - return self.data["filter_hours_used"] + return self.data.get("filter_hours_used") @property - def motor_speed(self) -> int: + def motor_speed(self) -> Optional[int]: """Speed of the motor.""" - return self.data["motor_speed"] + return self.data.get("motor_speed") @property def favorite_rpm(self) -> Optional[int]: """Return favorite rpm level.""" return self.data.get("favorite_rpm") - -class AirPurifierMiotStatus(BasicAirPurifierMiotStatus): - """Container for status reports from the air purifier. - - Mi Air Purifier 3/3H (zhimi.airpurifier.mb3) response (MIoT format) - - [ - {'did': 'power', 'siid': 2, 'piid': 2, 'code': 0, 'value': True}, - {'did': 'fan_level', 'siid': 2, 'piid': 4, 'code': 0, 'value': 1}, - {'did': 'mode', 'siid': 2, 'piid': 5, 'code': 0, 'value': 2}, - {'did': 'humidity', 'siid': 3, 'piid': 7, 'code': 0, 'value': 38}, - {'did': 'temperature', 'siid': 3, 'piid': 8, 'code': 0, 'value': 22.299999}, - {'did': 'aqi', 'siid': 3, 'piid': 6, 'code': 0, 'value': 2}, - {'did': 'filter_life_remaining', 'siid': 4, 'piid': 3, 'code': 0, 'value': 45}, - {'did': 'filter_hours_used', 'siid': 4, 'piid': 5, 'code': 0, 'value': 1915}, - {'did': 'buzzer', 'siid': 5, 'piid': 1, 'code': 0, 'value': False}, - {'did': 'buzzer_volume', 'siid': 5, 'piid': 2, 'code': -4001}, - {'did': 'led_brightness', 'siid': 6, 'piid': 1, 'code': 0, 'value': 1}, - {'did': 'led', 'siid': 6, 'piid': 6, 'code': 0, 'value': True}, - {'did': 'child_lock', 'siid': 7, 'piid': 1, 'code': 0, 'value': False}, - {'did': 'favorite_level', 'siid': 10, 'piid': 10, 'code': 0, 'value': 2}, - {'did': 'favorite_rpm', 'siid': 10, 'piid': 7, 'code': 0, 'value': 770}, - {'did': 'motor_speed', 'siid': 10, 'piid': 8, 'code': 0, 'value': 769}, - {'did': 'use_time', 'siid': 12, 'piid': 1, 'code': 0, 'value': 6895800}, - {'did': 'purify_volume', 'siid': 13, 'piid': 1, 'code': 0, 'value': 222564}, - {'did': 'average_aqi', 'siid': 13, 'piid': 2, 'code': 0, 'value': 2}, - {'did': 'filter_rfid_tag', 'siid': 14, 'piid': 1, 'code': 0, 'value': '81:6b:3f:32:84:4b:4'}, - {'did': 'filter_rfid_product_id', 'siid': 14, 'piid': 3, 'code': 0, 'value': '0:0:31:31'}, - {'did': 'app_extra', 'siid': 15, 'piid': 1, 'code': 0, 'value': 0} - ] - """ - @property - def average_aqi(self) -> int: + def average_aqi(self) -> Optional[int]: """Average of the air quality index.""" - return self.data["average_aqi"] + return self.data.get("average_aqi") @property - def humidity(self) -> int: + def humidity(self) -> Optional[int]: """Current humidity.""" - return self.data["humidity"] + return self.data.get("humidity") @property def temperature(self) -> Optional[float]: """Current temperature, if available.""" - if self.data["temperature"] is not None: - return round(self.data["temperature"], 1) - - return None + temperate = self.data.get("temperature") + return round(temperate, 1) if temperate is not None else None @property - def fan_level(self) -> int: + def fan_level(self) -> Optional[int]: """Current fan level.""" - return self.data["fan_level"] + return self.data.get("fan_level") @property - def led(self) -> bool: + def led(self) -> Optional[bool]: """Return True if LED is on.""" - return self.data["led"] + return self.data.get("led") @property def led_brightness(self) -> Optional[LedBrightness]: """Brightness of the LED.""" - if self.data["led_brightness"] is not None: + + value = self.data.get("led_brightness") + if value is not None: + if self.model == "zhimi.airp.va2": + value = 2 - value try: - return LedBrightness(self.data["led_brightness"]) + return LedBrightness(value) except ValueError: return None @@ -238,36 +268,33 @@ def led_brightness(self) -> Optional[LedBrightness]: @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 + return self.data.get("buzzer_volume") @property - def favorite_level(self) -> int: + def favorite_level(self) -> Optional[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"] + return self.data.get("favorite_level") @property - def use_time(self) -> int: + def use_time(self) -> Optional[int]: """How long the device has been active in seconds.""" - return self.data["use_time"] + return self.data.get("use_time") @property - def purify_volume(self) -> int: + def purify_volume(self) -> Optional[int]: """The volume of purified air in cubic meter.""" - return self.data["purify_volume"] + return self.data.get("purify_volume") @property def filter_rfid_product_id(self) -> Optional[str]: """RFID product ID of installed filter.""" - return self.data["filter_rfid_product_id"] + return self.data.get("filter_rfid_product_id") @property def filter_rfid_tag(self) -> Optional[str]: """RFID tag ID of installed filter.""" - return self.data["filter_rfid_tag"] + return self.data.get("filter_rfid_tag") @property def filter_type(self) -> Optional[FilterType]: @@ -276,50 +303,73 @@ def filter_type(self) -> Optional[FilterType]: self.filter_rfid_tag, self.filter_rfid_product_id ) + @property + def led_brightness_level(self) -> Optional[int]: + """Return brightness level.""" + return self.data.get("led_brightness_level") -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 anion(self) -> Optional[bool]: + """Return whether anion is on.""" + return self.data.get("anion") @property - def led_brightness_level(self) -> int: - """Return brightness level.""" - return self.data["led_brightness_level"] + def filter_left_time(self) -> Optional[int]: + """How many days can the filter still be used.""" + return self.data.get("filter_left_time") -class BasicAirPurifierMiot(MiotDevice): +class AirPurifierMiot(MiotDevice): """Main class representing the air purifier which uses MIoT protocol.""" + _supported_models = list(_MAPPINGS.keys()) + _mappings = _MAPPINGS + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Anion: {result.anion}\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" + "LED brightness level: {result.led_brightness_level}\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" + "Filter hours used: {result.filter_hours_used}\n" + "Filter left time: {result.filter_left_time} days\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.""" + # Some devices update the aqi information only every 30min. + # This forces the device to poll the sensor for 5 seconds, + # so that we get always the most recent values. See #1281. + if self.model == "zhimi.airpurifier.mb3": + self.set_property("aqi_realtime_update_duration", 5) + + return AirPurifierMiotStatus( + { + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping() + }, + self.model, + ) + @command(default_output=format_output("Powering on")) def on(self): """Power on.""" @@ -336,6 +386,11 @@ def off(self): ) def set_favorite_rpm(self, rpm: int): """Set favorite motor speed.""" + if "favorite_rpm" not in self._get_mapping(): + raise AirPurifierMiotException( + "Unsupported favorite rpm for model '%s'" % self.model + ) + # 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( @@ -352,6 +407,20 @@ def set_mode(self, mode: OperationMode): """Set mode.""" return self.set_property("mode", mode.value) + @command( + click.argument("anion", type=bool), + default_output=format_output( + lambda anion: "Turning on anion" if anion else "Turing off anion", + ), + ) + def set_anion(self, anion: bool): + """Set anion on/off.""" + if "anion" not in self._get_mapping(): + raise AirPurifierMiotException( + "Unsupported anion for model '%s'" % self.model + ) + return self.set_property("anion", anion) + @command( click.argument("buzzer", type=bool), default_output=format_output( @@ -360,6 +429,11 @@ def set_mode(self, mode: OperationMode): ) def set_buzzer(self, buzzer: bool): """Set buzzer on/off.""" + if "buzzer" not in self._get_mapping(): + raise AirPurifierMiotException( + "Unsupported buzzer for model '%s'" % self.model + ) + return self.set_property("buzzer", buzzer) @command( @@ -370,62 +444,23 @@ def set_buzzer(self, buzzer: bool): ) def set_child_lock(self, lock: bool): """Set child lock on/off.""" + if "child_lock" not in self._get_mapping(): + raise AirPurifierMiotException( + "Unsupported child lock for model '%s'" % self.model + ) return self.set_property("child_lock", lock) - -class AirPurifierMiot(BasicAirPurifierMiot): - """Main class representing the air purifier which uses MIoT protocol.""" - - mapping = _MAPPING - _supported_models = SUPPORTED_MODELS - - @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" - "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" - "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.""" - # Some devices update the aqi information only every 30min. - # This forces the device to poll the sensor for 5 seconds, - # so that we get always the most recent values. See #1281. - if self.model == "zhimi.airpurifier.mb3": - self.set_property("aqi_realtime_update_duration", 5) - - return AirPurifierMiotStatus( - { - prop["did"]: prop["value"] if prop["code"] == 0 else None - for prop in self.get_properties_for_mapping() - } - ) - @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 "fan_level" not in self._get_mapping(): + raise AirPurifierMiotException( + "Unsupported fan level for model '%s'" % self.model + ) + if level < 1 or level > 3: raise AirPurifierMiotException("Invalid fan level: %s" % level) return self.set_property("fan_level", level) @@ -436,6 +471,11 @@ def set_fan_level(self, level: int): ) def set_volume(self, volume: int): """Set buzzer volume.""" + if "volume" not in self._get_mapping(): + raise AirPurifierMiotException( + "Unsupported volume for model '%s'" % self.model + ) + if volume < 0 or volume > 100: raise AirPurifierMiotException( "Invalid volume: %s. Must be between 0 and 100" % volume @@ -451,6 +491,11 @@ def set_favorite_level(self, level: int): Needs to be between 0 and 14. """ + if "favorite_level" not in self._get_mapping(): + raise AirPurifierMiotException( + "Unsupported favorite level for model '%s'" % self.model + ) + if level < 0 or level > 14: raise AirPurifierMiotException("Invalid favorite level: %s" % level) @@ -462,7 +507,15 @@ 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) + if "led_brightness" not in self._get_mapping(): + raise AirPurifierMiotException( + "Unsupported led brightness for model '%s'" % self.model + ) + + value = brightness.value + if self.model == "zhimi.airp.va2" and value: + value = 2 - value + return self.set_property("led_brightness", value) @command( click.argument("led", type=bool), @@ -472,47 +525,29 @@ def set_led_brightness(self, brightness: LedBrightness): ) def set_led(self, led: bool): """Turn led on/off.""" + if "led" not in self._get_mapping(): + raise AirPurifierMiotException( + "Unsupported led for model '%s'" % self.model + ) return self.set_property("led", led) - -class AirPurifierMB4(BasicAirPurifierMiot): - """Main class representing the air purifier which uses MIoT protocol.""" - - mapping = _MODEL_AIRPURIFIER_MB4 - _supported_models = SUPPORTED_MODELS_MB4 - - @command( - default_output=format_output( - "", - "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 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("level", type=int), default_output=format_output("Setting LED brightness level to {level}"), ) def set_led_brightness_level(self, level: int): """Set led brightness level (0..8).""" + if "led_brightness_level" not in self._get_mapping(): + raise AirPurifierMiotException( + "Unsupported led brightness level for model '%s'" % self.model + ) if level < 0 or level > 8: raise AirPurifierMiotException("Invalid brightness level: %s" % level) return self.set_property("led_brightness_level", level) + + +class AirPurifierMB4(AirPurifierMiot): + @deprecated("Use AirPurifierMiot") + def __init__(*args, **kwargs): + super().__init__(*args, **kwargs) diff --git a/miio/integrations/vacuum/roborock/vacuumcontainers.py b/miio/integrations/vacuum/roborock/vacuumcontainers.py index 7d7956869..182d7a2d4 100644 --- a/miio/integrations/vacuum/roborock/vacuumcontainers.py +++ b/miio/integrations/vacuum/roborock/vacuumcontainers.py @@ -322,7 +322,7 @@ def complete(self) -> bool: see also :func:`error`. """ - return bool(self.data["complete"] == 1) + return self.data["complete"] == 1 class ConsumableStatus(DeviceStatus): @@ -446,7 +446,7 @@ def ts(self) -> datetime: @property def enabled(self) -> bool: """True if the timer is active.""" - return bool(self.data[1] == "on") + return self.data[1] == "on" @property def cron(self) -> str: diff --git a/miio/protocol.py b/miio/protocol.py index 93e4a6900..3b922158a 100644 --- a/miio/protocol.py +++ b/miio/protocol.py @@ -133,7 +133,7 @@ def is_hello(x) -> bool: # not very nice, but we know that hellos are 32b of length val = x.get("length", x.header.value["length"]) - return bool(val == 32) + return val == 32 class TimeAdapter(Adapter): diff --git a/miio/tests/test_airpurifier_miot.py b/miio/tests/test_airpurifier_miot.py index e05877d45..ce83897e2 100644 --- a/miio/tests/test_airpurifier_miot.py +++ b/miio/tests/test_airpurifier_miot.py @@ -32,10 +32,47 @@ "button_pressed": "power", } +_INITIAL_STATE_MB4 = { + "power": True, + "aqi": 10, + "mode": 0, + "led_brightness_level": 1, + "buzzer": False, + "child_lock": False, + "filter_life_remaining": 80, + "filter_hours_used": 682, + "motor_speed": 354, + "button_pressed": "power", +} + +_INITIAL_STATE_VA2 = { + "power": True, + "aqi": 10, + "anion": True, + "average_aqi": 8, + "humidity": 62, + "temperature": 18.599999, + "fan_level": 2, + "mode": 0, + "led_brightness": 1, + "buzzer": False, + "child_lock": False, + "favorite_level": 10, + "filter_life_remaining": 80, + "filter_hours_used": 682, + "filter_left_time": 309, + "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 + if getattr(self, "state", None) is None: + self.state = _INITIAL_STATE self.return_values = { "get_prop": self._get_state, "set_power": lambda x: self._set_state("power", x), @@ -192,3 +229,126 @@ def child_lock(): self.device.set_child_lock(False) assert child_lock() is False + + def test_set_anion(self): + with pytest.raises(AirPurifierMiotException): + self.device.set_anion(True) + + +class DummyAirPurifierMiotMB4(DummyAirPurifierMiot): + def __init__(self, *args, **kwargs): + self._model = "zhimi.airpurifier.mb4" + self.state = _INITIAL_STATE_MB4 + super().__init__(*args, **kwargs) + + +@pytest.fixture(scope="function") +def airpurifierMB4(request): + request.cls.device = DummyAirPurifierMiotMB4() + + +@pytest.mark.usefixtures("airpurifierMB4") +class TestAirPurifierMB4(TestCase): + def test_status(self): + status = self.device.status() + assert status.is_on is _INITIAL_STATE_MB4["power"] + assert status.aqi == _INITIAL_STATE_MB4["aqi"] + assert status.average_aqi is None + assert status.humidity is None + assert status.temperature is None + assert status.fan_level is None + assert status.mode == OperationMode(_INITIAL_STATE_MB4["mode"]) + assert status.led is None + assert status.led_brightness is None + assert status.led_brightness_level == _INITIAL_STATE_MB4["led_brightness_level"] + assert status.buzzer == _INITIAL_STATE_MB4["buzzer"] + assert status.child_lock == _INITIAL_STATE_MB4["child_lock"] + assert status.favorite_level is None + assert ( + status.filter_life_remaining == _INITIAL_STATE_MB4["filter_life_remaining"] + ) + assert status.filter_hours_used == _INITIAL_STATE_MB4["filter_hours_used"] + assert status.use_time is None + assert status.purify_volume is None + assert status.motor_speed == _INITIAL_STATE_MB4["motor_speed"] + assert status.filter_rfid_product_id is None + assert status.filter_type is None + + def test_set_led_brightness_level(self): + def led_brightness_level(): + return self.device.status().led_brightness_level + + self.device.set_led_brightness_level(2) + assert led_brightness_level() == 2 + + def test_set_fan_level(self): + with pytest.raises(AirPurifierMiotException): + self.device.set_fan_level(0) + + def test_set_favorite_level(self): + with pytest.raises(AirPurifierMiotException): + self.device.set_favorite_level(0) + + def test_set_led_brightness(self): + with pytest.raises(AirPurifierMiotException): + self.device.set_led_brightness(LedBrightness.Bright) + + def test_set_led(self): + with pytest.raises(AirPurifierMiotException): + self.device.set_led(True) + + +class DummyAirPurifierMiotVA2(DummyAirPurifierMiot): + def __init__(self, *args, **kwargs): + self._model = "zhimi.airp.va2" + self.state = _INITIAL_STATE_VA2 + super().__init__(*args, **kwargs) + + +@pytest.fixture(scope="function") +def airpurifierVA2(request): + request.cls.device = DummyAirPurifierMiotVA2() + + +@pytest.mark.usefixtures("airpurifierVA2") +class TestAirPurifierVA2(TestCase): + def test_status(self): + status = self.device.status() + assert status.is_on is _INITIAL_STATE_VA2["power"] + assert status.anion == _INITIAL_STATE_VA2["anion"] + assert status.aqi == _INITIAL_STATE_VA2["aqi"] + assert status.average_aqi == _INITIAL_STATE_VA2["average_aqi"] + assert status.humidity == _INITIAL_STATE_VA2["humidity"] + assert status.temperature == 18.6 + assert status.fan_level == _INITIAL_STATE_VA2["fan_level"] + assert status.mode == OperationMode(_INITIAL_STATE_VA2["mode"]) + assert status.led is None + assert status.led_brightness == LedBrightness( + _INITIAL_STATE_VA2["led_brightness"] + ) + assert status.buzzer == _INITIAL_STATE_VA2["buzzer"] + assert status.child_lock == _INITIAL_STATE_VA2["child_lock"] + assert status.favorite_level == _INITIAL_STATE_VA2["favorite_level"] + assert ( + status.filter_life_remaining == _INITIAL_STATE_VA2["filter_life_remaining"] + ) + assert status.filter_hours_used == _INITIAL_STATE_VA2["filter_hours_used"] + assert status.filter_left_time == _INITIAL_STATE_VA2["filter_left_time"] + assert status.use_time is None + assert status.purify_volume == _INITIAL_STATE_VA2["purify_volume"] + assert status.motor_speed == _INITIAL_STATE_VA2["motor_speed"] + assert ( + status.filter_rfid_product_id + == _INITIAL_STATE_VA2["filter_rfid_product_id"] + ) + assert status.filter_type == FilterType.AntiBacterial + + def test_set_anion(self): + def anion(): + return self.device.status().anion + + self.device.set_anion(True) + assert anion() is True + + self.device.set_anion(False) + assert anion() is False diff --git a/miio/tests/test_airpurifier_miot_mb4.py b/miio/tests/test_airpurifier_miot_mb4.py deleted file mode 100644 index c95072e17..000000000 --- a/miio/tests/test_airpurifier_miot_mb4.py +++ /dev/null @@ -1,139 +0,0 @@ -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