From 15589bf939f10272a982982a78af163d06c4452c Mon Sep 17 00:00:00 2001 From: Christian Lehmann Date: Wed, 17 Feb 2021 06:19:51 +0100 Subject: [PATCH] Initial support for Vacuum 1C STYTJ01ZHM (dreame.vacuum.mc1808) --- README.rst | 1 + miio/__init__.py | 1 + miio/dreamevacuum_miot.py | 292 +++++++++++++++++++++++++++ miio/heater.py | 2 +- miio/tests/test_dreamevacuum_miot.py | 95 +++++++++ 5 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 miio/dreamevacuum_miot.py create mode 100644 miio/tests/test_dreamevacuum_miot.py diff --git a/README.rst b/README.rst index a93d1f401..9aec7a65d 100644 --- a/README.rst +++ b/README.rst @@ -96,6 +96,7 @@ Supported devices - Xiaomi Aqara Gateway (basic implementation, alarm, lights) - Xiaomi Mijia 360 1080p - Xiaomi Mijia STYJ02YM (Viomi) +- Xiaomi Mijia 1C STYTJ01ZHM (Dreame) - Xiaomi Mi Smart WiFi Socket - Xiaomi Chuangmi Plug V1 (1 Socket, 1 USB Port) - Xiaomi Chuangmi Plug V3 (1 Socket, 2 USB Ports) diff --git a/miio/__init__.py b/miio/__init__.py index e91175222..1cc4f6dfc 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -32,6 +32,7 @@ from miio.cooker import Cooker from miio.curtain_youpin import CurtainMiot from miio.device import Device +from miio.dreamevacuum_miot import DreameVacuumMiot from miio.exceptions import DeviceError, DeviceException from miio.fan import Fan, FanP5, FanSA1, FanV2, FanZA1, FanZA4 from miio.fan_leshow import FanLeshow diff --git a/miio/dreamevacuum_miot.py b/miio/dreamevacuum_miot.py new file mode 100644 index 000000000..fd7231736 --- /dev/null +++ b/miio/dreamevacuum_miot.py @@ -0,0 +1,292 @@ +"""Vacuum 1C STYTJ01ZHM (dreame.vacuum.mc1808)""" + +import logging +from enum import Enum + +from .click_common import command, format_output +from .miot_device import MiotDevice + +_LOGGER = logging.getLogger(__name__) + +_MAPPING = { + "battery_level": {"siid": 2, "piid": 1}, + "charging_state": {"siid": 2, "piid": 2}, + "device_fault": {"siid": 3, "piid": 1}, + "device_status": {"siid": 3, "piid": 2}, + "brush_left_time": {"siid": 26, "piid": 1}, + "brush_life_level": {"siid": 26, "piid": 2}, + "filter_life_level": {"siid": 27, "piid": 1}, + "filter_left_time": {"siid": 27, "piid": 2}, + "brush_left_time2": {"siid": 28, "piid": 1}, + "brush_life_level2": {"siid": 28, "piid": 2}, + "operating_mode": {"siid": 18, "piid": 1}, + "cleaning_mode": {"siid": 18, "piid": 6}, + "delete_timer": {"siid": 18, "piid": 8}, + "life_sieve": {"siid": 19, "piid": 1}, + "life_brush_side": {"siid": 19, "piid": 2}, + "life_brush_main": {"siid": 19, "piid": 3}, + "timer_enable": {"siid": 20, "piid": 1}, + "start_time": {"siid": 20, "piid": 2}, + "stop_time": {"siid": 20, "piid": 3}, + "deg": {"siid": 21, "piid": 1, "access": ["write"]}, + "speed": {"siid": 21, "piid": 2, "access": ["write"]}, + "map_view": {"siid": 23, "piid": 1}, + "frame_info": {"siid": 23, "piid": 2}, + "volume": {"siid": 24, "piid": 1}, + "voice_package": {"siid": 24, "piid": 3}, +} + + +class ChargingState(Enum): + Unknown = -1 + Charging = 1 + Discharging = 2 + Charging2 = 4 + GoCharging = 5 + + +class CleaningMode(Enum): + Unknown = -1 + Quiet = 0 + Default = 1 + Medium = 2 + Strong = 3 + + +class OperatingMode(Enum): + Unknown = -1 + Cleaning = 2 + GoCharging = 3 + Paused = 14 + + +class FaultStatus(Enum): + Unknown = -1 + NoFaults = 0 + + +class DeviceStatus(Enum): + Unknown = -1 + Sweeping = 1 + Idle = 2 + Paused = 3 + Error = 4 + GoCharging = 5 + Charging = 6 + + +class DreameVacuumStatus: + def __init__(self, data): + self.data = data + + @property + def battery_level(self) -> str: + return self.data["battery_level"] + + @property + def brush_left_time(self) -> str: + return self.data["brush_left_time"] + + @property + def brush_left_time2(self) -> str: + return self.data["brush_left_time2"] + + @property + def brush_life_level2(self) -> str: + return self.data["brush_life_level2"] + + @property + def brush_life_level(self) -> str: + return self.data["brush_life_level"] + + @property + def filter_left_time(self) -> str: + return self.data["filter_left_time"] + + @property + def filter_life_level(self) -> str: + return self.data["filter_life_level"] + + @property + def device_fault(self) -> FaultStatus: + try: + return FaultStatus(self.data["device_fault"]) + except ValueError: + _LOGGER.error("Unknown FaultStatus (%s)", self.data["device_fault"]) + return FaultStatus.Unknown + + @property + def charging_state(self) -> ChargingState: + try: + return ChargingState(self.data["charging_state"]) + except ValueError: + _LOGGER.error("Unknown ChargingStats (%s)", self.data["charging_state"]) + return ChargingState.Unknown + + @property + def operating_mode(self) -> OperatingMode: + try: + return OperatingMode(self.data["operating_mode"]) + except ValueError: + _LOGGER.error("Unknown OperatingMode (%s)", self.data["operating_mode"]) + return OperatingMode.Unknown + + @property + def cleaning_mode(self) -> CleaningMode: + try: + return CleaningMode(self.data["cleaning_mode"]) + except ValueError: + _LOGGER.error("Unknown CleaningMode (%s)", self.data["cleaning_mode"]) + return CleaningMode.Unknown + + @property + def device_status(self) -> DeviceStatus: + try: + return DeviceStatus(self.data["device_status"]) + except TypeError: + _LOGGER.error("Unknown DeviceStatus (%s)", self.data["device_status"]) + return DeviceStatus.Unknown + + @property + def life_sieve(self) -> str: + return self.data["life_sieve"] + + @property + def life_brush_side(self) -> str: + return self.data["life_brush_side"] + + @property + def life_brush_main(self) -> str: + return self.data["life_brush_main"] + + @property + def timer_enable(self) -> str: + return self.data["timer_enable"] + + @property + def start_time(self) -> str: + return self.data["start_time"] + + @property + def stop_time(self) -> str: + return self.data["stop_time"] + + @property + def map_view(self) -> str: + return self.data["map_view"] + + @property + def volume(self) -> str: + return self.data["volume"] + + @property + def voice_package(self) -> str: + return self.data["voice_package"] + + +class DreameVacuumMiot(MiotDevice): + """Interface for Vacuum 1C STYTJ01ZHM (dreame.vacuum.mc1808)""" + + def __init__( + self, ip: str, token: str = None, start_id: int = 0, debug: int = 0 + ) -> None: + super().__init__(_MAPPING, ip, token, start_id, debug) + + @command( + default_output=format_output( + "\n", + "Battery level: {result.battery_level}\n" + "Brush life level: {result.brush_life_level}\n" + "Brush left time: {result.brush_left_time}\n" + "Charging state: {result.charging_state.name}\n" + "Cleaning mode: {result.cleaning_mode.name}\n" + "Device fault: {result.device_fault.name}\n" + "Device status: {result.device_status.name}\n" + "Filter left level: {result.filter_left_time}\n" + "Filter life level: {result.filter_life_level}\n" + "Life brush main: {result.life_brush_main}\n" + "Life brush side: {result.life_brush_side}\n" + "Life sieve: {result.life_sieve}\n" + "Map view: {result.map_view}\n" + "Operating mode: {result.operating_mode.name}\n" + "Side cleaning brush left time: {result.brush_left_time2}\n" + "Side cleaning brush life level: {result.brush_life_level2}\n" + "Timer enabled: {result.timer_enable}\n" + "Timer start time: {result.start_time}\n" + "Timer stop time: {result.stop_time}\n" + "Voice package: {result.voice_package}\n" + "Volume: {result.volume}\n", + ) + ) + def status(self) -> DreameVacuumStatus: + """State of the vacuum.""" + + return DreameVacuumStatus( + { + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping() + } + ) + + def send_action(self, siid, aiid, params=None): + """Send action to device.""" + + # {"did":"","siid":18,"aiid":1,"in":[{"piid":1,"value":2}] + if params is None: + params = [] + payload = { + "did": f"call-{siid}-{aiid}", + "siid": siid, + "aiid": aiid, + "in": params, + } + return self.send("action", payload) + + @command() + def start(self) -> None: + """Start cleaning.""" + return self.send_action(3, 1) + + @command() + def stop(self) -> None: + """Stop cleaning.""" + return self.send_action(3, 2) + + @command() + def home(self) -> None: + """Return to home.""" + return self.send_action(2, 1) + + @command() + def identify(self) -> None: + """Locate the device (i am here).""" + return self.send_action(17, 1) + + @command() + def reset_mainbrush_life(self) -> None: + """Reset main brush life.""" + return self.send_action(26, 1) + + @command() + def reset_filter_life(self) -> None: + """Reset filter life.""" + return self.send_action(27, 1) + + @command() + def reset_sidebrush_life(self) -> None: + """Reset side brush life.""" + return self.send_action(28, 1) + + def get_properties_for_mapping(self) -> list: + """Retrieve raw properties based on mapping. + + Method was copied from the base class to change the value of max_properties to + 10. This change is needed to avoid "Checksum error" messages from the device. + """ + + # We send property key in "did" because it's sent back via response and we can identify the property. + properties = [{"did": k, **v} for k, v in self.mapping.items()] + + return self.get_properties( + properties, property_getter="get_properties", max_properties=10 + ) diff --git a/miio/heater.py b/miio/heater.py index 441047aa8..e890203bb 100644 --- a/miio/heater.py +++ b/miio/heater.py @@ -163,7 +163,7 @@ def __init__( ) -> None: super().__init__(ip, token, start_id, debug, lazy_discover) - if model in SUPPORTED_MODELS.keys(): + if model in SUPPORTED_MODELS: self.model = model else: self.model = MODEL_HEATER_ZA1 diff --git a/miio/tests/test_dreamevacuum_miot.py b/miio/tests/test_dreamevacuum_miot.py new file mode 100644 index 000000000..4ef204e3e --- /dev/null +++ b/miio/tests/test_dreamevacuum_miot.py @@ -0,0 +1,95 @@ +from unittest import TestCase + +import pytest + +from miio import DreameVacuumMiot +from miio.dreamevacuum_miot import ( + ChargingState, + CleaningMode, + DeviceStatus, + FaultStatus, + OperatingMode, +) + +from .dummies import DummyMiotDevice + +_INITIAL_STATE = { + "battery_level": 42, + "charging_state": ChargingState.Charging, + "device_fault": FaultStatus.NoFaults, + "device_status": DeviceStatus.Paused, + "brush_left_time": 235, + "brush_life_level": 85, + "filter_life_level": 66, + "filter_left_time": 154, + "brush_left_time2": 187, + "brush_life_level2": 57, + "operating_mode": OperatingMode.Cleaning, + "cleaning_mode": CleaningMode.Medium, + "delete_timer": 12, + "life_sieve": "9000-9000", + "life_brush_side": "12000-12000", + "life_brush_main": "18000-18000", + "timer_enable": "false", + "start_time": "22:00", + "stop_time": "8:00", + "deg": 5, + "speed": 5, + "map_view": "tmp", + "frame_info": 3, + "volume": 4, + "voice_package": "DE", +} + + +class DummyDreameVacuumMiot(DummyMiotDevice, DreameVacuumMiot): + def __init__(self, *args, **kwargs): + self.state = _INITIAL_STATE + super().__init__(*args, **kwargs) + + +@pytest.fixture(scope="function") +def dummydreamevacuum(request): + request.cls.device = DummyDreameVacuumMiot() + + +@pytest.mark.usefixtures("dummydreamevacuum") +class TestDreameVacuum(TestCase): + def test_status(self): + status = self.device.status() + assert status.battery_level == _INITIAL_STATE["battery_level"] + assert status.brush_left_time == _INITIAL_STATE["brush_left_time"] + assert status.brush_left_time2 == _INITIAL_STATE["brush_left_time2"] + assert status.brush_life_level2 == _INITIAL_STATE["brush_life_level2"] + assert status.brush_life_level == _INITIAL_STATE["brush_life_level"] + assert status.filter_left_time == _INITIAL_STATE["filter_left_time"] + assert status.filter_life_level == _INITIAL_STATE["filter_life_level"] + assert status.device_fault == FaultStatus(_INITIAL_STATE["device_fault"]) + assert repr(status.device_fault) == repr( + FaultStatus(_INITIAL_STATE["device_fault"]) + ) + assert status.charging_state == ChargingState(_INITIAL_STATE["charging_state"]) + assert repr(status.charging_state) == repr( + ChargingState(_INITIAL_STATE["charging_state"]) + ) + assert status.operating_mode == OperatingMode(_INITIAL_STATE["operating_mode"]) + assert repr(status.operating_mode) == repr( + OperatingMode(_INITIAL_STATE["operating_mode"]) + ) + assert status.cleaning_mode == CleaningMode(_INITIAL_STATE["cleaning_mode"]) + assert repr(status.cleaning_mode) == repr( + CleaningMode(_INITIAL_STATE["cleaning_mode"]) + ) + assert status.device_status == DeviceStatus(_INITIAL_STATE["device_status"]) + assert repr(status.device_status) == repr( + DeviceStatus(_INITIAL_STATE["device_status"]) + ) + assert status.life_sieve == _INITIAL_STATE["life_sieve"] + assert status.life_brush_side == _INITIAL_STATE["life_brush_side"] + assert status.life_brush_main == _INITIAL_STATE["life_brush_main"] + assert status.timer_enable == _INITIAL_STATE["timer_enable"] + assert status.start_time == _INITIAL_STATE["start_time"] + assert status.stop_time == _INITIAL_STATE["stop_time"] + assert status.map_view == _INITIAL_STATE["map_view"] + assert status.volume == _INITIAL_STATE["volume"] + assert status.voice_package == _INITIAL_STATE["voice_package"]