Repository: rytilahti/python-miio (language: python)

Issue:
Mijia Smart Sterilization Humidifier - deerma.humidifier.jsq5
Before submitting a new request, use the search to see if there is an existing issue for the device.

**Device information:**

  - Name(s) of the device: Mijia Smart Sterilization Humidifier
  - Link: https://www.aliexpress.com/item/4000996247094.html?spm=a2g0o.search0304.0.0.a6ec51e14VlM8z&algo_pvid=0ddc29ce-d600-4c21-bc2a-f6599ed88419&algo_exp_id=0ddc29ce-d600-4c21-bc2a-f6599ed88419-0

Use `miiocli device --ip <ip address> --token <token>`.

Model: deerma.humidifier.jsq5
Hardware version: esp8266
Firmware version: 2.1.3

**Additional context**
probably a newer version of deerma.humidifier.jsq

"
Mijia Intelligent Sterilization Humidifier (EU version?) | deerma.humidifier.jsq | ZNJSQ01DEM | 4.5L, <=38dB, 300mL/h, 24W
Mijia Intelligent Sterilization Humidifier (EU version?) | deerma.humidifier.jsq5 | ZNJSQ01DEM | 4.5L, <=38dB, 300mL/h, 24W
"
Source: https://github.com/syssi/xiaomi_airpurifier



Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index a7b15868a..f53dd8855 100644
--- a/README.rst
+++ b/README.rst
@@ -151,6 +151,7 @@ Supported devices
 -  Qingping Air Monitor Lite (cgllc.airm.cgdn1)
 -  Xiaomi Walkingpad A1 (ksmb.walkingpad.v3)
 -  Xiaomi Smart Pet Water Dispenser (mmgg.pet_waterer.s1, s4)
+-  Xiaomi Mi Smart Humidifer S (jsqs, jsq5)
 
 
 *Feel free to create a pull request to add support for new devices as
diff --git a/miio/__init__.py b/miio/__init__.py
index c3595b536..790fcf1af 100644
--- a/miio/__init__.py
+++ b/miio/__init__.py
@@ -45,6 +45,7 @@
 from miio.integrations.fan.dmaker import Fan1C, FanMiot, FanP5, FanP9, FanP10, FanP11
 from miio.integrations.fan.leshow import FanLeshow
 from miio.integrations.fan.zhimi import Fan, FanZA5
+from miio.integrations.humidifier.deerma import AirHumidifierJsqs
 from miio.integrations.light.philips import (
     Ceil,
     PhilipsBulb,
diff --git a/miio/discovery.py b/miio/discovery.py
index 313c489a9..5a30fdd51 100644
--- a/miio/discovery.py
+++ b/miio/discovery.py
@@ -21,6 +21,7 @@
     AirFreshT2017,
     AirHumidifier,
     AirHumidifierJsq,
+    AirHumidifierJsqs,
     AirHumidifierMjjsq,
     AirPurifier,
     AirPurifierMiot,
@@ -137,6 +138,7 @@
         AirHumidifierMjjsq, model=MODEL_HUMIDIFIER_MJJSQ
     ),
     "deerma-humidifier-jsq1": partial(AirHumidifierMjjsq, model=MODEL_HUMIDIFIER_JSQ1),
+    "deerma-humidifier-jsqs": AirHumidifierJsqs,
     "yunmi-waterpuri-v2": WaterPurifier,
     "yunmi.waterpuri.lx9": WaterPurifierYunmi,
     "yunmi.waterpuri.lx11": WaterPurifierYunmi,
diff --git a/miio/integrations/humidifier/__init__.py b/miio/integrations/humidifier/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/miio/integrations/humidifier/deerma/__init__.py b/miio/integrations/humidifier/deerma/__init__.py
new file mode 100644
index 000000000..d07fde6a4
--- /dev/null
+++ b/miio/integrations/humidifier/deerma/__init__.py
@@ -0,0 +1,2 @@
+# flake8: noqa
+from .airhumidifier_jsqs import AirHumidifierJsqs
diff --git a/miio/integrations/humidifier/deerma/airhumidifier_jsqs.py b/miio/integrations/humidifier/deerma/airhumidifier_jsqs.py
new file mode 100644
index 000000000..652b1fd15
--- /dev/null
+++ b/miio/integrations/humidifier/deerma/airhumidifier_jsqs.py
@@ -0,0 +1,235 @@
+import enum
+import logging
+from typing import Any, Dict, Optional
+
+import click
+
+from miio.click_common import EnumType, command, format_output
+from miio.exceptions import DeviceException
+from miio.miot_device import DeviceStatus, MiotDevice
+
+_LOGGER = logging.getLogger(__name__)
+_MAPPING = {
+    # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:humidifier:0000A00E:deerma-jsqs:2
+    # Air Humidifier (siid=2)
+    "power": {"siid": 2, "piid": 1},  # bool
+    "fault": {"siid": 2, "piid": 2},  # 0
+    "mode": {"siid": 2, "piid": 5},  # 1 - lvl1, 2 - lvl2, 3 - lvl3, 4 - auto
+    "target_humidity": {"siid": 2, "piid": 6},  # [40, 80] step 1
+    # Environment (siid=3)
+    "relative_humidity": {"siid": 3, "piid": 1},  # [0, 100] step 1
+    "temperature": {"siid": 3, "piid": 7},  # [-30, 100] step 1
+    # Alarm (siid=5)
+    "buzzer": {"siid": 5, "piid": 1},  # bool
+    # Light (siid=6)
+    "led_light": {"siid": 6, "piid": 1},  # bool
+    # Other (siid=7)
+    "water_shortage_fault": {"siid": 7, "piid": 1},  # bool
+    "tank_filed": {"siid": 7, "piid": 2},  # bool
+    "overwet_protect": {"siid": 7, "piid": 3},  # bool
+}
+
+
+class AirHumidifierJsqsException(DeviceException):
+    pass
+
+
+class OperationMode(enum.Enum):
+    Low = 1
+    Mid = 2
+    High = 3
+    Auto = 4
+
+
+class AirHumidifierJsqsStatus(DeviceStatus):
+    """Container for status reports from the air humidifier.
+
+    Xiaomi Mi Smart Humidifer S (deerma.humidifier.[jsqs, jsq5]) 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': 1},
+        {'did': 'target_humidity', 'siid': 2, 'piid': 6, 'code': 0, 'value': 50},
+        {'did': 'relative_humidity', 'siid': 3, 'piid': 1, 'code': 0, 'value': 40},
+        {'did': 'temperature', 'siid': 3, 'piid': 7, 'code': 0, 'value': 22.7},
+        {'did': 'buzzer', 'siid': 5, 'piid': 1, 'code': 0, 'value': False},
+        {'did': 'led_light', 'siid': 6, 'piid': 1, 'code': 0, 'value': True},
+        {'did': 'water_shortage_fault', 'siid': 7, 'piid': 1, 'code': 0, 'value': False},
+        {'did': 'tank_filed', 'siid': 7, 'piid': 2, 'code': 0, 'value': False},
+        {'did': 'overwet_protect', 'siid': 7, 'piid': 3, 'code': 0, 'value': False}
+    ]
+    """
+
+    def __init__(self, data: Dict[str, Any]) -> None:
+        self.data = 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) -> Optional[int]:
+        """Return target humidity."""
+        return self.data.get("target_humidity")
+
+    # Environment
+
+    @property
+    def relative_humidity(self) -> Optional[int]:
+        """Return current humidity."""
+        return self.data.get("relative_humidity")
+
+    @property
+    def temperature(self) -> Optional[float]:
+        """Return current temperature, if available."""
+        return self.data.get("temperature")
+
+    # Alarm
+
+    @property
+    def buzzer(self) -> Optional[bool]:
+        """Return True if buzzer is on."""
+        return self.data.get("buzzer")
+
+    # Indicator Light
+
+    @property
+    def led_light(self) -> Optional[bool]:
+        """Return status of the LED."""
+        return self.data.get("led_light")
+
+    # Other
+
+    @property
+    def tank_filed(self) -> Optional[bool]:
+        """Return the tank filed."""
+        return self.data.get("tank_filed")
+
+    @property
+    def water_shortage_fault(self) -> Optional[bool]:
+        """Return water shortage fault."""
+        return self.data.get("water_shortage_fault")
+
+    @property
+    def overwet_protect(self) -> Optional[bool]:
+        """Return True if overwet mode is active."""
+        return self.data.get("overwet_protect")
+
+
+class AirHumidifierJsqs(MiotDevice):
+    """Main class representing the air humidifier which uses MIoT protocol."""
+
+    _supported_models = ["deerma.humidifier.jsqs", "deerma.humidifier.jsq5"]
+
+    mapping = _MAPPING
+
+    @command(
+        default_output=format_output(
+            "",
+            "Power: {result.power}\n"
+            "Error: {result.error}\n"
+            "Target Humidity: {result.target_humidity} %\n"
+            "Relative Humidity: {result.relative_humidity} %\n"
+            "Temperature: {result.temperature} °C\n"
+            "Water tank detached: {result.tank_filed}\n"
+            "Mode: {result.mode}\n"
+            "LED light: {result.led_light}\n"
+            "Buzzer: {result.buzzer}\n"
+            "Overwet protection: {result.overwet_protect}\n",
+        )
+    )
+    def status(self) -> AirHumidifierJsqsStatus:
+        """Retrieve properties."""
+
+        return AirHumidifierJsqsStatus(
+            {
+                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 < 40 or humidity > 80:
+            raise AirHumidifierJsqsException(
+                "Invalid target humidity: %s. Must be between 40 and 80" % 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("light", type=bool),
+        default_output=format_output(
+            lambda light: "Turning on LED light" if light else "Turning off LED light"
+        ),
+    )
+    def set_light(self, light: bool):
+        """Set led light."""
+        return self.set_property("led_light", light)
+
+    @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("overwet", type=bool),
+        default_output=format_output(
+            lambda overwet: "Turning on overwet" if overwet else "Turning off overwet"
+        ),
+    )
+    def set_overwet_protect(self, overwet: bool):
+        """Set overwet mode on/off."""
+        return self.set_property("overwet_protect", overwet)


Test patch:
diff --git a/miio/integrations/humidifier/deerma/tests/__init__.py b/miio/integrations/humidifier/deerma/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/miio/integrations/humidifier/deerma/tests/test_airhumidifier_jsqs.py b/miio/integrations/humidifier/deerma/tests/test_airhumidifier_jsqs.py
new file mode 100644
index 000000000..5f4ea9f2c
--- /dev/null
+++ b/miio/integrations/humidifier/deerma/tests/test_airhumidifier_jsqs.py
@@ -0,0 +1,137 @@
+import pytest
+
+from miio import AirHumidifierJsqs
+from miio.tests.dummies import DummyMiotDevice
+
+from ..airhumidifier_jsqs import AirHumidifierJsqsException, OperationMode
+
+_INITIAL_STATE = {
+    "power": True,
+    "fault": 0,
+    "mode": 4,
+    "target_humidity": 60,
+    "temperature": 21.6,
+    "relative_humidity": 62,
+    "buzzer": False,
+    "led_light": True,
+    "water_shortage_fault": False,
+    "tank_filed": False,
+    "overwet_protect": True,
+}
+
+
+class DummyAirHumidifierJsqs(DummyMiotDevice, AirHumidifierJsqs):
+    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_target_humidity": lambda x: self._set_state("target_humidity", x),
+            "set_mode": lambda x: self._set_state("mode", x),
+            "set_led_light": lambda x: self._set_state("led_light", x),
+            "set_buzzer": lambda x: self._set_state("buzzer", x),
+            "set_overwet_protect": lambda x: self._set_state("overwet_protect", x),
+        }
+        super().__init__(*args, **kwargs)
+
+
+@pytest.fixture()
+def dev(request):
+    yield DummyAirHumidifierJsqs()
+
+
+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"]
+    assert status.temperature == _INITIAL_STATE["temperature"]
+    assert status.relative_humidity == _INITIAL_STATE["relative_humidity"]
+    assert status.buzzer == _INITIAL_STATE["buzzer"]
+    assert status.led_light == _INITIAL_STATE["led_light"]
+    assert status.water_shortage_fault == _INITIAL_STATE["water_shortage_fault"]
+    assert status.tank_filed == _INITIAL_STATE["tank_filed"]
+    assert status.overwet_protect == _INITIAL_STATE["overwet_protect"]
+
+
+def test_set_target_humidity(dev):
+    def target_humidity():
+        return dev.status().target_humidity
+
+    dev.set_target_humidity(40)
+    assert target_humidity() == 40
+    dev.set_target_humidity(80)
+    assert target_humidity() == 80
+
+    with pytest.raises(AirHumidifierJsqsException):
+        dev.set_target_humidity(39)
+
+    with pytest.raises(AirHumidifierJsqsException):
+        dev.set_target_humidity(81)
+
+
+def test_set_mode(dev):
+    def mode():
+        return dev.status().mode
+
+    dev.set_mode(OperationMode.Auto)
+    assert mode() == OperationMode.Auto
+
+    dev.set_mode(OperationMode.Low)
+    assert mode() == OperationMode.Low
+
+    dev.set_mode(OperationMode.Mid)
+    assert mode() == OperationMode.Mid
+
+    dev.set_mode(OperationMode.High)
+    assert mode() == OperationMode.High
+
+
+def test_set_led_light(dev):
+    def led_light():
+        return dev.status().led_light
+
+    dev.set_light(True)
+    assert led_light() is True
+
+    dev.set_light(False)
+    assert led_light() is False
+
+
+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_overwet_protect(dev):
+    def overwet_protect():
+        return dev.status().overwet_protect
+
+    dev.set_overwet_protect(True)
+    assert overwet_protect() is True
+
+    dev.set_overwet_protect(False)
+    assert overwet_protect() is False