diff --git a/README.rst b/README.rst index c9d8dec7d..86658af16 100644 --- a/README.rst +++ b/README.rst @@ -33,6 +33,7 @@ Supported devices - Xiaomi Mi Smart Rice Cooker (:class:`miio.cooker`) - Xiaomi Smartmi Fresh Air System (:class:`miio.airfresh`) - :doc:`Yeelight light bulbs ` (:class:`miio.yeelight`) (only a very rudimentary support, use `python-yeelight `__ for a more complete support) +- Xiaomi Mi Air Dehumidifier (:class:`miio.airdehumidifier`) *Feel free to create a pull request to add support for new devices as well as additional features for supported devices.* diff --git a/miio/__init__.py b/miio/__init__.py index abe605db5..b457b0cf8 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -3,6 +3,7 @@ from miio.airconditioningcompanion import AirConditioningCompanionV3 from miio.airfresh import AirFresh from miio.airhumidifier import AirHumidifier +from miio.airdehumidifier import AirDehumidifier from miio.airpurifier import AirPurifier from miio.airqualitymonitor import AirQualityMonitor from miio.ceil import Ceil diff --git a/miio/airdehumidifier.py b/miio/airdehumidifier.py new file mode 100755 index 000000000..fd22c0270 --- /dev/null +++ b/miio/airdehumidifier.py @@ -0,0 +1,334 @@ +import enum +import logging +from collections import defaultdict +from typing import Any, Dict, Optional + +import click + +from .click_common import command, format_output, EnumType +from .device import Device, DeviceInfo, DeviceError, DeviceException + +_LOGGER = logging.getLogger(__name__) + +MODEL_DEHUMIDIFIER_V1 = 'nwt.derh.wdh318efw1' + +AVAILABLE_PROPERTIES = { + MODEL_DEHUMIDIFIER_V1: [ + "on_off", + "mode", + "fan_st", + "buzzer", + "led", + "child_lock", + "humidity", + "temp", + "compressor_status", + "fan_speed", + "tank_full", + "defrost_status", + "alarm", + "auto", + ] +} + + +class AirDehumidifierException(DeviceException): + pass + + +class OperationMode(enum.Enum): + On = 'on' + Auto = 'auto' + DryCloth = 'dry_cloth' + + +class FanSpeed(enum.Enum): + Sleep = 0 + Low = 1 + Medium = 2 + High = 3 + Strong = 4 + + +class AirDehumidifierStatus: + """Container for status reports from the air dehumidifier.""" + + def __init__(self, data: Dict[str, Any], device_info: DeviceInfo) -> None: + """ + Response of a Air Dehumidifier (nwt.derh.wdh318efw1): + + {'on_off': 'on', 'mode': 'auto', 'fan_st': 2, + 'buzzer': 'off', 'led': 'on', 'child_lock': 'off', + 'humidity': 47, 'temp': 34, 'compressor_status': 'off', + 'fan_speed': 0, 'tank_full': 'off', 'defrost_status': 'off, + 'alarm': 'ok','auto': 50} + """ + + self.data = data + self.device_info = device_info + + @property + def power(self) -> str: + """Power state.""" + return self.data["on_off"] + + @property + def is_on(self) -> bool: + """True if device is turned on.""" + return self.power == "on" + + @property + def mode(self) -> OperationMode: + """Operation mode. Can be either on, auth or dry_cloth.""" + return OperationMode(self.data["mode"]) + + @property + def temperature(self) -> Optional[float]: + """Current temperature, if available.""" + if "temp" in self.data and self.data["temp"] is not None: + return self.data["temp"] + return None + + @property + def humidity(self) -> int: + """Current humidity.""" + return self.data["humidity"] + + @property + def buzzer(self) -> bool: + """True if buzzer is turned on.""" + return self.data["buzzer"] == "on" + + @property + def led(self) -> bool: + """LED brightness if available.""" + return self.data["led"] == "on" + + @property + def child_lock(self) -> bool: + """Return True if child lock is on.""" + return self.data["child_lock"] == "on" + + @property + def target_humidity(self) -> Optional[int]: + """Target humiditiy. Can be either 40, 50, 60 percent.""" + if "auto" in self.data and self.data["auto"] is not None: + return self.data["auto"] + return None + + @property + def fan_speed(self) -> Optional[FanSpeed]: + """Current fan speed.""" + if "fan_speed" in self.data and self.data["fan_speed"] is not None: + return FanSpeed(self.data["fan_speed"]) + return None + + @property + def tank_full(self) -> bool: + """The remaining amount of water in percent.""" + return self.data["tank_full"] + + @property + def compressor_status(self) -> bool: + """Compressor status.""" + return self.data["compressor_status"] == "on" + + @property + def defrost_status(self) -> bool: + """Defrost status.""" + return self.data["defrost_status"] == "on" + + @property + def fan_st(self) -> int: + """Fan st.""" + return self.data["fan_st"] + + @property + def alarm(self) -> str: + """Alarm.""" + return self.data["alarm"] + + def __repr__(self) -> str: + s = " None: + super().__init__(ip, token, start_id, debug, lazy_discover) + + if model in AVAILABLE_PROPERTIES: + self.model = model + else: + self.model = MODEL_DEHUMIDIFIER_V1 + + self.device_info = None + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Mode: {result.mode}\n" + "Temperature: {result.temperature} °C\n" + "Humidity: {result.humidity} %\n" + "Buzzer: {result.buzzer}\n" + "LED : {result.led}\n" + "Child lock: {result.child_lock}\n" + "Target humidity: {result.target_humidity} %\n" + "Fan speed: {result.fan_speed}\n" + "Tank Full: {result.tank_full}\n" + "Compressor Status: {result.compressor_status}\n" + "Defrost Status: {result.defrost_status}\n" + "Fan st: {result.fan_st}\n" + "Alarm: {result.alarm}\n" + ) + ) + def status(self) -> AirDehumidifierStatus: + """Retrieve properties.""" + + if self.device_info is None: + self.device_info = self.info() + + properties = AVAILABLE_PROPERTIES[self.model] + + _props = properties.copy() + values = [] + while _props: + values.extend(self.send("get_prop", _props[:1])) + _props[:] = _props[1:] + + properties_count = len(properties) + values_count = len(values) + if properties_count != values_count: + _LOGGER.error( + "Count (%s) of requested properties does not match the " + "count (%s) of received values.", + properties_count, values_count) + + return AirDehumidifierStatus( + defaultdict(lambda: None, zip(properties, values)), self.device_info) + + @command( + default_output=format_output("Powering on"), + ) + def on(self): + """Power on.""" + return self.send("set_power", ["on"]) + + @command( + default_output=format_output("Powering off"), + ) + def off(self): + """Power off.""" + return self.send("set_power", ["off"]) + + @command( + click.argument("mode", type=EnumType(OperationMode, False)), + default_output=format_output("Setting mode to '{mode.value}'") + ) + def set_mode(self, mode: OperationMode): + """Set mode.""" + try: + return self.send("set_mode", [mode.value]) + except DeviceError as error: + # {'code': -6011, 'message': 'device_poweroff'} + if error.code == -6011: + self.on() + return self.send("set_mode", [mode.value]) + raise + + @command( + click.argument("fan_speed", type=EnumType(FanSpeed, False)), + default_output=format_output("Setting fan level to {fan_level}") + ) + def set_fan_speed(self, fan_speed: FanSpeed): + """Set the fan speed.""" + return self.send("set_fan_level", [fan_speed.value]) + + @command( + click.argument("led", type=bool), + default_output=format_output( + lambda led: "Turning on LED" + if led else "Turning off LED" + ) + ) + def set_led(self, led: bool): + """Turn led on/off.""" + if led: + return self.send("set_led", ["on"]) + else: + return self.send("set_led", ["off"]) + + @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.""" + if buzzer: + return self.send("set_buzzer", ["on"]) + else: + return self.send("set_buzzer", ["off"]) + + @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.""" + if lock: + return self.send("set_child_lock", ["on"]) + else: + return self.send("set_child_lock", ["off"]) + + @command( + click.argument("humidity", type=int), + default_output=format_output("Setting target humidity to {humidity}") + ) + def set_target_humidity(self, humidity: int): + """Set the auto target humidity.""" + if humidity not in [40, 50, 60]: + raise AirDehumidifierException( + "Invalid auto target humidity: %s" % humidity) + + return self.send("set_auto", [humidity]) diff --git a/miio/tests/test_airdehumidifier.py b/miio/tests/test_airdehumidifier.py new file mode 100644 index 000000000..21bcbda01 --- /dev/null +++ b/miio/tests/test_airdehumidifier.py @@ -0,0 +1,198 @@ +from unittest import TestCase + +import pytest + +from miio import AirDehumidifier +from miio.airdehumidifier import (OperationMode, FanSpeed, + AirDehumidifierStatus, AirDehumidifierException, + MODEL_DEHUMIDIFIER_V1) +from .dummies import DummyDevice +from miio.device import DeviceInfo + + +class DummyAirDehumidifierV1(DummyDevice, AirDehumidifier): + def __init__(self, *args, **kwargs): + self.model = MODEL_DEHUMIDIFIER_V1 + self.dummy_device_info = { + 'life': 348202, + 'uid': 1759530000, + 'model': 'nwt.derh.wdh318efw1', + 'token': '68ffffffffffffffffffffffffffffff', + 'fw_ver': '2.0.5', + 'mcu_fw_ver': '0018', + 'miio_ver': '0.0.5', + 'hw_ver': 'esp32', + 'mmfree': 65476, + 'mac': '78:11:FF:FF:FF:FF', + 'wifi_fw_ver': 'v3.1.4-56-g8ffb04960', + 'netif': {'gw': '192.168.0.1', + 'localIp': '192.168.0.25', + 'mask': '255.255.255.0'}, + } + + self.device_info = None + + self.state = { + 'on_off': 'on', + 'mode': 'auto', + 'fan_st': 2, + 'buzzer': 'off', + 'led': 'on', + 'child_lock': 'off', + 'humidity': 48, + 'temp': 34, + 'compressor_status': 'off', + 'fan_speed': 0, + 'tank_full': 'off', + 'defrost_status': 'off', + 'alarm': 'ok', + 'auto': 50 + } + + self.return_values = { + 'get_prop': self._get_state, + 'set_power': lambda x: self._set_state("power", x), + 'set_mode': lambda x: self._set_state("mode", x), + 'set_led': lambda x: self._set_state("led", x), + 'set_buzzer': lambda x: self._set_state("buzzer", x), + 'set_child_lock': lambda x: self._set_state("child_lock", x), + 'set_fan_speed': lambda x: self._set_state("fan_st", x), + 'set_target_humidity': lambda x: self._set_state("auto", x), + 'miIO.info': self._get_device_info, + } + super().__init__(args, kwargs) + + def _get_device_info(self, _): + """Return dummy device info.""" + return self.dummy_device_info + + +@pytest.fixture(scope="class") +def airdehumidifierv1(request): + request.cls.device = DummyAirDehumidifierV1() + # TODO add ability to test on a real device + + +@pytest.mark.usefixtures("airdehumidifierv1") +class TestAirDehumidifierV1(TestCase): + def is_on(self): + return self.device.status().is_on + + def state(self): + return self.device.status() + + def test_on(self): + self.device.off() # ensure off + assert self.is_on() is False + + self.device.on() + assert self.is_on() is True + + def test_off(self): + self.device.on() # ensure on + assert self.is_on() is True + + self.device.off() + assert self.is_on() is False + + def test_status(self): + self.device._reset_state() + + device_info = DeviceInfo(self.device.dummy_device_info) + + assert repr(self.state()) == repr( + AirDehumidifierStatus(self.device.start_state, device_info)) + + assert self.is_on() is True + assert self.state().temperature == self.device.start_state["temp"] + assert self.state().humidity == self.device.start_state["humidity"] + assert self.state().mode == OperationMode(self.device.start_state["mode"]) + assert self.state().led == self.device.start_state["led"] + assert self.state().buzzer == (self.device.start_state["buzzer"] == 'on') + assert self.state().child_lock == (self.device.start_state["child_lock"] == 'on') + assert self.state().target_humidity == self.device.start_state["auto"] + assert self.state().fan_speed == FanSpeed(self.device.start_state["fan_speed"]) + assert self.state().tank_full == (self.device.start_state["tank_full"] == 'on') + assert self.state().compressor_status == ( + self.device.start_state["compressor_status"] == 'on') + assert self.state().defrost_status == (self.device.start_state["defrost_status"] == 'on') + assert self.state().fan_st == self.device.start_state["fan_st"] + assert self.state().alarm == self.device.start_state["alarm"] + + def test_set_mode(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationMode.On) + assert mode() == OperationMode.On + + self.device.set_mode(OperationMode.Auto) + assert mode() == OperationMode.Auto + + self.device.set_mode(OperationMode.DryCloth) + assert mode() == OperationMode.DryCloth + + def test_set_led(self): + def led(): + return self.device.status().led + + self.device.set_led(True) + assert led() is True + + self.device.set_led(False) + assert led() is False + + def test_set_buzzer(self): + def buzzer(): + return self.device.status().buzzer + + self.device.set_buzzer(True) + assert buzzer() is True + + self.device.set_buzzer(False) + assert buzzer() is False + + def test_status_without_temperature(self): + self.device._reset_state() + self.device.state["temp"] = None + + assert self.state().temperature is None + + def test_status_without_led(self): + self.device._reset_state() + self.device.state["led"] = None + + assert self.state().led_brightness is None + + def test_set_target_humidity(self): + def target_humidity(): + return self.device.status().target_humidity + + self.device.set_target_humidity(40) + assert target_humidity() == 40 + self.device.set_target_humidity(50) + assert target_humidity() == 50 + self.device.set_target_humidity(60) + assert target_humidity() == 60 + + with pytest.raises(AirDehumidifierException): + self.device.set_target_humidity(-1) + + with pytest.raises(AirDehumidifierException): + self.device.set_target_humidity(30) + + with pytest.raises(AirDehumidifierException): + self.device.set_target_humidity(70) + + with pytest.raises(AirDehumidifierException): + self.device.set_target_humidity(110) + + 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