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

Issue:
Unsupported model ijai.vacuum.v3
**Describe the bug**
I have  a xiaomi vacuum model mop 2 pro. Launching the command following:

miiocli viomivacuum --ip 192.168.1.100 --token MY_TOKEN get_rooms

Got an error:
WARNING:miio.device:Found an unsupported model 'ijai.vacuum.v3' for class 'ViomiVacuum'

**Version information (please complete the following information):**
 - OS: Raspbian
 - python-miio: miiocli, version 0.5.11

**Device information:**
If the issue is specific to a device [Use `miiocli device --ip <ip address> --token <token> info`]:
  - Model: mop 2 pro (ijai.vacuum.v3)
  - Hardware version: 
  - Firmware version: 4.3.3_0048

**To Reproduce**
Steps to reproduce the behavior:
1.

**Expected behavior**
A clear and concise description of what you expected to happen.

**Console output**
If applicable, add console output to help explain your problem.
If the issue is about communication with a specific device, consider including the output using the `--debug` flag.

Mi Robot Vacuum-Mop 2 Pro (ijai.vacuum.v3)
**Device information:**

  - Name(s) of the device: Mi Robot Vacuum-Mop 2 Pro | MJST1SHW
  - Link: https://home.miot-spec.com/s/ijai.vacuum.v3
  - Model: ijai.vacuum.v3
  - Hardware version: 
  - Firmware version: 4.1.5_0041



Discussion / hints:
Are other viomivacuum commands working just fine on that device? If not, this is a duplicate of #1385. There is currently no support for any ijai devices in the lib, contributions are welcome as always!
Yes the following command works but with warning:

root@raspberrytv:/home/pi# miiocli viomivacuum --ip 192.168.1.100 --token MY_TOKEN get_maps
Running command get_maps
WARNING:miio.device:Found an unsupported model 'ijai.vacuum.v3' for class 'ViomiVacuum'. If this is working for you, please open an issue at https://github.com/rytilahti/python-miio/
{'out': [{'piid': 4, 'value': '[{"name":"Filomena","id":MAP-ID,"cur":false},{"name":"Mappa1","id":1654083172,"cur":true}]'}], 'code': 0}


Also consumable_status works with warning

root@raspberrytv:/home/pi# miiocli viomivacuum --ip 192.168.1.100 --token MY-TOKEN consumable_status
Running command consumable_status
WARNING:miio.device:Found an unsupported model 'ijai.vacuum.v3' for class 'ViomiVacuum'. If this is working for you, please open an issue at https://github.com/rytilahti/python-miio/
<ViomiConsumableStatus filter=1 day, 14:00:00 filter_left=5 days, 22:00:00 main_brush=1 day, 14:00:00 main_brush_left=13 days, 10:00:00 mop=1 day, 14:00:00 mop_left=5 days, 22:00:00 sensor_dirty=0:00:00 sensor_dirty_left=0:00:00 side_brush=1 day, 14:00:00 side_brush_left=5 days, 22:00:00>



Fix patch:
diff --git a/miio/__init__.py b/miio/__init__.py
index fd5f2093b..3fb218d9a 100644
--- a/miio/__init__.py
+++ b/miio/__init__.py
@@ -65,6 +65,7 @@
 from miio.integrations.vacuum import (
     DreameVacuum,
     G1Vacuum,
+    Pro2Vacuum,
     RoborockVacuum,
     RoidmiVacuumMiot,
     VacuumException,
diff --git a/miio/integrations/vacuum/mijia/__init__.py b/miio/integrations/vacuum/mijia/__init__.py
index 2ebcbbdb3..8d737a529 100644
--- a/miio/integrations/vacuum/mijia/__init__.py
+++ b/miio/integrations/vacuum/mijia/__init__.py
@@ -1,2 +1,3 @@
 # flake8: noqa
 from .g1vacuum import G1Vacuum
+from .pro2vacuum import Pro2Vacuum
diff --git a/miio/integrations/vacuum/mijia/pro2vacuum.py b/miio/integrations/vacuum/mijia/pro2vacuum.py
new file mode 100644
index 000000000..7ea511477
--- /dev/null
+++ b/miio/integrations/vacuum/mijia/pro2vacuum.py
@@ -0,0 +1,345 @@
+import logging
+from datetime import timedelta
+from enum import Enum
+from typing import Dict
+
+import click
+
+from miio.click_common import EnumType, command, format_output
+from miio.devicestatus import sensor, switch
+from miio.interfaces import FanspeedPresets, VacuumInterface
+from miio.miot_device import DeviceStatus, MiotDevice
+
+_LOGGER = logging.getLogger(__name__)
+MI_ROBOT_VACUUM_MOP_PRO_2 = "ijai.vacuum.v3"
+
+_MAPPINGS = {
+    MI_ROBOT_VACUUM_MOP_PRO_2: {
+        # Source  https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:vacuum:0000A006:ijai-v3:1
+        # Robot Cleaner (siid=2)
+        "state": {"siid": 2, "piid": 1},
+        "error_code": {"siid": 2, "piid": 2},  # [0, 3000] step 1
+        "sweep_mode": {
+            "siid": 2,
+            "piid": 4,
+        },  # 0 - Sweep, 1 - Sweep And Mop, 2 - Mop
+        "sweep_type": {
+            "siid": 2,
+            "piid": 8,
+        },  # 0 - Global, 1 - Mop, 2 - Edge, 3 - Area, 4 - Point, 5 - Remote, 6 - Explore, 7 - Room, 8 - Floor
+        "start": {"siid": 2, "aiid": 1},
+        "stop": {"siid": 2, "aiid": 2},
+        # Battery (siid=3)
+        "battery": {"siid": 3, "piid": 1},  # [0, 100] step 1
+        "home": {"siid": 3, "aiid": 1},  # Start Charge
+        # sweep (siid=7)
+        "mop_state": {"siid": 7, "piid": 4},  # 0 - none, 1 - set
+        "fan_speed": {
+            "siid": 7,
+            "piid": 5,
+        },  # 0 - off, 1 - power save, 2 - standard, 3 - turbo
+        "water_level": {"siid": 7, "piid": 6},  # 0 - low, 1 - medium, 2 - high
+        "side_brush_life_level": {"siid": 7, "piid": 8},  # [0, 100] step 1
+        "side_brush_time_left": {"siid": 7, "piid": 9},  # [0, 180] step 1
+        "main_brush_life_level": {"siid": 7, "piid": 10},  # [0, 100] step 1
+        "main_brush_time_left": {"siid": 7, "piid": 11},  # [0, 360] step 1
+        "filter_life_level": {"siid": 7, "piid": 12},  # [0, 100] step 1
+        "filter_time_left": {"siid": 7, "piid": 13},  # [0, 180] step 1
+        "mop_life_level": {"siid": 7, "piid": 14},  # [0, 100] step 1
+        "mop_time_left": {"siid": 7, "piid": 15},  # [0, 180] step 1
+        "current_language": {"siid": 7, "piid": 21},  # string
+        "clean_time": {"siid": 7, "piid": 22},  # [0, 120] step 1
+        "clean_area": {"siid": 7, "piid": 23},  # [0, 1200] step 1
+    }
+}
+
+ERROR_CODES: Dict[int, str] = {2105: "Fully charged"}
+
+
+def _enum_as_dict(cls):
+    return {x.name: x.value for x in list(cls)}
+
+
+class DeviceState(Enum):
+    Sleep = 0
+    Idle = 1
+    Paused = 2
+    GoCharging = 3
+    Charging = 4
+    Sweeping = 5
+    SweepingAndMopping = 6
+    Mopping = 7
+    Upgrading = 8
+
+
+class SweepMode(Enum):
+    Sweep = 0
+    SweepAndMop = 1
+    Mop = 2
+
+
+class SweepType(Enum):
+    Global = 0
+    Mop = 1
+    Edge = 2
+    Area = 3
+    Point = 4
+    Remote = 5
+    Explore = 6
+    Room = 7
+    Floor = 8
+
+
+class DoorState(Enum):
+    Off = 0
+    DustBox = 1
+    WaterVolume = 2
+    TwoInOneWaterVolume = 3
+
+
+class FanSpeedMode(Enum):
+    Off = 0
+    EnergySaving = 1
+    Standard = 2
+    Turbo = 3
+
+
+class WaterLevel(Enum):
+    Low = 0
+    Medium = 1
+    High = 2
+
+
+class MopRoute(Enum):
+    BowStyle = 0
+    YStyle = 1
+
+
+class Pro2Status(DeviceStatus):
+    """Container for status reports from Mi Robot Vacuum-Mop 2 Pro."""
+
+    def __init__(self, data):
+        """Response (MIoT format) of a Mi Robot Vacuum-Mop 2 Pro (ijai.vacuum.v3)
+
+        Example::
+            [
+                {'did': 'state', 'siid': 2, 'piid': 1, 'code': 0, 'value': 5},
+                {'did': 'error_code', 'siid': 2, 'piid': 2, 'code': 0, 'value': 0},
+                {'did': 'sweep_mode', 'siid': 2, 'piid': 4, 'code': 0, 'value': 1},
+                {'did': 'sweep_type', 'siid': 2, 'piid': 8, 'code': 0, 'value': 1},
+                {'did': 'battery', 'siid': 3, 'piid': 1, 'code': 0, 'value': 100},
+                {'did': 'mop_state', 'siid': 7, 'piid': 4, 'code': 0, 'value': 0},
+                {'did': 'fan_speed', 'siid': 7, 'piid': 5, 'code': 0, 'value': 1},
+                {'did': 'water_level', 'siid': 7, 'piid': 6, 'code': 0, 'value': 2},
+                {'did': 'side_brush_life_level', 'siid': 7, 'piid': 8, 'code': 0, 'value': 0 },
+                {'did': 'side_brush_time_left', 'siid': 7, 'piid': 9', 'code': 0, 'value': 0},
+                {'did': 'main_brush_life_level', 'siid': 7, 'piid': 10, 'code': 0, 'value': 99},
+                {'did': 'main_brush_time_left', 'siid': 7, 'piid': 11, 'code': 0, 'value': 17959},
+                {'did': 'filter_life_level', 'siid': 7, 'piid': 12, 'code': 0, 'value': 0},
+                {'did': 'filter_time_left', 'siid': 7, 'piid': 13, 'code': 0, 'value': 0},
+                {'did': 'mop_life_level', 'siid': 7, 'piid': 14, 'code': 0, 'value': 0},
+                {'did': 'mop_time_left', 'siid': 7, 'piid': 15, 'code': 0, 'value': 0},
+                {'did': 'current_language', 'siid': 7, 'piid': 21, 'code': 0, 'value': 0},
+                {'did': 'clean_area', 'siid': 7, 'piid': 22, 'code': 0, 'value': 0},
+                {'did': 'clean_time', 'siid': 7, 'piid': 23, 'code': 0, 'value': 0},
+            ]
+        """
+        self.data = data
+
+    @property
+    @sensor(name="Battery", unit="%", device_class="battery")
+    def battery(self) -> int:
+        """Battery Level."""
+        return self.data["battery"]
+
+    @property
+    @sensor("Error", icon="mdi:alert")
+    def error_code(self) -> int:
+        """Error code as returned by the device."""
+        return int(self.data["error_code"])
+
+    @property
+    @sensor("Error", icon="mdi:alert")
+    def error(self) -> str:
+        """Human readable error description, see also :func:`error_code`."""
+        return ERROR_CODES.get(
+            self.error_code, f"Unknown error code: {self.error_code}"
+        )
+
+    @property
+    def state(self) -> DeviceState:
+        """Vacuum Status."""
+        return DeviceState(self.data["state"])
+
+    @property
+    @switch(name="Fan Speed", choices=FanSpeedMode, setter_name="set_fan_speed")
+    def fan_speed(self) -> FanSpeedMode:
+        """Fan Speed."""
+        return FanSpeedMode(self.data["fan_speed"])
+
+    @property
+    @sensor(name="Sweep Type")
+    def sweep_type(self) -> SweepType:
+        """Operating Mode."""
+        return SweepType(self.data["sweep_type"])
+
+    @property
+    @sensor(name="Sweep Mode")
+    def sweep_mode(self) -> SweepMode:
+        """Sweep Mode."""
+        return SweepMode(self.data["sweep_mode"])
+
+    @property
+    @sensor("Mop Attached")
+    def mop_state(self) -> bool:
+        """Mop State."""
+        return bool(self.data["mop_state"])
+
+    @property
+    @sensor("Water Level")
+    def water_level(self) -> WaterLevel:
+        """Water Level."""
+        return WaterLevel(self.data["water_level"])
+
+    @property
+    @sensor("Main Brush Life Level", unit="%")
+    def main_brush_life_level(self) -> int:
+        """Main Brush Life Level(%)."""
+        return self.data["main_brush_life_level"]
+
+    @property
+    @sensor("Main Brush Life Time Left")
+    def main_brush_time_left(self) -> timedelta:
+        """Main Brush Life Time Left(hours)."""
+        return timedelta(hours=self.data["main_brush_time_left"])
+
+    @property
+    @sensor("Side Brush Life Level", unit="%")
+    def side_brush_life_level(self) -> int:
+        """Side Brush Life Level(%)."""
+        return self.data["side_brush_life_level"]
+
+    @property
+    @sensor("Side Brush Life Time Left")
+    def side_brush_time_left(self) -> timedelta:
+        """Side Brush Life Time Left(hours)."""
+        return timedelta(hours=self.data["side_brush_time_left"])
+
+    @property
+    @sensor("Filter Life Level", unit="%")
+    def filter_life_level(self) -> int:
+        """Filter Life Level(%)."""
+        return self.data["filter_life_level"]
+
+    @property
+    @sensor("Filter Life Time Left")
+    def filter_time_left(self) -> timedelta:
+        """Filter Life Time Left(hours)."""
+        return timedelta(hours=self.data["filter_time_left"])
+
+    @property
+    @sensor("Mop Life Level", unit="%")
+    def mop_life_level(self) -> int:
+        """Mop Life Level(%)."""
+        return self.data["mop_life_level"]
+
+    @property
+    @sensor("Mop Life Time Left")
+    def mop_time_left(self) -> timedelta:
+        """Mop Life Time Left(hours)."""
+        return timedelta(hours=self.data["mop_time_left"])
+
+    @property
+    @sensor("Last Clean Area", unit="m2", icon="mdi:texture-box")
+    def clean_area(self) -> int:
+        """Last time clean area(m^2)."""
+        return self.data["clean_area"]
+
+    @property
+    @sensor("Last Clean Time", icon="mdi:timer-sand")
+    def clean_time(self) -> timedelta:
+        """Last time clean time(mins)."""
+        return timedelta(minutes=self.data["clean_time"])
+
+    @property
+    def current_language(self) -> str:
+        """Current Language."""
+        return self.data["current_language"]
+
+
+class Pro2Vacuum(MiotDevice, VacuumInterface):
+    """Support for Mi Robot Vacuum-Mop 2 Pro (ijai.vacuum.v3)."""
+
+    _mappings = _MAPPINGS
+
+    @command(
+        default_output=format_output(
+            "",
+            "State: {result.state}\n"
+            "Error: {result.error}\n"
+            "Battery: {result.battery}%\n"
+            "Sweep Mode: {result.sweep_mode}\n"
+            "Sweep Type: {result.sweep_type}\n"
+            "Mop State: {result.mop_state}\n"
+            "Fan speed: {result.fan_speed}\n"
+            "Water level: {result.water_level}\n"
+            "Main Brush Life Level: {result.main_brush_life_level}%\n"
+            "Main Brush Life Time: {result.main_brush_time_left}h\n"
+            "Side Brush Life Level: {result.side_brush_life_level}%\n"
+            "Side Brush Life Time: {result.side_brush_time_left}h\n"
+            "Filter Life Level: {result.filter_life_level}%\n"
+            "Filter Life Time: {result.filter_time_left}h\n"
+            "Mop Life Level: {result.mop_life_level}%\n"
+            "Mop Life Time: {result.mop_time_left}h\n"
+            "Clean Area: {result.clean_area} m^2\n"
+            "Clean Time: {result.clean_time} mins\n"
+            "Current Language: {result.current_language}\n",
+        )
+    )
+    def status(self) -> Pro2Status:
+        """Retrieve properties."""
+        return Pro2Status(
+            {
+                # max_properties limited to 10 to avoid "Checksum error"
+                # messages from the device.
+                prop["did"]: prop["value"] if prop["code"] == 0 else None
+                for prop in self.get_properties_for_mapping(max_properties=10)
+            }
+        )
+
+    @command()
+    def home(self):
+        """Go Home."""
+        return self.call_action("home")
+
+    @command()
+    def start(self) -> None:
+        """Start Cleaning."""
+        return self.call_action("start")
+
+    @command()
+    def stop(self):
+        """Stop Cleaning."""
+        return self.call_action("stop")
+
+    @command(
+        click.argument("fan_speed", type=EnumType(FanSpeedMode)),
+        default_output=format_output("Setting fan speed to {fan_speed}"),
+    )
+    def set_fan_speed(self, fan_speed: FanSpeedMode):
+        """Set fan speed."""
+        return self.set_property("fan_speed", fan_speed)
+
+    @command()
+    def fan_speed_presets(self) -> FanspeedPresets:
+        """Return available fan speed presets."""
+        return _enum_as_dict(FanSpeedMode)
+
+    @command(click.argument("speed", type=int))
+    def set_fan_speed_preset(self, speed_preset: int) -> None:
+        """Set fan speed preset speed."""
+        if speed_preset not in self.fan_speed_presets().values():
+            raise ValueError(
+                f"Invalid preset speed {speed_preset}, not in: {self.fan_speed_presets().values()}"
+            )
+        return self.set_property("fan_speed", speed_preset)


Test patch:
diff --git a/miio/integrations/vacuum/mijia/tests/test_pro2vacuum.py b/miio/integrations/vacuum/mijia/tests/test_pro2vacuum.py
new file mode 100644
index 000000000..b336a917c
--- /dev/null
+++ b/miio/integrations/vacuum/mijia/tests/test_pro2vacuum.py
@@ -0,0 +1,112 @@
+import datetime
+from unittest import TestCase
+
+import pytest
+
+from miio import Pro2Vacuum
+from miio.tests.dummies import DummyMiotDevice
+
+from ..pro2vacuum import (
+    ERROR_CODES,
+    MI_ROBOT_VACUUM_MOP_PRO_2,
+    DeviceState,
+    FanSpeedMode,
+    SweepMode,
+    SweepType,
+    WaterLevel,
+)
+
+_INITIAL_STATE_PRO2 = {
+    "state": DeviceState.Mopping,
+    "error_code": 2105,
+    "sweep_mode": SweepMode.SweepAndMop,
+    "sweep_type": SweepType.Floor,
+    "battery": 42,
+    "mop_state": False,
+    "fan_speed": FanSpeedMode.EnergySaving,
+    "water_level": WaterLevel.High,
+    "side_brush_life_level": 93,
+    "side_brush_time_left": 14,
+    "main_brush_life_level": 87,
+    "main_brush_time_left": 15,
+    "filter_life_level": 88,
+    "filter_time_left": 12,
+    "mop_life_level": 85,
+    "mop_time_left": 10,
+    "current_language": "en_US",
+    "clean_time": 5,
+    "clean_area": 8,
+}
+
+
+class DummyPRO2Vacuum(DummyMiotDevice, Pro2Vacuum):
+    def __init__(self, *args, **kwargs):
+        self._model = MI_ROBOT_VACUUM_MOP_PRO_2
+        self.state = _INITIAL_STATE_PRO2
+        super().__init__(*args, **kwargs)
+
+
+@pytest.fixture(scope="function")
+def dummypro2vacuum(request):
+    request.cls.device = DummyPRO2Vacuum()
+
+
+@pytest.mark.usefixtures("dummypro2vacuum")
+class TestPro2Vacuum(TestCase):
+    def test_status(self):
+        status = self.device.status()
+        assert status.clean_time == datetime.timedelta(
+            minutes=_INITIAL_STATE_PRO2["clean_time"]
+        )
+        assert status.battery == _INITIAL_STATE_PRO2["battery"]
+        assert status.error_code == _INITIAL_STATE_PRO2["error_code"]
+        assert status.error == ERROR_CODES[_INITIAL_STATE_PRO2["error_code"]]
+        assert status.state == _INITIAL_STATE_PRO2["state"]
+        assert status.fan_speed == _INITIAL_STATE_PRO2["fan_speed"]
+        assert status.sweep_type == _INITIAL_STATE_PRO2["sweep_type"]
+        assert status.sweep_mode == _INITIAL_STATE_PRO2["sweep_mode"]
+        assert status.mop_state == _INITIAL_STATE_PRO2["mop_state"]
+        assert status.water_level == _INITIAL_STATE_PRO2["water_level"]
+        assert (
+            status.main_brush_life_level == _INITIAL_STATE_PRO2["main_brush_life_level"]
+        )
+        assert status.main_brush_time_left == datetime.timedelta(
+            hours=_INITIAL_STATE_PRO2["main_brush_time_left"]
+        )
+        assert (
+            status.side_brush_life_level == _INITIAL_STATE_PRO2["side_brush_life_level"]
+        )
+        assert status.side_brush_time_left == datetime.timedelta(
+            hours=_INITIAL_STATE_PRO2["side_brush_time_left"]
+        )
+        assert status.filter_life_level == _INITIAL_STATE_PRO2["filter_life_level"]
+        assert status.filter_time_left == datetime.timedelta(
+            hours=_INITIAL_STATE_PRO2["filter_time_left"]
+        )
+        assert status.mop_life_level == _INITIAL_STATE_PRO2["mop_life_level"]
+        assert status.mop_time_left == datetime.timedelta(
+            hours=_INITIAL_STATE_PRO2["mop_time_left"]
+        )
+        assert status.clean_area == _INITIAL_STATE_PRO2["clean_area"]
+        assert status.clean_time == datetime.timedelta(
+            minutes=_INITIAL_STATE_PRO2["clean_time"]
+        )
+        assert status.current_language == _INITIAL_STATE_PRO2["current_language"]
+
+    def test_fanspeed_presets(self):
+        presets = self.device.fan_speed_presets()
+        for item in FanSpeedMode:
+            assert item.name in presets
+            assert presets[item.name] == item.value
+
+    def test_set_fan_speed_preset(self):
+        for speed in self.device.fan_speed_presets().values():
+            self.device.set_fan_speed_preset(speed)
+            status = self.device.status()
+            assert status.fan_speed == FanSpeedMode(speed)
+
+    def test_set_fan_speed(self):
+        for speed in self.device.fan_speed_presets().values():
+            self.device.set_fan_speed(speed)
+            status = self.device.status()
+            assert status.fan_speed == FanSpeedMode(speed)