diff --git a/README.rst b/README.rst index dd6ce7063..e621b2705 100644 --- a/README.rst +++ b/README.rst @@ -25,7 +25,7 @@ Supported devices - Xiaomi Philips Zhirui Bedroom Smart Lamp - Xiaomi Universal IR Remote Controller (Chuangmi IR) - Xiaomi Mi Smart Pedestal Fan V2, V3, SA1, ZA1, ZA3, ZA4, P5 -- Xiaomi Mi Air Humidifier V1, CA1, CB1 +- Xiaomi Mi Air Humidifier V1, CA1, CB1, MJJSQ - Xiaomi Mi Water Purifier (Basic support: Turn on & off) - Xiaomi PM2.5 Air Quality Monitor V1, B1, S1 - Xiaomi Smart WiFi Speaker diff --git a/miio/__init__.py b/miio/__init__.py index b3c7b24b2..27272495e 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -5,7 +5,8 @@ ) from miio.airdehumidifier import AirDehumidifier from miio.airfresh import AirFresh -from miio.airhumidifier import AirHumidifier +from miio.airhumidifier import AirHumidifier, AirHumidifierCA1, AirHumidifierCB1 +from miio.airhumidifier_mjjsq import AirHumidifierMjjsq from miio.airpurifier import AirPurifier from miio.airqualitymonitor import AirQualityMonitor from miio.aqaracamera import AqaraCamera diff --git a/miio/airdehumidifier.py b/miio/airdehumidifier.py old mode 100755 new mode 100644 diff --git a/miio/airhumidifier_mjjsq.py b/miio/airhumidifier_mjjsq.py new file mode 100644 index 000000000..f5ad109f9 --- /dev/null +++ b/miio/airhumidifier_mjjsq.py @@ -0,0 +1,234 @@ +import enum +import logging +from collections import defaultdict +from typing import Any, Dict + +import click + +from .click_common import EnumType, command, format_output +from .device import Device, DeviceException + +_LOGGER = logging.getLogger(__name__) + +MODEL_HUMIDIFIER_MJJSQ = "deerma.humidifier.mjjsq" + +AVAILABLE_PROPERTIES = { + MODEL_HUMIDIFIER_MJJSQ: [ + "OnOff_State", + "TemperatureValue", + "Humidity_Value", + "HumiSet_Value", + "Humidifier_Gear", + "Led_State", + "TipSound_State", + "waterstatus", + "watertankstatus", + ] +} + + +class AirHumidifierException(DeviceException): + pass + + +class OperationMode(enum.Enum): + Low = 1 + Medium = 2 + High = 3 + Humidity = 4 + + +class AirHumidifierStatus: + """Container for status reports from the air humidifier mjjsq.""" + + def __init__(self, data: Dict[str, Any]) -> None: + """ + Response of a Air Humidifier (deerma.humidifier.mjjsq): + + {'Humidifier_Gear': 4, 'Humidity_Value': 44, 'HumiSet_Value': 54, + 'Led_State': 1, 'OnOff_State': 0, 'TemperatureValue': 21, + 'TipSound_State': 1, 'waterstatus': 1, 'watertankstatus': 1} + """ + + self.data = data + + @property + def power(self) -> str: + """Power state.""" + return "on" if self.data["OnOff_State"] == 1 else "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 low, medium, high or humidity.""" + return OperationMode(self.data["Humidifier_Gear"]) + + @property + def temperature(self) -> int: + """Current temperature in degree celsius.""" + return self.data["TemperatureValue"] + + @property + def humidity(self) -> int: + """Current humidity in percent.""" + return self.data["Humidity_Value"] + + @property + def buzzer(self) -> bool: + """True if buzzer is turned on.""" + return self.data["TipSound_State"] == 1 + + @property + def led(self) -> bool: + """True if LED is turned on.""" + return self.data["Led_State"] == 1 + + @property + def target_humidity(self) -> int: + """Target humiditiy in percent.""" + return self.data["HumiSet_Value"] + + @property + def no_water(self) -> bool: + """True if the water tank is empty.""" + return self.data["waterstatus"] == 0 + + @property + def water_tank_detached(self) -> bool: + """True if the water tank is detached.""" + return self.data["watertankstatus"] == 0 + + def __repr__(self) -> str: + s = ( + "" + % ( + self.power, + self.mode, + self.temperature, + self.humidity, + self.led, + self.buzzer, + self.target_humidity, + self.no_water, + self.water_tank_detached, + ) + ) + return s + + def __json__(self): + return self.data + + +class AirHumidifierMjjsq(Device): + def __init__( + self, + ip: str = None, + token: str = None, + start_id: int = 0, + debug: int = 0, + lazy_discover: bool = True, + model: str = MODEL_HUMIDIFIER_MJJSQ, + ) -> None: + super().__init__(ip, token, start_id, debug, lazy_discover) + + if model in AVAILABLE_PROPERTIES: + self.model = model + else: + self.model = MODEL_HUMIDIFIER_MJJSQ + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Mode: {result.mode}\n" + "Temperature: {result.temperature} °C\n" + "Humidity: {result.humidity} %\n" + "LED: {result.led}\n" + "Buzzer: {result.buzzer}\n" + "Target humidity: {result.target_humidity} %\n" + "No water: {result.no_water}\n" + "Water tank detached: {result.water_tank_detached}\n", + ) + ) + def status(self) -> AirHumidifierStatus: + """Retrieve properties.""" + + 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 AirHumidifierStatus(defaultdict(lambda: None, zip(properties, values))) + + @command(default_output=format_output("Powering on")) + def on(self): + """Power on.""" + return self.send("Set_OnOff", [1]) + + @command(default_output=format_output("Powering off")) + def off(self): + """Power off.""" + return self.send("Set_OnOff", [0]) + + @command( + click.argument("mode", type=EnumType(OperationMode, False)), + default_output=format_output("Setting mode to '{mode.value}'"), + ) + def set_mode(self, mode: OperationMode): + """Set mode.""" + return self.send("Set_HumidifierGears", [mode.value]) + + @command( + click.argument("led", type=bool), + default_output=format_output( + lambda led: "Turning on LED" if led else "Turning off LED" + ), + ) + def set_led(self, led: bool): + """Turn led on/off.""" + return self.send("SetLedState", [int(led)]) + + @command( + click.argument("buzzer", type=bool), + default_output=format_output( + lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" + ), + ) + def set_buzzer(self, buzzer: bool): + """Set buzzer on/off.""" + return self.send("SetTipSound_Status", [int(buzzer)]) + + @command( + click.argument("humidity", type=int), + default_output=format_output("Setting target humidity to {humidity}"), + ) + def set_target_humidity(self, humidity: int): + """Set the target humidity in percent.""" + if humidity < 0 or humidity > 99: + raise AirHumidifierException("Invalid target humidity: %s" % humidity) + + return self.send("Set_HumiValue", [humidity]) diff --git a/miio/discovery.py b/miio/discovery.py index b4af5e6d6..29267968c 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -11,6 +11,7 @@ AirConditioningCompanion, AirFresh, AirHumidifier, + AirHumidifierMjjsq, AirPurifier, AirQualityMonitor, AqaraCamera, @@ -42,6 +43,7 @@ MODEL_HUMIDIFIER_CB1, MODEL_HUMIDIFIER_V1, ) +from .airhumidifier_mjjsq import MODEL_HUMIDIFIER_MJJSQ from .airqualitymonitor import ( MODEL_AIRQUALITYMONITOR_B1, MODEL_AIRQUALITYMONITOR_S1, @@ -106,6 +108,9 @@ "zhimi-humidifier-v1": partial(AirHumidifier, model=MODEL_HUMIDIFIER_V1), "zhimi-humidifier-ca1": partial(AirHumidifier, model=MODEL_HUMIDIFIER_CA1), "zhimi-humidifier-cb1": partial(AirHumidifier, model=MODEL_HUMIDIFIER_CB1), + "deerma-humidifier-mjjsq": partial( + AirHumidifierMjjsq, model=MODEL_HUMIDIFIER_MJJSQ + ), "yunmi-waterpuri-v2": WaterPurifier, "philips-light-bulb": PhilipsBulb, # cannot be discovered via mdns "philips-light-candle": PhilipsBulb, # cannot be discovered via mdns diff --git a/miio/tests/test_airhumidifier_mjjsq.py b/miio/tests/test_airhumidifier_mjjsq.py new file mode 100644 index 000000000..db5276e19 --- /dev/null +++ b/miio/tests/test_airhumidifier_mjjsq.py @@ -0,0 +1,141 @@ +from unittest import TestCase + +import pytest + +from miio import AirHumidifierMjjsq +from miio.airhumidifier_mjjsq import ( + MODEL_HUMIDIFIER_MJJSQ, + AirHumidifierException, + AirHumidifierStatus, + OperationMode, +) + +from .dummies import DummyDevice + + +class DummyAirHumidifierMjjsq(DummyDevice, AirHumidifierMjjsq): + def __init__(self, *args, **kwargs): + self.model = MODEL_HUMIDIFIER_MJJSQ + self.state = { + "Humidifier_Gear": 1, + "Humidity_Value": 44, + "HumiSet_Value": 11, + "Led_State": 0, + "OnOff_State": 1, + "TemperatureValue": 21, + "TipSound_State": 0, + "waterstatus": 1, + "watertankstatus": 1, + } + self.return_values = { + "get_prop": self._get_state, + "Set_OnOff": lambda x: self._set_state("OnOff_State", x), + "Set_HumidifierGears": lambda x: self._set_state("Humidifier_Gear", x), + "SetLedState": lambda x: self._set_state("Led_State", x), + "SetTipSound_Status": lambda x: self._set_state("TipSound_State", x), + "Set_HumiValue": lambda x: self._set_state("HumiSet_Value", x), + } + super().__init__(args, kwargs) + + +@pytest.fixture(scope="class") +def airhumidifiermjjsq(request): + request.cls.device = DummyAirHumidifierMjjsq() + # TODO add ability to test on a real device + + +@pytest.mark.usefixtures("airhumidifiermjjsq") +class TestAirHumidifierMjjsq(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() + + assert repr(self.state()) == repr(AirHumidifierStatus(self.device.start_state)) + assert self.is_on() is True + assert self.state().temperature == self.device.start_state["TemperatureValue"] + assert self.state().humidity == self.device.start_state["Humidity_Value"] + assert self.state().mode == OperationMode( + self.device.start_state["Humidifier_Gear"] + ) + assert self.state().led is (self.device.start_state["Led_State"] == 1) + assert self.state().buzzer is (self.device.start_state["TipSound_State"] == 1) + assert self.state().target_humidity == self.device.start_state["HumiSet_Value"] + assert self.state().no_water is (self.device.start_state["waterstatus"] == 0) + assert self.state().water_tank_detached is ( + self.device.start_state["watertankstatus"] == 0 + ) + + def test_set_mode(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationMode.Low) + assert mode() == OperationMode.Low + + self.device.set_mode(OperationMode.Medium) + assert mode() == OperationMode.Medium + + self.device.set_mode(OperationMode.High) + assert mode() == OperationMode.High + + self.device.set_mode(OperationMode.Humidity) + assert mode() == OperationMode.Humidity + + def test_set_led(self): + def led(): + return self.device.status().led + + self.device.set_led(True) + assert led() is True + + self.device.set_led(False) + assert led() is False + + def test_set_buzzer(self): + def buzzer(): + return self.device.status().buzzer + + self.device.set_buzzer(True) + assert buzzer() is True + + self.device.set_buzzer(False) + assert buzzer() is False + + def test_set_target_humidity(self): + def target_humidity(): + return self.device.status().target_humidity + + self.device.set_target_humidity(0) + assert target_humidity() == 0 + self.device.set_target_humidity(50) + assert target_humidity() == 50 + self.device.set_target_humidity(99) + assert target_humidity() == 99 + + with pytest.raises(AirHumidifierException): + self.device.set_target_humidity(-1) + + with pytest.raises(AirHumidifierException): + self.device.set_target_humidity(100) + + with pytest.raises(AirHumidifierException): + self.device.set_target_humidity(101)