From ad58bee5cc60ca69fe5515fd8457b1665a56e041 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) --- miio/__init__.py | 1 + miio/dreamevacuum_miot.py | 376 +++++++++++++++++++++++++++ miio/tests/test_dreamevacuum_miot.py | 95 +++++++ 3 files changed, 472 insertions(+) create mode 100644 miio/dreamevacuum_miot.py create mode 100644 miio/tests/test_dreamevacuum_miot.py 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..cf2f9f6fc --- /dev/null +++ b/miio/dreamevacuum_miot.py @@ -0,0 +1,376 @@ +"""Vacuum 1C STYTJ01ZHM (dreame.vacuum.mc1808) + +Commands: + home Return to home. + identify Locate the device (i am here). + info Get miIO protocol information from the device. + raw_command Send a raw command to the device. + reset_filter_life Reset filter life. + reset_mainbrush_life Reset main brush life. + reset_sidebrush_life Reset side brush life. + start Start cleaning. + status State of the vacuum. + stop Stop cleaning. +""" + +import logging +from enum import Enum + +from miio.miot_device import MiotDevice + +from .click_common import command, format_output + +_LOGGER = logging.getLogger(__name__) + +_MAPPING = { + # siid 1: (Device Information): 4 props, 0 actions + # piid: 4 (Current Firmware Version): (string, unit: None) (acc: ['read'], value-list: [], value-range: None) + # piid: 1 (Device Manufacturer): (string, unit: None) (acc: ['read'], value-list: [], value-range: None) + # piid: 2 (Device Model): (string, unit: None) (acc: ['read'], value-list: [], value-range: None) + # piid: 3 (Device Serial Number): (string, unit: None) (acc: ['read'], value-list: [], value-range: None) + # siid 2: (Battery): 2 props, 1 actions + # piid: 1 (Battery Level): (uint8, unit: percentage) (acc: ['read', 'notify'], value-list: [], value-range: [0, 100, 1]) + "battery_level": {"siid": 2, "piid": 1}, + # piid: 2 (Charging State): (uint8, unit: None) (acc: ['read', 'notify'], value-list: [{'value': 1, 'description': 'Charging'}, {'value': 2, 'description': 'Not Charging'}, {'value': 4, 'description': 'Charging'}, {'value': 5, 'description': 'Go Charging'}], value-range: None) + "charging_state": {"siid": 2, "piid": 2}, + # siid 3: (Robot Cleaner): 2 props, 2 actions + # piid: 1 (Device Fault): (uint8, unit: None) (acc: ['read', 'notify'], value-list: [{'value': 0, 'description': 'No faults'}], value-range: None) + "device_fault": {"siid": 3, "piid": 1}, + # piid: 2 (Status): (int8, unit: None) (acc: ['read', 'notify'], value-list: [{'value': 1, 'description': 'Sweeping'}, {'value': 2, 'description': 'Idle'}, {'value': 3, 'description': 'Paused'}, {'value': 4, 'description': 'Error'}, {'value': 5, 'description': 'Go Charging'}, {'value': 6, 'description': 'Charging'}], value-range: None) + "device_status": {"siid": 3, "piid": 2}, + # siid 17: (Identify): 0 props, 1 actions + # siid 26: (Main Cleaning Brush): 2 props, 1 actions + # piid: 1 (Brush Left Time): (uint16, unit: hour) (acc: ['read', 'notify'], value-list: [], value-range: [0, 300, 1]) + "brush_left_time": {"siid": 26, "piid": 1}, + # piid: 2 (Brush Life Level): (uint8, unit: percentage) (acc: ['read', 'notify'], value-list: [], value-range: [0, 100, 1]) + "brush_life_level": {"siid": 26, "piid": 2}, + # siid 27: (Filter): 2 props, 1 actions + # piid: 1 (Filter Life Level): (uint8, unit: percentage) (acc: ['read', 'notify'], value-list: [], value-range: [0, 100, 1]) + "filter_life_level": {"siid": 27, "piid": 1}, + # piid: 2 (Filter Left Time): (uint16, unit: hour) (acc: ['read', 'notify'], value-list: [], value-range: [0, 300, 1]) + "filter_left_time": {"siid": 27, "piid": 2}, + # siid 28: (Side Cleaning Brush): 2 props, 1 actions + # piid: 1 (Brush Left Time): (uint16, unit: hour) (acc: ['read', 'notify'], value-list: [], value-range: [0, 300, 1]) + "brush_left_time2": {"siid": 28, "piid": 1}, + # piid: 2 (Brush Life Level): (uint8, unit: percentage) (acc: ['read', 'notify'], value-list: [], value-range: [0, 100, 1]) + "brush_life_level2": {"siid": 28, "piid": 2}, + # siid 18: (clean): 16 props, 2 actions + # piid: 1 (Operating Mode): (int32, unit: none) (acc: ['read', 'notify'], value-list: [], value-range: [0, 17, 1]) + "operating_mode": {"siid": 18, "piid": 1}, + # piid: 4 (area): (string, unit: None) (acc: ['read', 'write'], value-list: [], value-range: None) + # piid: 6 (Cleaning Mode): (int32, unit: none) (acc: ['read', 'write', 'notify'], value-list: [{'value': 0, 'description': 'quiet'}, {'value': 1, 'description': 'default'}, {'value': 2, 'description': 'medium'}, {'value': 3, 'description': 'strong'}], value-range: None) + "cleaning_mode": {"siid": 18, "piid": 6}, + # piid: 8 (delete-timer): (int32, unit: None) (acc: ['write'], value-list: [], value-range: [0, 100, 1]) + "delete_timer": {"siid": 18, "piid": 8}, + # piid: 13 (): (uint32, unit: minutes) (acc: ['read', 'notify'], value-list: [], value-range: [0, 4294967295, 1]) + #: int = field(metadata={'siid': 18, 'piid': 13, 'access': ['read', 'notify']}) + # piid: 14 (): (uint32, unit: None) (acc: ['read', 'notify'], value-list: [], value-range: [0, 4294967295, 1]) + #: int = field(metadata={'siid': 18, 'piid': 14, 'access': ['read', 'notify']}) + # piid: 15 (): (uint32, unit: None) (acc: ['read', 'notify'], value-list: [], value-range: [0, 4294967295, 1]) + #: int = field(metadata={'siid': 18, 'piid': 15, 'access': ['read', 'notify']}) + # piid: 16 (): (uint32, unit: None) (acc: ['read', 'notify'], value-list: [], value-range: [0, 4294967295, 1]) + #: int = field(metadata={'siid': 18, 'piid': 16, 'access': ['read', 'notify']}) + # piid: 17 (): (uint16, unit: None) (acc: ['read', 'notify'], value-list: [], value-range: [0, 100, 1]) + #: int = field(metadata={'siid': 18, 'piid': 17, 'access': ['read', 'notify']}) + # piid: 18 (): (uint8, unit: None) (acc: ['read', 'notify'], value-list: [{'value': 0, 'description': ''}, {'value': 1, 'description': ''}], value-range: None) + #: int = field(metadata={'siid': 18, 'piid': 18, 'access': ['read', 'notify']}) + # siid 19: (consumable): 3 props, 0 actions + # piid: 1 (life-sieve): (string, unit: None) (acc: ['read', 'write'], value-list: [], value-range: None) + "life_sieve": {"siid": 19, "piid": 1}, + # piid: 2 (life-brush-side): (string, unit: None) (acc: ['read', 'write'], value-list: [], value-range: None) + "life_brush_side": {"siid": 19, "piid": 2}, + # piid: 3 (life-brush-main): (string, unit: None) (acc: ['read', 'write'], value-list: [], value-range: None) + "life_brush_main": {"siid": 19, "piid": 3}, + # siid 20: (annoy): 3 props, 0 actions + # piid: 1 (enable): (bool, unit: None) (acc: ['read', 'write'], value-list: [], value-range: None) + "timer_enable": {"siid": 20, "piid": 1}, + # piid: 2 (start-time): (string, unit: None) (acc: ['read', 'write'], value-list: [], value-range: None) + "start_time": {"siid": 20, "piid": 2}, + # piid: 3 (stop-time): (string, unit: None) (acc: ['read', 'write'], value-list: [], value-range: None) + "stop_time": {"siid": 20, "piid": 3}, + # siid 21: (remote): 2 props, 3 actions + # piid: 1 (deg): (string, unit: None) (acc: ['write'], value-list: [], value-range: None) + "deg": {"siid": 21, "piid": 1, "access": ["write"]}, + # piid: 2 (speed): (string, unit: None) (acc: ['write'], value-list: [], value-range: None) + "speed": {"siid": 21, "piid": 2, "access": ["write"]}, + # siid 22: (warn): 1 props, 0 actions + # siid 23: (map): 3 props, 1 actions + # piid: 1 (map-view): (string, unit: None) (acc: ['read', 'notify'], value-list: [], value-range: None) + "map_view": {"siid": 23, "piid": 1}, + # piid: 2 (frame-info): (string, unit: None) (acc: ['write'], value-list: [], value-range: None) + "frame_info": {"siid": 23, "piid": 2}, + # siid 24: (audio): 2 props, 3 actions + # piid: 1 (volume): (int32, unit: None) (acc: ['read', 'write', 'notify'], value-list: [], value-range: [0, 100, 1]) + "volume": {"siid": 24, "piid": 1}, + # piid: 3 (voice package): (string, unit: none) (acc: ['read', 'write'], value-list: [], value-range: None) + "voice_package": {"siid": 24, "piid": 3} + # siid 25: (): 1 props, 0 actions + # piid: 1 (): (string, unit: None) (acc: ['read', 'notify'], value-list: [], value-range: None) + # : str = field(metadata={'siid': 25, 'piid': 1, 'access': ['read', 'notify']}) +} + + +class ChargingState(Enum): + unknown = -1 + charging = 1 + not_charging = 2 + charging2 = 4 + go_charging = 5 + + +class CleaningMode(Enum): + unknown = -1 + quiet = 0 + default = 1 + medium = 2 + strong = 3 + + +# TODO: Replace with correct operating modes +class OperatingMode(Enum): + unknown = -1 + fixme0 = 0 + fixme1 = 1 + fixme14 = 14 + fixme17 = 17 + + +class FaultStatus(Enum): + unknown = -1 + no_faults = 0 + + +class DeviceStatus(Enum): + unknown = -1 + sweeping = 1 + idle = 2 + paused = 3 + error = 4 + go_charging = 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) + + # siid 3: (Robot Cleaner): 2 props, 2 actions + # aiid 1 Start Sweep: in: [] -> out: [] + @command() + def start(self) -> None: + """Start cleaning.""" + return self.send_action(3, 1) + + # aiid 2 Stop Sweeping: in: [] -> out: [] + @command() + def stop(self) -> None: + """Stop cleaning.""" + return self.send_action(3, 2) + + # siid 2: (Battery): 2 props, 1 actions + # aiid 1 Start Charge: in: [] -> out: [] + @command() + def home(self) -> None: + """Return to home.""" + return self.send_action(2, 1) + + # siid 17: (Identify): 0 props, 1 actions + # aiid 1 Identify: in: [] -> out: [] + @command() + def identify(self) -> None: + """Locate the device (i am here).""" + return self.send_action(17, 1) + + # siid 26: (Main Cleaning Brush): 2 props, 1 actions + # aiid 1 Reset Brush Life: in: [] -> out: [] + @command() + def reset_mainbrush_life(self) -> None: + """Reset main brush life.""" + return self.send_action(26, 1) + + # siid 27: (Filter): 2 props, 1 actions + # aiid 1 Reset Filter Life: in: [] -> out: [] + @command() + def reset_filter_life(self) -> None: + """Reset filter life.""" + return self.send_action(27, 1) + + # siid 28: (Side Cleaning Brush): 2 props, 1 actions + # aiid 1 Reset Brush Life: in: [] -> out: [] + @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.""" + + # 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/tests/test_dreamevacuum_miot.py b/miio/tests/test_dreamevacuum_miot.py new file mode 100644 index 000000000..774ba67bb --- /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.no_faults, + "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.fixme14, + "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"]