From fa3c93ec6780f6a28b58eb274c41ff39beb21e0f Mon Sep 17 00:00:00 2001 From: smmoroz Date: Tue, 9 Jan 2024 03:35:12 +0300 Subject: [PATCH 01/13] Added zhimi.humidifier.ca6 support --- miio/__init__.py | 2 +- .../integrations/zhimi/humidifier/__init__.py | 1 + .../humidifier/airhumidifier_miot_ca6.py | 346 ++++++++++++++++++ 3 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py diff --git a/miio/__init__.py b/miio/__init__.py index 8e50b2aba..09e4aa5fe 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -89,7 +89,7 @@ from miio.integrations.zhimi.airpurifier import AirFresh, AirPurifier, AirPurifierMiot from miio.integrations.zhimi.fan import Fan, FanZA5 from miio.integrations.zhimi.heater import Heater, HeaterMiot -from miio.integrations.zhimi.humidifier import AirHumidifier, AirHumidifierMiot +from miio.integrations.zhimi.humidifier import AirHumidifier, AirHumidifierMiot, AirHumidifierMiotCA6 from miio.integrations.zimi.powerstrip import PowerStrip from miio.protocol import Message, Utils from miio.push_server import EventInfo, PushServer diff --git a/miio/integrations/zhimi/humidifier/__init__.py b/miio/integrations/zhimi/humidifier/__init__.py index 26b999c4f..7ae07ae35 100644 --- a/miio/integrations/zhimi/humidifier/__init__.py +++ b/miio/integrations/zhimi/humidifier/__init__.py @@ -1,3 +1,4 @@ # flake8: noqa from .airhumidifier import AirHumidifier from .airhumidifier_miot import AirHumidifierMiot +from .airhumidifier_miot_ca6 import AirHumidifierMiotCA6 diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py new file mode 100644 index 000000000..7adf80e0b --- /dev/null +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py @@ -0,0 +1,346 @@ +import enum +import logging +from typing import Any, Dict, Optional + +import click + +from miio import DeviceStatus, MiotDevice +from miio.click_common import EnumType, command, format_output + +_LOGGER = logging.getLogger(__name__) + + +SMARTMI_EVAPORATIVE_HUMIDIFIER_3 = "zhimi.humidifier.ca6" + + +_MAPPINGS = { + SMARTMI_EVAPORATIVE_HUMIDIFIER_3: { + # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:humidifier:0000A00E:zhimi-ca6:1 + # Air Humidifier (siid=2) + "power": {"siid": 2, "piid": 1}, # bool + "fault": {"siid": 2, "piid": 2}, # [0, 15] step 1 + "mode": {"siid": 2, "piid": 5}, # 0 - Fav, 1 - Auto, 2 - Sleep : TODO: Update OperationMode + "target_humidity": {"siid": 2, "piid": 6}, # [30, 60] step 1 : TODO: Default max in HA is 80%, update to 60% + "water_level": {"siid": 2, "piid": 7}, # 0 - empty/min, 1 - normal, 2 - full/max: TODO: Override water_level + "dry": {"siid": 2, "piid": 8}, # Automatic Air Drying, bool + "status": {"siid": 2, "piid": 9}, # 1 - Close, 2 - Work, 3 - Dry, 4 - Clean + # Environment (siid=3) + "temperature": {"siid": 3, "piid": 7}, # [-40, 125] step 0.1 + "humidity": {"siid": 3, "piid": 9}, # [0, 100] step 1 + # Alarm (siid=4) + "buzzer": {"siid": 4, "piid": 1}, + # Indicator Light (siid=5) + "led_brightness": {"siid": 5, "piid": 2}, # 0 - Off, 1 - Dim, 2 - Brightest + # Physical Control Locked (siid=6) + "child_lock": {"siid": 6, "piid": 1}, # bool + # Other (siid=7) + "actual_speed": {"siid": 7, "piid": 1}, # [0, 2000] step 1 + "coutry_code": {"siid": 7, "piid": 2}, # 82 - KR, 44 - EU, 81 - JP, 86 - CN, 886 - TW + "clean_mode": {"siid": 7, "piid": 5}, # bool + "self_clean_percent": {"siid": 7, "piid": 6}, # minutes, [0, 30] step 1 + "pump_state": {"siid": 7, "piid": 7}, # bool + "pump_cnt": {"siid": 7, "piid": 8}, # [0, 4000] step 1 + } +} + + +class OperationMode(enum.Enum): + Fav = 0 + Auto = 1 + Sleep = 2 + +class OperationStatus(enum.Enum): + Close = 1 + Work = 2 + Dry = 3 + Clean = 4 + +class LedBrightness(enum.Enum): + Off = 0 + Dim = 1 + Bright = 2 + + +class AirHumidifierMiotStatus(DeviceStatus): + """Container for status reports from the air humidifier. + + Xiaomi Smartmi Evaporation Air Humidifier 3 (zhimi.humidifier.ca6) respone (MIoT format):: + + [ + {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': True}, + {'did': 'fault', 'siid': 2, 'piid': 2, 'code': 0, 'value': 0}, + {'did': 'mode', 'siid': 2, 'piid': 5, 'code': 0, 'value': 0}, + {'did': 'target_humidity', 'siid': 2, 'piid': 6, 'code': 0, 'value': 50}, + {'did': 'water_level', 'siid': 2, 'piid': 7, 'code': 0, 'value': 1}, + {'did': 'dry', 'siid': 2, 'piid': 8, 'code': 0, 'value': True}, + {'did': 'status', 'siid': 2, 'piid': 9, 'code': 0, 'value': 2}, + {'did': 'temperature', 'siid': 3, 'piid': 7, 'code': 0, 'value': 19.0}, + {'did': 'humidity', 'siid': 3, 'piid': 9, 'code': 0, 'value': 51}, + {'did': 'buzzer', 'siid': 4, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'led_brightness', 'siid': 5, 'piid': 2, 'code': 0, 'value': 2}, + {'did': 'child_lock', 'siid': 6, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'actual_speed', 'siid': 7, 'piid': 1, 'code': 0, 'value': 1100}, + {'did': 'clean_mode', 'siid': 7, 'piid': 5, 'code': 0, 'value': False} + ] + """ + + def __init__(self, data: Dict[str, Any]) -> None: + self.data = data + _LOGGER.debug("Status : %s", repr(data)) + + # Air Humidifier + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.data["power"] + + @property + def power(self) -> str: + """Return power state.""" + return "on" if self.is_on else "off" + + @property + def error(self) -> int: + """Return error state.""" + return self.data["fault"] + + @property + def mode(self) -> OperationMode: + """Return current operation mode.""" + + try: + mode = OperationMode(self.data["mode"]) + except ValueError as e: + _LOGGER.exception("Cannot parse mode: %s", e) + return OperationMode.Auto + + return mode + + @property + def target_humidity(self) -> int: + """Return target humidity.""" + return self.data["target_humidity"] + + @property + def water_level(self) -> Optional[int]: + """Return current water level (empty/min, normal, full/max). + + 0 - empty/min, 1 - normal, 2 - full/max + """ + water_level = self.data["water_level"] + if water_level == 0: + return 0 + elif water_level == 1: + return 50 + elif water_level == 2: + return 100 + return None + + @property + def dry(self) -> Optional[bool]: + """Return True if dry mode is on.""" + if self.data["dry"] is not None: + return self.data["dry"] + return None + + @property + def status(self) -> OperationStatus: + """Return current status.""" + + try: + status = OperationStatus(self.data["status"]) + except ValueError as e: + _LOGGER.exception("Cannot parse status: %s", e) + return OperationStatus.Close + + return status + + # Environment + + @property + def humidity(self) -> int: + """Return current humidity.""" + return self.data["humidity"] + + @property + def temperature(self) -> Optional[float]: + """Return current temperature, if available.""" + if self.data["temperature"] is not None: + return round(self.data["temperature"], 1) + return None + + # Alarm + + @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 + + # Indicator Light + + @property + def led_brightness(self) -> Optional[LedBrightness]: + """Return brightness of the LED.""" + + if self.data["led_brightness"] is not None: + try: + return LedBrightness(self.data["led_brightness"]) + except ValueError as e: + _LOGGER.exception("Cannot parse led_brightness: %s", e) + return None + + return None + + # Physical Control Locked + + @property + def child_lock(self) -> bool: + """Return True if child lock is on.""" + return self.data["child_lock"] + + # Other + + @property + def actual_speed(self) -> int: + """Return real speed of the motor.""" + return self.data["actual_speed"] + + @property + def clean_mode(self) -> bool: + """Return True if clean mode is active.""" + return self.data["clean_mode"] + + @property + def clean_percent(self) -> int: + """Return time in minutes (from 0 to 30) of self-cleaning procedure.""" + return self.data["self_clean_percent"] + + @property + def pump_state(self) -> bool: + """Return pump statue.""" + return self.data["pump_state"] + + @property + def pump_cnt(self) -> int: + """Return pump-cnt.""" + return self.data["pump_cnt"] + +class AirHumidifierMiotCA6(MiotDevice): + """Main class representing the air humidifier which uses MIoT protocol.""" + + _mappings = _MAPPINGS + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Error: {result.error}\n" + "Target Humidity: {result.target_humidity} %\n" + "Humidity: {result.humidity} %\n" + "Temperature: {result.temperature} °C\n" + "Water Level: {result.water_level} %\n" + "Mode: {result.mode}\n" + "Status: {result.status}\n" + "LED brightness: {result.led_brightness}\n" + "Buzzer: {result.buzzer}\n" + "Child lock: {result.child_lock}\n" + "Dry mode: {result.dry}\n" + "Actual motor speed: {result.actual_speed} rpm\n" + "Clean mode: {result.clean_mode}\n" + "Clean percent: {result.clean_percent} minutes\n" + "Pump state: {result.pump_state}\n" + "Pump cnt: {result.pump_cnt}\n", + ) + ) + def status(self) -> AirHumidifierMiotStatus: + """Retrieve properties.""" + + return AirHumidifierMiotStatus( + { + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping() + } + ) + + @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("humidity", type=int), + default_output=format_output("Setting target humidity {humidity}%"), + ) + def set_target_humidity(self, humidity: int): + """Set target humidity.""" + if humidity < 30 or humidity > 60: + raise ValueError( + "Invalid target humidity: %s. Must be between 30 and 60" % humidity + ) + return self.set_property("target_humidity", humidity) + + @command( + click.argument("mode", type=EnumType(OperationMode)), + default_output=format_output("Setting mode to '{mode.value}'"), + ) + def set_mode(self, mode: OperationMode): + """Set working mode.""" + return self.set_property("mode", mode.value) + + @command( + click.argument("brightness", type=EnumType(LedBrightness)), + 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("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) + + @command( + click.argument("dry", type=bool), + default_output=format_output( + lambda dry: "Turning on dry mode" if dry else "Turning off dry mode" + ), + ) + def set_dry(self, dry: bool): + """Set dry mode on/off.""" + return self.set_property("dry", dry) + + @command( + click.argument("clean_mode", type=bool), + default_output=format_output( + lambda clean_mode: "Turning on clean mode" + if clean_mode + else "Turning off clean mode" + ), + ) + def set_clean_mode(self, clean_mode: bool): + """Set clean mode on/off.""" + return self.set_property("clean_mode", clean_mode) From f2a051fae2121e3be0657851d3e3667ae324e8f9 Mon Sep 17 00:00:00 2001 From: smmoroz Date: Tue, 9 Jan 2024 03:51:22 +0300 Subject: [PATCH 02/13] Fix black formatting --- miio/__init__.py | 6 ++- .../humidifier/airhumidifier_miot_ca6.py | 37 +++++++++++++------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/miio/__init__.py b/miio/__init__.py index 09e4aa5fe..87f8e3dc1 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -89,7 +89,11 @@ from miio.integrations.zhimi.airpurifier import AirFresh, AirPurifier, AirPurifierMiot from miio.integrations.zhimi.fan import Fan, FanZA5 from miio.integrations.zhimi.heater import Heater, HeaterMiot -from miio.integrations.zhimi.humidifier import AirHumidifier, AirHumidifierMiot, AirHumidifierMiotCA6 +from miio.integrations.zhimi.humidifier import ( + AirHumidifier, + AirHumidifierMiot, + AirHumidifierMiotCA6, +) from miio.integrations.zimi.powerstrip import PowerStrip from miio.protocol import Message, Utils from miio.push_server import EventInfo, PushServer diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py index 7adf80e0b..c285338d9 100644 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py @@ -19,11 +19,20 @@ # Air Humidifier (siid=2) "power": {"siid": 2, "piid": 1}, # bool "fault": {"siid": 2, "piid": 2}, # [0, 15] step 1 - "mode": {"siid": 2, "piid": 5}, # 0 - Fav, 1 - Auto, 2 - Sleep : TODO: Update OperationMode - "target_humidity": {"siid": 2, "piid": 6}, # [30, 60] step 1 : TODO: Default max in HA is 80%, update to 60% - "water_level": {"siid": 2, "piid": 7}, # 0 - empty/min, 1 - normal, 2 - full/max: TODO: Override water_level + "mode": { + "siid": 2, + "piid": 5, + }, # 0 - Fav, 1 - Auto, 2 - Sleep : TODO: Update OperationMode + "target_humidity": { + "siid": 2, + "piid": 6, + }, # [30, 60] step 1 : TODO: Default max in HA is 80%, update to 60% + "water_level": { + "siid": 2, + "piid": 7, + }, # 0 - empty/min, 1 - normal, 2 - full/max: TODO: Override water_level "dry": {"siid": 2, "piid": 8}, # Automatic Air Drying, bool - "status": {"siid": 2, "piid": 9}, # 1 - Close, 2 - Work, 3 - Dry, 4 - Clean + "status": {"siid": 2, "piid": 9}, # 1 - Close, 2 - Work, 3 - Dry, 4 - Clean # Environment (siid=3) "temperature": {"siid": 3, "piid": 7}, # [-40, 125] step 0.1 "humidity": {"siid": 3, "piid": 9}, # [0, 100] step 1 @@ -35,11 +44,14 @@ "child_lock": {"siid": 6, "piid": 1}, # bool # Other (siid=7) "actual_speed": {"siid": 7, "piid": 1}, # [0, 2000] step 1 - "coutry_code": {"siid": 7, "piid": 2}, # 82 - KR, 44 - EU, 81 - JP, 86 - CN, 886 - TW + "coutry_code": { + "siid": 7, + "piid": 2, + }, # 82 - KR, 44 - EU, 81 - JP, 86 - CN, 886 - TW "clean_mode": {"siid": 7, "piid": 5}, # bool - "self_clean_percent": {"siid": 7, "piid": 6}, # minutes, [0, 30] step 1 - "pump_state": {"siid": 7, "piid": 7}, # bool - "pump_cnt": {"siid": 7, "piid": 8}, # [0, 4000] step 1 + "self_clean_percent": {"siid": 7, "piid": 6}, # minutes, [0, 30] step 1 + "pump_state": {"siid": 7, "piid": 7}, # bool + "pump_cnt": {"siid": 7, "piid": 8}, # [0, 4000] step 1 } } @@ -49,12 +61,14 @@ class OperationMode(enum.Enum): Auto = 1 Sleep = 2 + class OperationStatus(enum.Enum): Close = 1 Work = 2 Dry = 3 Clean = 4 + class LedBrightness(enum.Enum): Off = 0 Dim = 1 @@ -143,7 +157,7 @@ def dry(self) -> Optional[bool]: if self.data["dry"] is not None: return self.data["dry"] return None - + @property def status(self) -> OperationStatus: """Return current status.""" @@ -155,7 +169,7 @@ def status(self) -> OperationStatus: return OperationStatus.Close return status - + # Environment @property @@ -212,7 +226,7 @@ def actual_speed(self) -> int: def clean_mode(self) -> bool: """Return True if clean mode is active.""" return self.data["clean_mode"] - + @property def clean_percent(self) -> int: """Return time in minutes (from 0 to 30) of self-cleaning procedure.""" @@ -228,6 +242,7 @@ def pump_cnt(self) -> int: """Return pump-cnt.""" return self.data["pump_cnt"] + class AirHumidifierMiotCA6(MiotDevice): """Main class representing the air humidifier which uses MIoT protocol.""" From e993781fd714e9c18a946bde409ebe485a1822e3 Mon Sep 17 00:00:00 2001 From: smmoroz Date: Tue, 9 Jan 2024 03:57:39 +0300 Subject: [PATCH 03/13] Fix flake8 --- .../zhimi/humidifier/airhumidifier_miot_ca6.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py index c285338d9..25022e192 100644 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py @@ -143,13 +143,7 @@ def water_level(self) -> Optional[int]: 0 - empty/min, 1 - normal, 2 - full/max """ water_level = self.data["water_level"] - if water_level == 0: - return 0 - elif water_level == 1: - return 50 - elif water_level == 2: - return 100 - return None + return {0: "0", 1: "50", 2: "100"}.get(water_level) @property def dry(self) -> Optional[bool]: From f0d79b503444352c6c25ecde32f0e3d328cb1643 Mon Sep 17 00:00:00 2001 From: smmoroz Date: Tue, 9 Jan 2024 04:02:16 +0300 Subject: [PATCH 04/13] Fix flake8 --- miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py index 25022e192..44f5735b2 100644 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py @@ -143,7 +143,7 @@ def water_level(self) -> Optional[int]: 0 - empty/min, 1 - normal, 2 - full/max """ water_level = self.data["water_level"] - return {0: "0", 1: "50", 2: "100"}.get(water_level) + return {0: 0, 1: 50, 2: 100}.get(water_level) @property def dry(self) -> Optional[bool]: From af30d8f36690739488f9e296f79d1f61dce5e137 Mon Sep 17 00:00:00 2001 From: smmoroz Date: Thu, 11 Jan 2024 16:29:14 +0300 Subject: [PATCH 05/13] Added ca6 test, fixed some bugs --- .../humidifier/airhumidifier_miot_ca6.py | 21 +- .../tests/test_airhumidifier_miot_ca6.py | 197 ++++++++++++++++++ 2 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py index 44f5735b2..530bb2cf7 100644 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py @@ -22,15 +22,15 @@ "mode": { "siid": 2, "piid": 5, - }, # 0 - Fav, 1 - Auto, 2 - Sleep : TODO: Update OperationMode + }, # 0 - Fav, 1 - Auto, 2 - Sleep "target_humidity": { "siid": 2, "piid": 6, - }, # [30, 60] step 1 : TODO: Default max in HA is 80%, update to 60% + }, # [30, 60] step 1 "water_level": { "siid": 2, "piid": 7, - }, # 0 - empty/min, 1 - normal, 2 - full/max: TODO: Override water_level + }, # 0 - empty/min, 1 - normal, 2 - full/max "dry": {"siid": 2, "piid": 8}, # Automatic Air Drying, bool "status": {"siid": 2, "piid": 9}, # 1 - Close, 2 - Work, 3 - Dry, 4 - Clean # Environment (siid=3) @@ -75,7 +75,7 @@ class LedBrightness(enum.Enum): Bright = 2 -class AirHumidifierMiotStatus(DeviceStatus): +class AirHumidifierMiotCA6Status(DeviceStatus): """Container for status reports from the air humidifier. Xiaomi Smartmi Evaporation Air Humidifier 3 (zhimi.humidifier.ca6) respone (MIoT format):: @@ -95,6 +95,9 @@ class AirHumidifierMiotStatus(DeviceStatus): {'did': 'child_lock', 'siid': 6, 'piid': 1, 'code': 0, 'value': False}, {'did': 'actual_speed', 'siid': 7, 'piid': 1, 'code': 0, 'value': 1100}, {'did': 'clean_mode', 'siid': 7, 'piid': 5, 'code': 0, 'value': False} + {'did': 'self_clean_percent, 'siid': 7, 'piid': 6, 'code': 0, 'value': 0}, + {'did': 'pump_state, 'siid': 7, 'piid': 7, 'code': 0, 'value': False}, + {'did': 'pump_cnt', 'siid': 7, 'piid': 8, 'code': 0, 'value': 1000}, ] """ @@ -238,7 +241,7 @@ def pump_cnt(self) -> int: class AirHumidifierMiotCA6(MiotDevice): - """Main class representing the air humidifier which uses MIoT protocol.""" + """Main class representing zhimi.humidifier.ca6 air humidifier which uses MIoT protocol.""" _mappings = _MAPPINGS @@ -264,10 +267,10 @@ class AirHumidifierMiotCA6(MiotDevice): "Pump cnt: {result.pump_cnt}\n", ) ) - def status(self) -> AirHumidifierMiotStatus: + def status(self) -> AirHumidifierMiotCA6Status: """Retrieve properties.""" - return AirHumidifierMiotStatus( + return AirHumidifierMiotCA6Status( { prop["did"]: prop["value"] if prop["code"] == 0 else None for prop in self.get_properties_for_mapping() @@ -294,7 +297,9 @@ def set_target_humidity(self, humidity: int): raise ValueError( "Invalid target humidity: %s. Must be between 30 and 60" % humidity ) - return self.set_property("target_humidity", humidity) + # HA sends humidity in float, e.g. 45.0 + # ca6 does accept only int values, e.g. 45 + return self.set_property("target_humidity", int(humidity)) @command( click.argument("mode", type=EnumType(OperationMode)), diff --git a/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py new file mode 100644 index 000000000..c20401b8a --- /dev/null +++ b/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py @@ -0,0 +1,197 @@ +import pytest + +from miio.tests.dummies import DummyMiotDevice + +from .. import AirHumidifierMiotCA6 +from ..airhumidifier_miot_ca6 import LedBrightness, OperationMode, OperationStatus + +_INITIAL_STATE = { + "power": True, + "fault": 0, + "mode": 0, + "target_humidity": 40, + "water_level": 1, + "dry": True, + "status": 2, + "temperature": 19, + "humidity": 51, + "buzzer": False, + "led_brightness": 2, + "child_lock": False, + "actual_speed": 1100, + "clean_mode": False, + "self_clean_percent": 0, + "pump_state": False, + "pump_cnt": 1000, +} + +class DummyAirHumidifierMiotCA6(DummyMiotDevice, AirHumidifierMiotCA6): + 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_speed": lambda x: self._set_state("speed_level", x), + "set_target_humidity": lambda x: self._set_state("target_humidity", x), + "set_mode": lambda x: self._set_state("mode", x), + "set_led_brightness": lambda x: self._set_state("led_brightness", x), + "set_buzzer": lambda x: self._set_state("buzzer", x), + "set_child_lock": lambda x: self._set_state("child_lock", x), + "set_dry": lambda x: self._set_state("dry", x), + "set_clean_mode": lambda x: self._set_state("clean_mode", x), + } + super().__init__(*args, **kwargs) + + +@pytest.fixture() +def dev(request): + yield DummyAirHumidifierMiotCA6() + + +def test_on(dev): + dev.off() # ensure off + assert dev.status().is_on is False + + dev.on() + assert dev.status().is_on is True + + +def test_off(dev): + dev.on() # ensure on + assert dev.status().is_on is True + + dev.off() + assert dev.status().is_on is False + + +def test_status(dev): + status = dev.status() + assert status.is_on is _INITIAL_STATE["power"] + assert status.error == _INITIAL_STATE["fault"] + assert status.mode == OperationMode(_INITIAL_STATE["mode"]) + assert status.target_humidity == _INITIAL_STATE["target_humidity"] + print("status = ", status) + print("water level %s == %s", status.water_level, _INITIAL_STATE["water_level"]) + assert status.water_level == {0: 0, 1: 50, 2: 100}.get(int(_INITIAL_STATE["water_level"])) + assert status.dry == _INITIAL_STATE["dry"] + assert status.temperature == _INITIAL_STATE["temperature"] + assert status.humidity == _INITIAL_STATE["humidity"] + assert status.buzzer == _INITIAL_STATE["buzzer"] + assert status.led_brightness == LedBrightness(_INITIAL_STATE["led_brightness"]) + assert status.child_lock == _INITIAL_STATE["child_lock"] + assert status.actual_speed == _INITIAL_STATE["actual_speed"] + # TODO : add other props + + +def test_set_target_humidity(dev): + def target_humidity(): + return dev.status().target_humidity + + dev.set_target_humidity(30) + assert target_humidity() == 30 + dev.set_target_humidity(60) + assert target_humidity() == 60 + + with pytest.raises(ValueError): + dev.set_target_humidity(29) + + with pytest.raises(ValueError): + dev.set_target_humidity(61) + + +def test_set_mode(dev): + def mode(): + return dev.status().mode + + dev.set_mode(OperationMode.Auto) + assert mode() == OperationMode.Auto + + dev.set_mode(OperationMode.Fav) + assert mode() == OperationMode.Fav + + dev.set_mode(OperationMode.Sleep) + assert mode() == OperationMode.Sleep + + +def test_set_led_brightness(dev): + def led_brightness(): + return dev.status().led_brightness + + dev.set_led_brightness(LedBrightness.Bright) + assert led_brightness() == LedBrightness.Bright + + dev.set_led_brightness(LedBrightness.Dim) + assert led_brightness() == LedBrightness.Dim + + dev.set_led_brightness(LedBrightness.Off) + assert led_brightness() == LedBrightness.Off + + +def test_set_buzzer(dev): + def buzzer(): + return dev.status().buzzer + + dev.set_buzzer(True) + assert buzzer() is True + + dev.set_buzzer(False) + assert buzzer() is False + + +def test_set_child_lock(dev): + def child_lock(): + return dev.status().child_lock + + dev.set_child_lock(True) + assert child_lock() is True + + dev.set_child_lock(False) + assert child_lock() is False + + +def test_set_dry(dev): + def dry(): + return dev.status().dry + + dev.set_dry(True) + assert dry() is True + + dev.set_dry(False) + assert dry() is False + + +def test_set_clean_mode(dev): + def clean_mode(): + return dev.status().clean_mode + + dev.set_clean_mode(True) + assert clean_mode() is True + + dev.set_clean_mode(False) + assert clean_mode() is False + + +@pytest.mark.parametrize( + "given,expected", [(0, 0), (1, 50), (2, 100)] +) +def test_water_level(dev, given, expected): + dev.set_property("water_level", given) + assert dev.status().water_level == expected + + + +def test_status(dev): + def status(): + return dev.status().status + + dev.set_property("status", OperationStatus.Close) + assert status() == OperationStatus.Close + + dev.set_property("status", OperationStatus.Work) + assert status() == OperationStatus.Work + + dev.set_property("status", OperationStatus.Dry) + assert status() == OperationStatus.Dry + + dev.set_property("status", OperationStatus.Clean) + assert status() == OperationStatus.Clean \ No newline at end of file From 9327d63507c4f3e59f95a5818dcb218516dfc0fe Mon Sep 17 00:00:00 2001 From: smmoroz Date: Thu, 11 Jan 2024 16:37:19 +0300 Subject: [PATCH 06/13] Few more fixes --- .../tests/test_airhumidifier_miot_ca6.py | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py index c20401b8a..a642390be 100644 --- a/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py +++ b/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py @@ -25,6 +25,7 @@ "pump_cnt": 1000, } + class DummyAirHumidifierMiotCA6(DummyMiotDevice, AirHumidifierMiotCA6): def __init__(self, *args, **kwargs): self.state = _INITIAL_STATE @@ -70,9 +71,9 @@ def test_status(dev): assert status.error == _INITIAL_STATE["fault"] assert status.mode == OperationMode(_INITIAL_STATE["mode"]) assert status.target_humidity == _INITIAL_STATE["target_humidity"] - print("status = ", status) - print("water level %s == %s", status.water_level, _INITIAL_STATE["water_level"]) - assert status.water_level == {0: 0, 1: 50, 2: 100}.get(int(_INITIAL_STATE["water_level"])) + assert status.water_level == {0: 0, 1: 50, 2: 100}.get( + int(_INITIAL_STATE["water_level"]) + ) assert status.dry == _INITIAL_STATE["dry"] assert status.temperature == _INITIAL_STATE["temperature"] assert status.humidity == _INITIAL_STATE["humidity"] @@ -80,7 +81,11 @@ def test_status(dev): assert status.led_brightness == LedBrightness(_INITIAL_STATE["led_brightness"]) assert status.child_lock == _INITIAL_STATE["child_lock"] assert status.actual_speed == _INITIAL_STATE["actual_speed"] - # TODO : add other props + assert status.actual_speed == _INITIAL_STATE["actual_speed"] + assert status.clean_mode == _INITIAL_STATE["clean_mode"] + assert status.self_clean_percent == _INITIAL_STATE["self_clean_percent"] + assert status.pump_state == _INITIAL_STATE["pump_state"] + assert status.pump_cnt == _INITIAL_STATE["pump_cnt"] def test_set_target_humidity(dev): @@ -171,27 +176,24 @@ def clean_mode(): assert clean_mode() is False -@pytest.mark.parametrize( - "given,expected", [(0, 0), (1, 50), (2, 100)] -) +@pytest.mark.parametrize("given,expected", [(0, 0), (1, 50), (2, 100)]) def test_water_level(dev, given, expected): dev.set_property("water_level", given) assert dev.status().water_level == expected - -def test_status(dev): - def status(): +def test_op_status(dev): + def op_status(): return dev.status().status dev.set_property("status", OperationStatus.Close) - assert status() == OperationStatus.Close + assert op_status() == OperationStatus.Close dev.set_property("status", OperationStatus.Work) - assert status() == OperationStatus.Work + assert op_status() == OperationStatus.Work dev.set_property("status", OperationStatus.Dry) - assert status() == OperationStatus.Dry - + assert op_status() == OperationStatus.Dry + dev.set_property("status", OperationStatus.Clean) - assert status() == OperationStatus.Clean \ No newline at end of file + assert op_status() == OperationStatus.Clean From 37a3eea95d7db8ed99daefbbfab0a32d4ab7fa41 Mon Sep 17 00:00:00 2001 From: smmoroz Date: Thu, 11 Jan 2024 16:47:06 +0300 Subject: [PATCH 07/13] Few more fixes --- miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py index 530bb2cf7..7f0f12d02 100644 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py @@ -225,7 +225,7 @@ def clean_mode(self) -> bool: return self.data["clean_mode"] @property - def clean_percent(self) -> int: + def self_clean_percent(self) -> int: """Return time in minutes (from 0 to 30) of self-cleaning procedure.""" return self.data["self_clean_percent"] @@ -262,7 +262,7 @@ class AirHumidifierMiotCA6(MiotDevice): "Dry mode: {result.dry}\n" "Actual motor speed: {result.actual_speed} rpm\n" "Clean mode: {result.clean_mode}\n" - "Clean percent: {result.clean_percent} minutes\n" + "Self clean percent: {result.self_clean_percent} minutes\n" "Pump state: {result.pump_state}\n" "Pump cnt: {result.pump_cnt}\n", ) From f4e04c5589e927c41afd607b3ae4907a8ea30958 Mon Sep 17 00:00:00 2001 From: smmoroz Date: Sat, 13 Jan 2024 12:49:32 +0300 Subject: [PATCH 08/13] Fix country-code PIID --- miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py index 7f0f12d02..f80a595db 100644 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py @@ -46,7 +46,7 @@ "actual_speed": {"siid": 7, "piid": 1}, # [0, 2000] step 1 "coutry_code": { "siid": 7, - "piid": 2, + "piid": 4, }, # 82 - KR, 44 - EU, 81 - JP, 86 - CN, 886 - TW "clean_mode": {"siid": 7, "piid": 5}, # bool "self_clean_percent": {"siid": 7, "piid": 6}, # minutes, [0, 30] step 1 From 1b0b9017527a770e65022394ef0a6bd8c8e470c4 Mon Sep 17 00:00:00 2001 From: smmoroz Date: Tue, 30 Jan 2024 23:47:12 +0300 Subject: [PATCH 09/13] Fix typo --- miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py index f80a595db..3d8a3742b 100644 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py @@ -44,7 +44,7 @@ "child_lock": {"siid": 6, "piid": 1}, # bool # Other (siid=7) "actual_speed": {"siid": 7, "piid": 1}, # [0, 2000] step 1 - "coutry_code": { + "country_code": { "siid": 7, "piid": 4, }, # 82 - KR, 44 - EU, 81 - JP, 86 - CN, 886 - TW From cb2297fb79e2328ab8d58568298af074b913dc30 Mon Sep 17 00:00:00 2001 From: smmoroz Date: Wed, 31 Jan 2024 01:06:07 +0300 Subject: [PATCH 10/13] retrigger checks From 7018a74cb886febc14d232f021494bb06ca72cdd Mon Sep 17 00:00:00 2001 From: smmoroz Date: Sun, 18 Feb 2024 21:15:07 +0300 Subject: [PATCH 11/13] Merging CA6 into airhumidifier_miot.py --- .../integrations/zhimi/humidifier/__init__.py | 3 +- .../zhimi/humidifier/airhumidifier_miot.py | 342 +++++++++++++++++ .../humidifier/airhumidifier_miot_ca6.py | 360 ------------------ .../tests/test_airhumidifier_miot_ca6.py | 32 +- 4 files changed, 359 insertions(+), 378 deletions(-) delete mode 100644 miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py diff --git a/miio/integrations/zhimi/humidifier/__init__.py b/miio/integrations/zhimi/humidifier/__init__.py index 7ae07ae35..b56912279 100644 --- a/miio/integrations/zhimi/humidifier/__init__.py +++ b/miio/integrations/zhimi/humidifier/__init__.py @@ -1,4 +1,3 @@ # flake8: noqa from .airhumidifier import AirHumidifier -from .airhumidifier_miot import AirHumidifierMiot -from .airhumidifier_miot_ca6 import AirHumidifierMiotCA6 +from .airhumidifier_miot import AirHumidifierMiot, AirHumidifierMiotCA6 diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot.py index e0543aa96..beeedca07 100644 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot.py +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot.py @@ -11,6 +11,7 @@ SMARTMI_EVAPORATIVE_HUMIDIFIER_2 = "zhimi.humidifier.ca4" +SMARTMI_EVAPORATIVE_HUMIDIFIER_3 = "zhimi.humidifier.ca6" _MAPPINGS = { @@ -44,6 +45,49 @@ } +_MAPPINGS_CA6 = { + SMARTMI_EVAPORATIVE_HUMIDIFIER_3: { + # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:humidifier:0000A00E:zhimi-ca6:1 + # Air Humidifier (siid=2) + "power": {"siid": 2, "piid": 1}, # bool + "fault": {"siid": 2, "piid": 2}, # [0, 15] step 1 + "mode": { + "siid": 2, + "piid": 5, + }, # 0 - Fav, 1 - Auto, 2 - Sleep + "target_humidity": { + "siid": 2, + "piid": 6, + }, # [30, 60] step 1 + "water_level": { + "siid": 2, + "piid": 7, + }, # 0 - empty/min, 1 - normal, 2 - full/max + "dry": {"siid": 2, "piid": 8}, # Automatic Air Drying, bool + "status": {"siid": 2, "piid": 9}, # 1 - Close, 2 - Work, 3 - Dry, 4 - Clean + # Environment (siid=3) + "temperature": {"siid": 3, "piid": 7}, # [-40, 125] step 0.1 + "humidity": {"siid": 3, "piid": 9}, # [0, 100] step 1 + # Alarm (siid=4) + "buzzer": {"siid": 4, "piid": 1}, + # Indicator Light (siid=5) + "led_brightness": {"siid": 5, "piid": 2}, # 0 - Off, 1 - Dim, 2 - Brightest + # Physical Control Locked (siid=6) + "child_lock": {"siid": 6, "piid": 1}, # bool + # Other (siid=7) + "actual_speed": {"siid": 7, "piid": 1}, # [0, 2000] step 1 + "country_code": { + "siid": 7, + "piid": 4, + }, # 82 - KR, 44 - EU, 81 - JP, 86 - CN, 886 - TW + "clean_mode": {"siid": 7, "piid": 5}, # bool + "self_clean_percent": {"siid": 7, "piid": 6}, # minutes, [0, 30] step 1 + "pump_state": {"siid": 7, "piid": 7}, # bool + "pump_cnt": {"siid": 7, "piid": 8}, # [0, 4000] step 1 + } +} + + class OperationMode(enum.Enum): Auto = 0 Low = 1 @@ -51,6 +95,19 @@ class OperationMode(enum.Enum): High = 3 +class OperationModeCA6(enum.Enum): + Fav = 0 + Auto = 1 + Sleep = 2 + + +class OperationStatusCA6(enum.Enum): + Close = 1 + Work = 2 + Dry = 3 + Clean = 4 + + class LedBrightness(enum.Enum): Off = 0 Dim = 1 @@ -381,3 +438,288 @@ def set_dry(self, dry: bool): def set_clean_mode(self, clean_mode: bool): """Set clean mode on/off.""" return self.set_property("clean_mode", clean_mode) + + +class AirHumidifierMiotCA6Status(DeviceStatus): + """Container for status reports from the air humidifier. + + Xiaomi Smartmi Evaporation Air Humidifier 3 (zhimi.humidifier.ca6) respone (MIoT format):: + + [ + {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': True}, + {'did': 'fault', 'siid': 2, 'piid': 2, 'code': 0, 'value': 0}, + {'did': 'mode', 'siid': 2, 'piid': 5, 'code': 0, 'value': 0}, + {'did': 'target_humidity', 'siid': 2, 'piid': 6, 'code': 0, 'value': 50}, + {'did': 'water_level', 'siid': 2, 'piid': 7, 'code': 0, 'value': 1}, + {'did': 'dry', 'siid': 2, 'piid': 8, 'code': 0, 'value': True}, + {'did': 'status', 'siid': 2, 'piid': 9, 'code': 0, 'value': 2}, + {'did': 'temperature', 'siid': 3, 'piid': 7, 'code': 0, 'value': 19.0}, + {'did': 'humidity', 'siid': 3, 'piid': 9, 'code': 0, 'value': 51}, + {'did': 'buzzer', 'siid': 4, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'led_brightness', 'siid': 5, 'piid': 2, 'code': 0, 'value': 2}, + {'did': 'child_lock', 'siid': 6, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'actual_speed', 'siid': 7, 'piid': 1, 'code': 0, 'value': 1100}, + {'did': 'clean_mode', 'siid': 7, 'piid': 5, 'code': 0, 'value': False} + {'did': 'self_clean_percent, 'siid': 7, 'piid': 6, 'code': 0, 'value': 0}, + {'did': 'pump_state, 'siid': 7, 'piid': 7, 'code': 0, 'value': False}, + {'did': 'pump_cnt', 'siid': 7, 'piid': 8, 'code': 0, 'value': 1000}, + ] + """ + + def __init__(self, data: Dict[str, Any]) -> None: + self.data = data + _LOGGER.debug("Status CA6: %s", repr(data)) + + # Air Humidifier 3 + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.data["power"] + + @property + def power(self) -> str: + """Return power state.""" + return "on" if self.is_on else "off" + + @property + def error(self) -> int: + """Return error state.""" + return self.data["fault"] + + @property + def mode(self) -> OperationModeCA6: + """Return current operation mode.""" + + try: + mode = OperationModeCA6(self.data["mode"]) + except ValueError as e: + _LOGGER.exception("Cannot parse mode: %s", e) + return OperationModeCA6.Auto + + return mode + + @property + def target_humidity(self) -> int: + """Return target humidity.""" + return self.data["target_humidity"] + + @property + def water_level(self) -> Optional[int]: + """Return current water level (empty/min, normal, full/max). + + 0 - empty/min, 1 - normal, 2 - full/max + """ + water_level = self.data["water_level"] + return {0: 0, 1: 50, 2: 100}.get(water_level) + + @property + def dry(self) -> Optional[bool]: + """Return True if dry mode is on.""" + if self.data["dry"] is not None: + return self.data["dry"] + return None + + @property + def status(self) -> OperationStatusCA6: + """Return current status.""" + + try: + status = OperationStatusCA6(self.data["status"]) + except ValueError as e: + _LOGGER.exception("Cannot parse status: %s", e) + return OperationStatusCA6.Close + + return status + + # Environment + + @property + def humidity(self) -> int: + """Return current humidity.""" + return self.data["humidity"] + + @property + def temperature(self) -> Optional[float]: + """Return current temperature, if available.""" + if self.data["temperature"] is not None: + return round(self.data["temperature"], 1) + return None + + # Alarm + + @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 + + # Indicator Light + + @property + def led_brightness(self) -> Optional[LedBrightness]: + """Return brightness of the LED.""" + + if self.data["led_brightness"] is not None: + try: + return LedBrightness(self.data["led_brightness"]) + except ValueError as e: + _LOGGER.exception("Cannot parse led_brightness: %s", e) + return None + + return None + + # Physical Control Locked + + @property + def child_lock(self) -> bool: + """Return True if child lock is on.""" + return self.data["child_lock"] + + # Other + + @property + def actual_speed(self) -> int: + """Return real speed of the motor.""" + return self.data["actual_speed"] + + @property + def clean_mode(self) -> bool: + """Return True if clean mode is active.""" + return self.data["clean_mode"] + + @property + def self_clean_percent(self) -> int: + """Return time in minutes (from 0 to 30) of self-cleaning procedure.""" + return self.data["self_clean_percent"] + + @property + def pump_state(self) -> bool: + """Return pump statue.""" + return self.data["pump_state"] + + @property + def pump_cnt(self) -> int: + """Return pump-cnt.""" + return self.data["pump_cnt"] + + +class AirHumidifierMiotCA6(MiotDevice): + """Main class representing zhimi.humidifier.ca6 air humidifier which uses MIoT protocol.""" + + _mappings = _MAPPINGS_CA6 + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Error: {result.error}\n" + "Target Humidity: {result.target_humidity} %\n" + "Humidity: {result.humidity} %\n" + "Temperature: {result.temperature} °C\n" + "Water Level: {result.water_level} %\n" + "Mode: {result.mode}\n" + "Status: {result.status}\n" + "LED brightness: {result.led_brightness}\n" + "Buzzer: {result.buzzer}\n" + "Child lock: {result.child_lock}\n" + "Dry mode: {result.dry}\n" + "Actual motor speed: {result.actual_speed} rpm\n" + "Clean mode: {result.clean_mode}\n" + "Self clean percent: {result.self_clean_percent} minutes\n" + "Pump state: {result.pump_state}\n" + "Pump cnt: {result.pump_cnt}\n", + ) + ) + def status(self) -> AirHumidifierMiotCA6Status: + """Retrieve properties.""" + + return AirHumidifierMiotCA6Status( + { + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping() + } + ) + + @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("humidity", type=int), + default_output=format_output("Setting target humidity {humidity}%"), + ) + def set_target_humidity(self, humidity: int): + """Set target humidity.""" + if humidity < 30 or humidity > 60: + raise ValueError( + "Invalid target humidity: %s. Must be between 30 and 60" % humidity + ) + # HA sends humidity in float, e.g. 45.0 + # ca6 does accept only int values, e.g. 45 + return self.set_property("target_humidity", int(humidity)) + + @command( + click.argument("mode", type=EnumType(OperationModeCA6)), + default_output=format_output("Setting mode to '{mode.value}'"), + ) + def set_mode(self, mode: OperationMode): + """Set working mode.""" + return self.set_property("mode", mode.value) + + @command( + click.argument("brightness", type=EnumType(LedBrightness)), + 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("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) + + @command( + click.argument("dry", type=bool), + default_output=format_output( + lambda dry: "Turning on dry mode" if dry else "Turning off dry mode" + ), + ) + def set_dry(self, dry: bool): + """Set dry mode on/off.""" + return self.set_property("dry", dry) + + @command( + click.argument("clean_mode", type=bool), + default_output=format_output( + lambda clean_mode: "Turning on clean mode" + if clean_mode + else "Turning off clean mode" + ), + ) + def set_clean_mode(self, clean_mode: bool): + """Set clean mode on/off.""" + return self.set_property("clean_mode", clean_mode) diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py deleted file mode 100644 index 3d8a3742b..000000000 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot_ca6.py +++ /dev/null @@ -1,360 +0,0 @@ -import enum -import logging -from typing import Any, Dict, Optional - -import click - -from miio import DeviceStatus, MiotDevice -from miio.click_common import EnumType, command, format_output - -_LOGGER = logging.getLogger(__name__) - - -SMARTMI_EVAPORATIVE_HUMIDIFIER_3 = "zhimi.humidifier.ca6" - - -_MAPPINGS = { - SMARTMI_EVAPORATIVE_HUMIDIFIER_3: { - # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:humidifier:0000A00E:zhimi-ca6:1 - # Air Humidifier (siid=2) - "power": {"siid": 2, "piid": 1}, # bool - "fault": {"siid": 2, "piid": 2}, # [0, 15] step 1 - "mode": { - "siid": 2, - "piid": 5, - }, # 0 - Fav, 1 - Auto, 2 - Sleep - "target_humidity": { - "siid": 2, - "piid": 6, - }, # [30, 60] step 1 - "water_level": { - "siid": 2, - "piid": 7, - }, # 0 - empty/min, 1 - normal, 2 - full/max - "dry": {"siid": 2, "piid": 8}, # Automatic Air Drying, bool - "status": {"siid": 2, "piid": 9}, # 1 - Close, 2 - Work, 3 - Dry, 4 - Clean - # Environment (siid=3) - "temperature": {"siid": 3, "piid": 7}, # [-40, 125] step 0.1 - "humidity": {"siid": 3, "piid": 9}, # [0, 100] step 1 - # Alarm (siid=4) - "buzzer": {"siid": 4, "piid": 1}, - # Indicator Light (siid=5) - "led_brightness": {"siid": 5, "piid": 2}, # 0 - Off, 1 - Dim, 2 - Brightest - # Physical Control Locked (siid=6) - "child_lock": {"siid": 6, "piid": 1}, # bool - # Other (siid=7) - "actual_speed": {"siid": 7, "piid": 1}, # [0, 2000] step 1 - "country_code": { - "siid": 7, - "piid": 4, - }, # 82 - KR, 44 - EU, 81 - JP, 86 - CN, 886 - TW - "clean_mode": {"siid": 7, "piid": 5}, # bool - "self_clean_percent": {"siid": 7, "piid": 6}, # minutes, [0, 30] step 1 - "pump_state": {"siid": 7, "piid": 7}, # bool - "pump_cnt": {"siid": 7, "piid": 8}, # [0, 4000] step 1 - } -} - - -class OperationMode(enum.Enum): - Fav = 0 - Auto = 1 - Sleep = 2 - - -class OperationStatus(enum.Enum): - Close = 1 - Work = 2 - Dry = 3 - Clean = 4 - - -class LedBrightness(enum.Enum): - Off = 0 - Dim = 1 - Bright = 2 - - -class AirHumidifierMiotCA6Status(DeviceStatus): - """Container for status reports from the air humidifier. - - Xiaomi Smartmi Evaporation Air Humidifier 3 (zhimi.humidifier.ca6) respone (MIoT format):: - - [ - {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': True}, - {'did': 'fault', 'siid': 2, 'piid': 2, 'code': 0, 'value': 0}, - {'did': 'mode', 'siid': 2, 'piid': 5, 'code': 0, 'value': 0}, - {'did': 'target_humidity', 'siid': 2, 'piid': 6, 'code': 0, 'value': 50}, - {'did': 'water_level', 'siid': 2, 'piid': 7, 'code': 0, 'value': 1}, - {'did': 'dry', 'siid': 2, 'piid': 8, 'code': 0, 'value': True}, - {'did': 'status', 'siid': 2, 'piid': 9, 'code': 0, 'value': 2}, - {'did': 'temperature', 'siid': 3, 'piid': 7, 'code': 0, 'value': 19.0}, - {'did': 'humidity', 'siid': 3, 'piid': 9, 'code': 0, 'value': 51}, - {'did': 'buzzer', 'siid': 4, 'piid': 1, 'code': 0, 'value': False}, - {'did': 'led_brightness', 'siid': 5, 'piid': 2, 'code': 0, 'value': 2}, - {'did': 'child_lock', 'siid': 6, 'piid': 1, 'code': 0, 'value': False}, - {'did': 'actual_speed', 'siid': 7, 'piid': 1, 'code': 0, 'value': 1100}, - {'did': 'clean_mode', 'siid': 7, 'piid': 5, 'code': 0, 'value': False} - {'did': 'self_clean_percent, 'siid': 7, 'piid': 6, 'code': 0, 'value': 0}, - {'did': 'pump_state, 'siid': 7, 'piid': 7, 'code': 0, 'value': False}, - {'did': 'pump_cnt', 'siid': 7, 'piid': 8, 'code': 0, 'value': 1000}, - ] - """ - - def __init__(self, data: Dict[str, Any]) -> None: - self.data = data - _LOGGER.debug("Status : %s", repr(data)) - - # Air Humidifier - - @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self.data["power"] - - @property - def power(self) -> str: - """Return power state.""" - return "on" if self.is_on else "off" - - @property - def error(self) -> int: - """Return error state.""" - return self.data["fault"] - - @property - def mode(self) -> OperationMode: - """Return current operation mode.""" - - try: - mode = OperationMode(self.data["mode"]) - except ValueError as e: - _LOGGER.exception("Cannot parse mode: %s", e) - return OperationMode.Auto - - return mode - - @property - def target_humidity(self) -> int: - """Return target humidity.""" - return self.data["target_humidity"] - - @property - def water_level(self) -> Optional[int]: - """Return current water level (empty/min, normal, full/max). - - 0 - empty/min, 1 - normal, 2 - full/max - """ - water_level = self.data["water_level"] - return {0: 0, 1: 50, 2: 100}.get(water_level) - - @property - def dry(self) -> Optional[bool]: - """Return True if dry mode is on.""" - if self.data["dry"] is not None: - return self.data["dry"] - return None - - @property - def status(self) -> OperationStatus: - """Return current status.""" - - try: - status = OperationStatus(self.data["status"]) - except ValueError as e: - _LOGGER.exception("Cannot parse status: %s", e) - return OperationStatus.Close - - return status - - # Environment - - @property - def humidity(self) -> int: - """Return current humidity.""" - return self.data["humidity"] - - @property - def temperature(self) -> Optional[float]: - """Return current temperature, if available.""" - if self.data["temperature"] is not None: - return round(self.data["temperature"], 1) - return None - - # Alarm - - @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 - - # Indicator Light - - @property - def led_brightness(self) -> Optional[LedBrightness]: - """Return brightness of the LED.""" - - if self.data["led_brightness"] is not None: - try: - return LedBrightness(self.data["led_brightness"]) - except ValueError as e: - _LOGGER.exception("Cannot parse led_brightness: %s", e) - return None - - return None - - # Physical Control Locked - - @property - def child_lock(self) -> bool: - """Return True if child lock is on.""" - return self.data["child_lock"] - - # Other - - @property - def actual_speed(self) -> int: - """Return real speed of the motor.""" - return self.data["actual_speed"] - - @property - def clean_mode(self) -> bool: - """Return True if clean mode is active.""" - return self.data["clean_mode"] - - @property - def self_clean_percent(self) -> int: - """Return time in minutes (from 0 to 30) of self-cleaning procedure.""" - return self.data["self_clean_percent"] - - @property - def pump_state(self) -> bool: - """Return pump statue.""" - return self.data["pump_state"] - - @property - def pump_cnt(self) -> int: - """Return pump-cnt.""" - return self.data["pump_cnt"] - - -class AirHumidifierMiotCA6(MiotDevice): - """Main class representing zhimi.humidifier.ca6 air humidifier which uses MIoT protocol.""" - - _mappings = _MAPPINGS - - @command( - default_output=format_output( - "", - "Power: {result.power}\n" - "Error: {result.error}\n" - "Target Humidity: {result.target_humidity} %\n" - "Humidity: {result.humidity} %\n" - "Temperature: {result.temperature} °C\n" - "Water Level: {result.water_level} %\n" - "Mode: {result.mode}\n" - "Status: {result.status}\n" - "LED brightness: {result.led_brightness}\n" - "Buzzer: {result.buzzer}\n" - "Child lock: {result.child_lock}\n" - "Dry mode: {result.dry}\n" - "Actual motor speed: {result.actual_speed} rpm\n" - "Clean mode: {result.clean_mode}\n" - "Self clean percent: {result.self_clean_percent} minutes\n" - "Pump state: {result.pump_state}\n" - "Pump cnt: {result.pump_cnt}\n", - ) - ) - def status(self) -> AirHumidifierMiotCA6Status: - """Retrieve properties.""" - - return AirHumidifierMiotCA6Status( - { - prop["did"]: prop["value"] if prop["code"] == 0 else None - for prop in self.get_properties_for_mapping() - } - ) - - @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("humidity", type=int), - default_output=format_output("Setting target humidity {humidity}%"), - ) - def set_target_humidity(self, humidity: int): - """Set target humidity.""" - if humidity < 30 or humidity > 60: - raise ValueError( - "Invalid target humidity: %s. Must be between 30 and 60" % humidity - ) - # HA sends humidity in float, e.g. 45.0 - # ca6 does accept only int values, e.g. 45 - return self.set_property("target_humidity", int(humidity)) - - @command( - click.argument("mode", type=EnumType(OperationMode)), - default_output=format_output("Setting mode to '{mode.value}'"), - ) - def set_mode(self, mode: OperationMode): - """Set working mode.""" - return self.set_property("mode", mode.value) - - @command( - click.argument("brightness", type=EnumType(LedBrightness)), - 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("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) - - @command( - click.argument("dry", type=bool), - default_output=format_output( - lambda dry: "Turning on dry mode" if dry else "Turning off dry mode" - ), - ) - def set_dry(self, dry: bool): - """Set dry mode on/off.""" - return self.set_property("dry", dry) - - @command( - click.argument("clean_mode", type=bool), - default_output=format_output( - lambda clean_mode: "Turning on clean mode" - if clean_mode - else "Turning off clean mode" - ), - ) - def set_clean_mode(self, clean_mode: bool): - """Set clean mode on/off.""" - return self.set_property("clean_mode", clean_mode) diff --git a/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py b/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py index a642390be..d89bb7f1b 100644 --- a/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py +++ b/miio/integrations/zhimi/humidifier/tests/test_airhumidifier_miot_ca6.py @@ -3,7 +3,7 @@ from miio.tests.dummies import DummyMiotDevice from .. import AirHumidifierMiotCA6 -from ..airhumidifier_miot_ca6 import LedBrightness, OperationMode, OperationStatus +from ..airhumidifier_miot import LedBrightness, OperationModeCA6, OperationStatusCA6 _INITIAL_STATE = { "power": True, @@ -69,7 +69,7 @@ def test_status(dev): status = dev.status() assert status.is_on is _INITIAL_STATE["power"] assert status.error == _INITIAL_STATE["fault"] - assert status.mode == OperationMode(_INITIAL_STATE["mode"]) + assert status.mode == OperationModeCA6(_INITIAL_STATE["mode"]) assert status.target_humidity == _INITIAL_STATE["target_humidity"] assert status.water_level == {0: 0, 1: 50, 2: 100}.get( int(_INITIAL_STATE["water_level"]) @@ -108,14 +108,14 @@ def test_set_mode(dev): def mode(): return dev.status().mode - dev.set_mode(OperationMode.Auto) - assert mode() == OperationMode.Auto + dev.set_mode(OperationModeCA6.Auto) + assert mode() == OperationModeCA6.Auto - dev.set_mode(OperationMode.Fav) - assert mode() == OperationMode.Fav + dev.set_mode(OperationModeCA6.Fav) + assert mode() == OperationModeCA6.Fav - dev.set_mode(OperationMode.Sleep) - assert mode() == OperationMode.Sleep + dev.set_mode(OperationModeCA6.Sleep) + assert mode() == OperationModeCA6.Sleep def test_set_led_brightness(dev): @@ -186,14 +186,14 @@ def test_op_status(dev): def op_status(): return dev.status().status - dev.set_property("status", OperationStatus.Close) - assert op_status() == OperationStatus.Close + dev.set_property("status", OperationStatusCA6.Close) + assert op_status() == OperationStatusCA6.Close - dev.set_property("status", OperationStatus.Work) - assert op_status() == OperationStatus.Work + dev.set_property("status", OperationStatusCA6.Work) + assert op_status() == OperationStatusCA6.Work - dev.set_property("status", OperationStatus.Dry) - assert op_status() == OperationStatus.Dry + dev.set_property("status", OperationStatusCA6.Dry) + assert op_status() == OperationStatusCA6.Dry - dev.set_property("status", OperationStatus.Clean) - assert op_status() == OperationStatus.Clean + dev.set_property("status", OperationStatusCA6.Clean) + assert op_status() == OperationStatusCA6.Clean From 00e22f496bf2af08c845660a214ab0dd5c48eb6a Mon Sep 17 00:00:00 2001 From: smmoroz Date: Sun, 18 Feb 2024 21:21:12 +0300 Subject: [PATCH 12/13] Fix lint --- miio/integrations/zhimi/humidifier/airhumidifier_miot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot.py index beeedca07..00b7c04a9 100644 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot.py +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot.py @@ -715,9 +715,9 @@ def set_dry(self, dry: bool): @command( click.argument("clean_mode", type=bool), default_output=format_output( - lambda clean_mode: "Turning on clean mode" - if clean_mode - else "Turning off clean mode" + lambda clean_mode: ( + "Turning on clean mode" if clean_mode else "Turning off clean mode" + ) ), ) def set_clean_mode(self, clean_mode: bool): From b839b325c3863ff9932f69189b6939eed58ea9f2 Mon Sep 17 00:00:00 2001 From: smmoroz Date: Mon, 19 Feb 2024 00:15:12 +0300 Subject: [PATCH 13/13] Added support for 'status' command in 'miiocli' --- .../zhimi/humidifier/airhumidifier_miot.py | 388 ++++++++++-------- 1 file changed, 220 insertions(+), 168 deletions(-) diff --git a/miio/integrations/zhimi/humidifier/airhumidifier_miot.py b/miio/integrations/zhimi/humidifier/airhumidifier_miot.py index 00b7c04a9..5cf4f49c3 100644 --- a/miio/integrations/zhimi/humidifier/airhumidifier_miot.py +++ b/miio/integrations/zhimi/humidifier/airhumidifier_miot.py @@ -6,6 +6,7 @@ from miio import DeviceStatus, MiotDevice from miio.click_common import EnumType, command, format_output +from miio.devicestatus import sensor, setting _LOGGER = logging.getLogger(__name__) @@ -14,7 +15,7 @@ SMARTMI_EVAPORATIVE_HUMIDIFIER_3 = "zhimi.humidifier.ca6" -_MAPPINGS = { +_MAPPINGS_CA4 = { SMARTMI_EVAPORATIVE_HUMIDIFIER_2: { # Source http://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:humidifier:0000A00E:zhimi-ca4:2 # Air Humidifier (siid=2) @@ -120,7 +121,147 @@ class PressedButton(enum.Enum): Power = 2 -class AirHumidifierMiotStatus(DeviceStatus): +class AirHumidifierMiotCommonStatus(DeviceStatus): + """Container for status reports from the air humidifier. Common features for CA4 and CA6 models.""" + + def __init__(self, data: Dict[str, Any]) -> None: + self.data = data + _LOGGER.debug( + "Status Common: %s, __cli_output__ %s", repr(self), self.__cli_output__ + ) + + # Air Humidifier + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.data["power"] + + @property + def power(self) -> str: + """Return power state.""" + return "on" if self.is_on else "off" + + @property + def error(self) -> int: + """Return error state.""" + return self.data["fault"] + + @property + def target_humidity(self) -> int: + """Return target humidity.""" + return self.data["target_humidity"] + + @property + @setting( + name="Dry Mode", + icon="mdi:hair-dryer", + setter_name="set_dry", + device_class="switch", + entity_category="config", + ) + def dry(self) -> Optional[bool]: + """Return True if dry mode is on.""" + if self.data["dry"] is not None: + return self.data["dry"] + return None + + @property + @setting( + name="Clean Mode", + icon="mdi:shimmer", + setter_name="set_clean_mode", + device_class="switch", + entity_category="config", + ) + def clean_mode(self) -> bool: + """Return True if clean mode is active.""" + return self.data["clean_mode"] + + # Environment + + @property + @sensor("Humidity", unit="%", device_class="humidity") + def humidity(self) -> int: + """Return current humidity.""" + return self.data["humidity"] + + @property + @sensor("Temperature", unit="°C", device_class="temperature") + def temperature(self) -> Optional[float]: + """Return current temperature, if available.""" + if self.data["temperature"] is not None: + return round(self.data["temperature"], 1) + return None + + # Alarm + + @property + @setting( + name="Buzzer", + icon="mdi:volume-high", + setter_name="set_buzzer", + device_class="switch", + entity_category="config", + ) + def buzzer(self) -> Optional[bool]: + """Return True if buzzer is on.""" + if self.data["buzzer"] is not None: + return self.data["buzzer"] + return None + + # Indicator Light + + @property + @setting( + name="Led Brightness", + icon="mdi:brightness-6", + setter_name="set_led_brightness", + choices=LedBrightness, + entity_category="config", + ) + def led_brightness(self) -> Optional[LedBrightness]: + """Return brightness of the LED.""" + + if self.data["led_brightness"] is not None: + try: + return LedBrightness(self.data["led_brightness"]) + except ValueError as e: + _LOGGER.exception("Cannot parse led_brightness: %s", e) + return None + + return None + + # Physical Control Locked + + @property + @setting( + name="Child Lock", + icon="mdi:lock", + setter_name="set_child_lock", + device_class="switch", + entity_category="config", + ) + def child_lock(self) -> bool: + """Return True if child lock is on.""" + return self.data["child_lock"] + + # Other + + @property + @sensor( + "Actual Motor Speed", + unit="rpm", + device_class="measurement", + icon="mdi:fast-forward", + entity_category="diagnostic", + ) + def actual_speed(self) -> int: + """Return real speed of the motor.""" + return self.data["actual_speed"] + + +class AirHumidifierMiotStatus(AirHumidifierMiotCommonStatus): """Container for status reports from the air humidifier. Xiaomi Smartmi Evaporation Air Humidifier 2 (zhimi.humidifier.ca4) respone (MIoT format):: @@ -149,25 +290,16 @@ class AirHumidifierMiotStatus(DeviceStatus): def __init__(self, data: Dict[str, Any]) -> None: self.data = data + super().__init__(self.data) + self.embed("common", AirHumidifierMiotCommonStatus(self.data)) # Air Humidifier @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self.data["power"] - - @property - def power(self) -> str: - """Return power state.""" - return "on" if self.is_on else "off" - - @property - def error(self) -> int: - """Return error state.""" - return self.data["fault"] - - @property + @setting( + name="Operation Mode", + setter_name="set_mode", + ) def mode(self) -> OperationMode: """Return current operation mode.""" @@ -180,11 +312,13 @@ def mode(self) -> OperationMode: return mode @property - def target_humidity(self) -> int: - """Return target humidity.""" - return self.data["target_humidity"] - - @property + @sensor( + "Water Level", + unit="%", + device_class="measurement", + icon="mdi:water-check", + entity_category="diagnostic", + ) def water_level(self) -> Optional[int]: """Return current water level in percent. @@ -201,6 +335,12 @@ def water_level(self) -> Optional[int]: return int(min(water_level / 1.2, 100)) @property + @sensor( + "Water Tank Attached", + device_class="connectivity", + icon="mdi:car-coolant-level", + entity_category="diagnostic", + ) def water_tank_detached(self) -> bool: """True if the water tank is detached. @@ -209,13 +349,13 @@ def water_tank_detached(self) -> bool: return self.data["water_level"] == 127 @property - def dry(self) -> Optional[bool]: - """Return True if dry mode is on.""" - if self.data["dry"] is not None: - return self.data["dry"] - return None - - @property + @sensor( + "Use Time", + unit="s", + device_class="total_increasing", + icon="mdi:progress-clock", + entity_category="diagnostic", + ) def use_time(self) -> int: """Return how long the device has been active in seconds.""" return self.data["use_time"] @@ -233,6 +373,13 @@ def button_pressed(self) -> PressedButton: return button @property + @sensor( + "Target Motor Speed", + unit="rpm", + device_class="measurement", + icon="mdi:fast-forward", + entity_category="diagnostic", + ) def motor_speed(self) -> int: """Return target speed of the motor.""" return self.data["speed_level"] @@ -240,77 +387,32 @@ def motor_speed(self) -> int: # Environment @property - def humidity(self) -> int: - """Return current humidity.""" - return self.data["humidity"] - - @property - def temperature(self) -> Optional[float]: - """Return current temperature, if available.""" - if self.data["temperature"] is not None: - return round(self.data["temperature"], 1) - return None - - @property + @sensor("Temperature", unit="°F", device_class="temperature") def fahrenheit(self) -> Optional[float]: """Return current temperature in fahrenheit, if available.""" if self.data["fahrenheit"] is not None: return round(self.data["fahrenheit"], 1) return None - # Alarm - - @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 - - # Indicator Light - - @property - def led_brightness(self) -> Optional[LedBrightness]: - """Return brightness of the LED.""" - - if self.data["led_brightness"] is not None: - try: - return LedBrightness(self.data["led_brightness"]) - except ValueError as e: - _LOGGER.exception("Cannot parse led_brightness: %s", e) - return None - - return None - - # Physical Control Locked - - @property - def child_lock(self) -> bool: - """Return True if child lock is on.""" - return self.data["child_lock"] - # Other @property - def actual_speed(self) -> int: - """Return real speed of the motor.""" - return self.data["actual_speed"] - - @property + @sensor( + "Power On Time", + unit="s", + device_class="total_increasing", + icon="mdi:progress-clock", + entity_category="diagnostic", + ) def power_time(self) -> int: """Return how long the device has been powered in seconds.""" return self.data["power_time"] - @property - def clean_mode(self) -> bool: - """Return True if clean mode is active.""" - return self.data["clean_mode"] - class AirHumidifierMiot(MiotDevice): """Main class representing the air humidifier which uses MIoT protocol.""" - _mappings = _MAPPINGS + _mappings = _MAPPINGS_CA4 @command( default_output=format_output( @@ -440,7 +542,7 @@ def set_clean_mode(self, clean_mode: bool): return self.set_property("clean_mode", clean_mode) -class AirHumidifierMiotCA6Status(DeviceStatus): +class AirHumidifierMiotCA6Status(AirHumidifierMiotCommonStatus): """Container for status reports from the air humidifier. Xiaomi Smartmi Evaporation Air Humidifier 3 (zhimi.humidifier.ca6) respone (MIoT format):: @@ -468,26 +570,16 @@ class AirHumidifierMiotCA6Status(DeviceStatus): def __init__(self, data: Dict[str, Any]) -> None: self.data = data - _LOGGER.debug("Status CA6: %s", repr(data)) + super().__init__(self.data) + self.embed("common", AirHumidifierMiotCommonStatus(self.data)) # Air Humidifier 3 @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self.data["power"] - - @property - def power(self) -> str: - """Return power state.""" - return "on" if self.is_on else "off" - - @property - def error(self) -> int: - """Return error state.""" - return self.data["fault"] - - @property + @setting( + name="Operation Mode", + setter_name="set_mode", + ) def mode(self) -> OperationModeCA6: """Return current operation mode.""" @@ -500,11 +592,13 @@ def mode(self) -> OperationModeCA6: return mode @property - def target_humidity(self) -> int: - """Return target humidity.""" - return self.data["target_humidity"] - - @property + @sensor( + "Water Level", + unit="%", + device_class="measurement", + icon="mdi:water-check", + entity_category="diagnostic", + ) def water_level(self) -> Optional[int]: """Return current water level (empty/min, normal, full/max). @@ -514,13 +608,11 @@ def water_level(self) -> Optional[int]: return {0: 0, 1: 50, 2: 100}.get(water_level) @property - def dry(self) -> Optional[bool]: - """Return True if dry mode is on.""" - if self.data["dry"] is not None: - return self.data["dry"] - return None - - @property + @sensor( + "Operation status", + device_class="measurement", + entity_category="diagnostic", + ) def status(self) -> OperationStatusCA6: """Return current status.""" @@ -532,74 +624,34 @@ def status(self) -> OperationStatusCA6: return status - # Environment - - @property - def humidity(self) -> int: - """Return current humidity.""" - return self.data["humidity"] - - @property - def temperature(self) -> Optional[float]: - """Return current temperature, if available.""" - if self.data["temperature"] is not None: - return round(self.data["temperature"], 1) - return None - - # Alarm - - @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 - - # Indicator Light - - @property - def led_brightness(self) -> Optional[LedBrightness]: - """Return brightness of the LED.""" - - if self.data["led_brightness"] is not None: - try: - return LedBrightness(self.data["led_brightness"]) - except ValueError as e: - _LOGGER.exception("Cannot parse led_brightness: %s", e) - return None - - return None - - # Physical Control Locked - - @property - def child_lock(self) -> bool: - """Return True if child lock is on.""" - return self.data["child_lock"] - # Other @property - def actual_speed(self) -> int: - """Return real speed of the motor.""" - return self.data["actual_speed"] - - @property - def clean_mode(self) -> bool: - """Return True if clean mode is active.""" - return self.data["clean_mode"] - - @property + @sensor( + "Self-clean Percent", + unit="s", + device_class="total_increasing", + icon="mdi:progress-clock", + entity_category="diagnostic", + ) def self_clean_percent(self) -> int: """Return time in minutes (from 0 to 30) of self-cleaning procedure.""" return self.data["self_clean_percent"] @property + @sensor( + "Pump State", + entity_category="diagnostic", + ) def pump_state(self) -> bool: - """Return pump statue.""" + """Return pump state.""" return self.data["pump_state"] @property + @sensor( + "Pump Cnt", + entity_category="diagnostic", + ) def pump_cnt(self) -> int: """Return pump-cnt.""" return self.data["pump_cnt"]