diff --git a/miio/integrations/vacuum/roborock/tests/test_vacuum.py b/miio/integrations/vacuum/roborock/tests/test_vacuum.py index af9408bd0..f88df3e4f 100644 --- a/miio/integrations/vacuum/roborock/tests/test_vacuum.py +++ b/miio/integrations/vacuum/roborock/tests/test_vacuum.py @@ -62,6 +62,15 @@ def __init__(self, *args, **kwargs): 1487548800, ], ] + self.dummies["dnd_timer"] = [ + { + "enabled": 1, + "start_minute": 0, + "end_minute": 0, + "start_hour": 22, + "end_hour": 8, + } + ] self.return_values = { "get_status": lambda x: [self.state], @@ -75,6 +84,8 @@ def __init__(self, *args, **kwargs): "app_zoned_clean": lambda x: self.change_mode("zoned clean"), "app_charge": lambda x: self.change_mode("charge"), "miIO.info": "dummy info", + "get_clean_record": lambda x: [[1488347071, 1488347123, 16, 0, 0, 0]], + "get_dnd_timer": lambda x: self.dummies["dnd_timer"], } super().__init__(args, kwargs) diff --git a/miio/integrations/vacuum/roborock/vacuum.py b/miio/integrations/vacuum/roborock/vacuum.py index 3f45dfac2..1e0cd5272 100644 --- a/miio/integrations/vacuum/roborock/vacuum.py +++ b/miio/integrations/vacuum/roborock/vacuum.py @@ -1,6 +1,5 @@ import contextlib import datetime -import enum import json import logging import math @@ -24,6 +23,22 @@ from miio.exceptions import DeviceInfoUnavailableException, UnsupportedFeatureException from miio.interfaces import FanspeedPresets, VacuumInterface +from .vacuum_enums import ( + CarpetCleaningMode, + Consumable, + DustCollectionMode, + FanspeedE2, + FanspeedEnum, + FanspeedS7, + FanspeedS7_Maxv, + FanspeedV1, + FanspeedV2, + FanspeedV3, + MopIntensity, + MopMode, + TimerState, + WaterFlow, +) from .vacuumcontainers import ( CarpetModeStatus, CleaningDetails, @@ -39,112 +54,6 @@ _LOGGER = logging.getLogger(__name__) -class TimerState(enum.Enum): - On = "on" - Off = "off" - - -class Consumable(enum.Enum): - MainBrush = "main_brush_work_time" - SideBrush = "side_brush_work_time" - Filter = "filter_work_time" - SensorDirty = "sensor_dirty_time" - - -class FanspeedEnum(enum.Enum): - pass - - -class FanspeedV1(FanspeedEnum): - Silent = 38 - Standard = 60 - Medium = 77 - Turbo = 90 - - -class FanspeedV2(FanspeedEnum): - Silent = 101 - Standard = 102 - Medium = 103 - Turbo = 104 - Gentle = 105 - Auto = 106 - - -class FanspeedV3(FanspeedEnum): - Silent = 38 - Standard = 60 - Medium = 75 - Turbo = 100 - - -class FanspeedE2(FanspeedEnum): - # Original names from the app: Gentle, Silent, Standard, Strong, Max - Gentle = 41 - Silent = 50 - Standard = 68 - Medium = 79 - Turbo = 100 - - -class FanspeedS7(FanspeedEnum): - Silent = 101 - Standard = 102 - Medium = 103 - Turbo = 104 - - -class FanspeedS7_Maxv(FanspeedEnum): - Silent = 101 - Standard = 102 - Medium = 103 - Turbo = 104 - Max = 108 - - -class WaterFlow(enum.Enum): - """Water flow strength on s5 max.""" - - Minimum = 200 - Low = 201 - High = 202 - Maximum = 203 - - -class MopMode(enum.Enum): - """Mop routing on S7.""" - - Standard = 300 - Deep = 301 - - -class MopIntensity(enum.Enum): - """Mop scrub intensity on S7 + S7MAXV.""" - - Close = 200 - Mild = 201 - Moderate = 202 - Intense = 203 - - -class CarpetCleaningMode(enum.Enum): - """Type of carpet cleaning/avoidance.""" - - Avoid = 0 - Rise = 1 - Ignore = 2 - - -class DustCollectionMode(enum.Enum): - """Auto emptying mode (S7 + S7MAXV only)""" - - Smart = 0 - Quick = 1 - Daily = 2 - Strong = 3 - Max = 4 - - ROCKROBO_V1 = "rockrobo.vacuum.v1" ROCKROBO_S4 = "roborock.vacuum.s4" ROCKROBO_S4_MAX = "roborock.vacuum.a19" @@ -410,11 +319,17 @@ def manual_control( @command() def status(self) -> VacuumStatus: """Return status of the vacuum.""" - status = VacuumStatus(self.send("get_status")[0]) + status = self.vacuum_status() status.embed(self.consumable_status()) status.embed(self.clean_history()) + status.embed(self.dnd_status()) return status + @command() + def vacuum_status(self) -> VacuumStatus: + """Return only status of the vacuum.""" + return VacuumStatus(self.send("get_status")[0]) + def enable_log_upload(self): raise NotImplementedError("unknown parameters") # return self.send("enable_log_upload") @@ -964,7 +879,7 @@ def set_mop_mode(self, mop_mode: MopMode): @command() def mop_intensity(self) -> MopIntensity: """Get mop scrub intensity setting.""" - if self.model != ROCKROBO_S7: + if self.model not in [ROCKROBO_S7, ROCKROBO_S7_MAXV]: raise UnsupportedFeatureException( "Mop scrub intensity not supported by %s", self.model ) @@ -974,7 +889,7 @@ def mop_intensity(self) -> MopIntensity: @command(click.argument("mop_intensity", type=EnumType(MopIntensity))) def set_mop_intensity(self, mop_intensity: MopIntensity): """Set mop scrub intensity setting.""" - if self.model != ROCKROBO_S7: + if self.model not in [ROCKROBO_S7, ROCKROBO_S7_MAXV]: raise UnsupportedFeatureException( "Mop scrub intensity not supported by %s", self.model ) diff --git a/miio/integrations/vacuum/roborock/vacuum_enums.py b/miio/integrations/vacuum/roborock/vacuum_enums.py new file mode 100644 index 000000000..3cf0cab94 --- /dev/null +++ b/miio/integrations/vacuum/roborock/vacuum_enums.py @@ -0,0 +1,110 @@ +import enum + + +class TimerState(enum.Enum): + On = "on" + Off = "off" + + +class Consumable(enum.Enum): + MainBrush = "main_brush_work_time" + SideBrush = "side_brush_work_time" + Filter = "filter_work_time" + SensorDirty = "sensor_dirty_time" + + +class FanspeedEnum(enum.Enum): + pass + + +class FanspeedV1(FanspeedEnum): + Silent = 38 + Standard = 60 + Medium = 77 + Turbo = 90 + + +class FanspeedV2(FanspeedEnum): + Silent = 101 + Standard = 102 + Medium = 103 + Turbo = 104 + Gentle = 105 + Auto = 106 + + +class FanspeedV3(FanspeedEnum): + Silent = 38 + Standard = 60 + Medium = 75 + Turbo = 100 + + +class FanspeedE2(FanspeedEnum): + # Original names from the app: Gentle, Silent, Standard, Strong, Max + Gentle = 41 + Silent = 50 + Standard = 68 + Medium = 79 + Turbo = 100 + + +class FanspeedS7(FanspeedEnum): + Silent = 101 + Standard = 102 + Medium = 103 + Turbo = 104 + + +class FanspeedS7_Maxv(FanspeedEnum): + # Original names from the app: Quiet, Balanced, Turbo, Max, Max+ + Off = 105 + Silent = 101 + Standard = 102 + Medium = 103 + Turbo = 104 + Max = 108 + + +class WaterFlow(enum.Enum): + """Water flow strength on s5 max.""" + + Minimum = 200 + Low = 201 + High = 202 + Maximum = 203 + + +class MopMode(enum.Enum): + """Mop routing on S7 + S7MAXV.""" + + Standard = 300 + Deep = 301 + DeepPlus = 303 + + +class MopIntensity(enum.Enum): + """Mop scrub intensity on S7 + S7MAXV.""" + + Off = 200 + Mild = 201 + Moderate = 202 + Intense = 203 + + +class CarpetCleaningMode(enum.Enum): + """Type of carpet cleaning/avoidance.""" + + Avoid = 0 + Rise = 1 + Ignore = 2 + + +class DustCollectionMode(enum.Enum): + """Auto emptying mode (S7 + S7MAXV only)""" + + Smart = 0 + Quick = 1 + Daily = 2 + Strong = 3 + Max = 4 diff --git a/miio/integrations/vacuum/roborock/vacuumcontainers.py b/miio/integrations/vacuum/roborock/vacuumcontainers.py index 6e62e3fb0..a7d81a92b 100644 --- a/miio/integrations/vacuum/roborock/vacuumcontainers.py +++ b/miio/integrations/vacuum/roborock/vacuumcontainers.py @@ -10,6 +10,8 @@ from miio.interfaces.vacuuminterface import VacuumDeviceStatus, VacuumState from miio.utils import pretty_seconds, pretty_time +from .vacuum_enums import MopIntensity, MopMode + def pretty_area(x: float) -> float: return int(x) / 1000000 @@ -80,6 +82,15 @@ def pretty_area(x: float) -> float: 22: "Clean the dock charging contacts", 23: "Docking station not reachable", 24: "No-go zone or invisible wall detected", + 26: "Wall sensor is dirty", + 27: "VibraRise system is jammed", + 28: "Roborock is on carpet", +} + +dock_error_codes = { # from vacuum_cleaner-EN.pdf + 0: "No error", + 38: "Clean water tank empty", + 39: "Dirty water tank full", } @@ -129,13 +140,13 @@ def __init__(self, data: Dict[str, Any]) -> None: self.data = data @property - @sensor("State Code") + @sensor("State code", entity_category="diagnostic", enabled_default=False) def state_code(self) -> int: """State code as returned by the device.""" return int(self.data["state"]) @property - @sensor("State message") + @sensor("State", entity_category="diagnostic") def state(self) -> str: """Human readable state description, see also :func:`state_code`.""" return STATE_CODE_TO_STRING.get( @@ -148,13 +159,23 @@ def vacuum_state(self) -> VacuumState: return STATE_CODE_TO_VACUUMSTATE.get(self.state_code, VacuumState.Unknown) @property - @sensor("Error Code", icon="mdi:alert") + @sensor( + "Error code", + icon="mdi:alert", + entity_category="diagnostic", + enabled_default=False, + ) def error_code(self) -> int: """Error code as returned by the device.""" return int(self.data["error_code"]) @property - @sensor("Error", icon="mdi:alert") + @sensor( + "Error string", + icon="mdi:alert", + entity_category="diagnostic", + enabled_default=False, + ) def error(self) -> str: """Human readable error description, see also :func:`error_code`.""" try: @@ -163,7 +184,36 @@ def error(self) -> str: return "Definition missing for error %s" % self.error_code @property - @sensor("Battery", unit="%", device_class="battery") + @sensor( + "Dock error code", + icon="mdi:alert", + entity_category="diagnostic", + enabled_default=False, + ) + def dock_error_code(self) -> Optional[int]: + """Dock error status as returned by the device.""" + if "dock_error_status" in self.data: + return int(self.data["dock_error_status"]) + return None + + @property + @sensor( + "Dock error string", + icon="mdi:alert", + entity_category="diagnostic", + enabled_default=False, + ) + def dock_error(self) -> Optional[str]: + """Human readable dock error description, see also :func:`dock_error_code`.""" + if self.dock_error_code is None: + return None + try: + return dock_error_codes[self.dock_error_code] + except KeyError: + return "Definition missing for dock error %s" % self.dock_error_code + + @property + @sensor("Battery", unit="%", device_class="battery", enabled_default=False) def battery(self) -> int: """Remaining battery in percentage.""" return int(self.data["battery"]) @@ -178,18 +228,53 @@ def battery(self) -> int: step=1, icon="mdi:fan", ) - def fanspeed(self) -> int: + def fanspeed(self) -> Optional[int]: """Current fan speed.""" - return int(self.data["fan_power"]) + fan_power = int(self.data["fan_power"]) + if fan_power > 100: + # values 100+ are reserved for presets + return None + return fan_power + + @property + @setting( + "Mop scrub intensity", + choices=MopIntensity, + setter_name="set_mop_intensity", + icon="mdi:checkbox-multiple-blank-circle-outline", + ) + def mop_intensity(self) -> Optional[int]: + """Current mop intensity.""" + if "water_box_mode" in self.data: + return int(self.data["water_box_mode"]) + return None @property - @sensor("Clean Duration", unit="s", icon="mdi:timer-sand") + @setting( + "Mop route", + choices=MopMode, + setter_name="set_mop_mode", + icon="mdi:swap-horizontal-variant", + ) + def mop_route(self) -> Optional[int]: + """Current mop route.""" + if "mop_mode" in self.data: + return int(self.data["mop_mode"]) + return None + + @property + @sensor( + "Current clean duration", + unit="s", + icon="mdi:timer-sand", + device_class="duration", + ) def clean_time(self) -> timedelta: """Time used for cleaning (if finished, shows how long it took).""" return pretty_seconds(self.data["clean_time"]) @property - @sensor("Cleaned Area", unit="m2", icon="mdi:texture-box") + @sensor("Current clean area", unit="m²", icon="mdi:texture-box") def clean_area(self) -> float: """Cleaned area in m2.""" return pretty_area(self.data["clean_area"]) @@ -226,7 +311,7 @@ def is_on(self) -> bool: ) @property - @sensor("Water Box Attached") + @sensor("Water box attached", icon="mdi:cup-water") def is_water_box_attached(self) -> Optional[bool]: """Return True is water box is installed.""" if "water_box_status" in self.data: @@ -234,7 +319,7 @@ def is_water_box_attached(self) -> Optional[bool]: return None @property - @sensor("Mop Attached") + @sensor("Mop attached") def is_water_box_carriage_attached(self) -> Optional[bool]: """Return True if water box carriage (mop) is installed, None if sensor not present.""" @@ -243,7 +328,7 @@ def is_water_box_carriage_attached(self) -> Optional[bool]: return None @property - @sensor("Water Level Low", icon="mdi:alert") + @sensor("Water level low", icon="mdi:water-alert-outline") def is_water_shortage(self) -> Optional[bool]: """Returns True if water is low in the tank, None if sensor not present.""" if "water_shortage_status" in self.data: @@ -251,7 +336,23 @@ def is_water_shortage(self) -> Optional[bool]: return None @property - @sensor("Error", icon="mdi:alert") + @setting( + "Auto dust collection", + setter_name="set_dust_collection", + icon="mdi:turbine", + entity_category="config", + ) + def auto_dust_collection(self) -> Optional[bool]: + """Returns True if auto dust collection is enabled, None if sensor not + present.""" + if "auto_dust_collection" in self.data: + return self.data["auto_dust_collection"] == 1 + return None + + @property + @sensor( + "Error", icon="mdi:alert", entity_category="diagnostic", enabled_default=False + ) def got_error(self) -> bool: """True if an error has occurred.""" return self.error_code != 0 @@ -283,30 +384,52 @@ def __init__(self, data: Union[List[Any], Dict[str, Any]]) -> None: self.data["records"] = [] @property - @sensor("Total Cleaning Time", icon="mdi:timer-sand") + @sensor( + "Total clean duration", + unit="s", + icon="mdi:timer-sand", + device_class="duration", + entity_category="diagnostic", + ) def total_duration(self) -> timedelta: """Total cleaning duration.""" return pretty_seconds(self.data["clean_time"]) @property - @sensor("Total Cleaning Area", icon="mdi:texture-box") + @sensor( + "Total clean area", + unit="m²", + icon="mdi:texture-box", + entity_category="diagnostic", + ) def total_area(self) -> float: """Total cleaned area.""" return pretty_area(self.data["clean_area"]) @property - @sensor("Total Clean Count") + @sensor( + "Total clean count", + icon="mdi:counter", + state_class="total_increasing", + entity_category="diagnostic", + ) def count(self) -> int: """Number of cleaning runs.""" return int(self.data["clean_count"]) @property def ids(self) -> List[int]: - """A list of available cleaning IDs, see also :class:`CleaningDetails`.""" + """A list of available cleaning IDs, see also + :class:`CleaningDetails`.""" return list(self.data["records"]) @property - @sensor("Dust Collection Count") + @sensor( + "Total dust collection count", + icon="mdi:counter", + state_class="total_increasing", + entity_category="diagnostic", + ) def dust_collection_count(self) -> Optional[int]: """Total number of dust collections.""" if "dust_collection_count" in self.data: @@ -335,21 +458,46 @@ def __init__(self, data: Union[List[Any], Dict[str, Any]]) -> None: self.data = data @property + @sensor( + "Last clean start", + icon="mdi:clock-time-twelve", + device_class="timestamp", + entity_category="diagnostic", + ) def start(self) -> datetime: """When cleaning was started.""" return pretty_time(self.data["begin"]) @property + @sensor( + "Last clean end", + icon="mdi:clock-time-twelve", + device_class="timestamp", + entity_category="diagnostic", + ) def end(self) -> datetime: """When cleaning was finished.""" return pretty_time(self.data["end"]) @property + @sensor( + "Last clean duration", + unit="s", + icon="mdi:timer-sand", + device_class="duration", + entity_category="diagnostic", + ) def duration(self) -> timedelta: """Total duration of the cleaning run.""" return pretty_seconds(self.data["duration"]) @property + @sensor( + "Last clean area", + unit="m²", + icon="mdi:texture-box", + entity_category="diagnostic", + ) def area(self) -> float: """Total cleaned area.""" return pretty_area(self.data["area"]) @@ -397,46 +545,117 @@ def __init__(self, data: Dict[str, Any]) -> None: self.sensor_dirty_total = timedelta(hours=30) @property - @sensor("Main Brush Usage", unit="s") + @sensor( + "Main brush used", + unit="s", + icon="mdi:brush", + device_class="duration", + entity_category="diagnostic", + enabled_default=False, + ) def main_brush(self) -> timedelta: """Main brush usage time.""" return pretty_seconds(self.data["main_brush_work_time"]) @property - @sensor("Main Brush Remaining", unit="s") + @sensor( + "Main brush left", + unit="s", + icon="mdi:brush", + device_class="duration", + entity_category="diagnostic", + ) def main_brush_left(self) -> timedelta: """How long until the main brush should be changed.""" return self.main_brush_total - self.main_brush @property + @sensor( + "Side brush used", + unit="s", + icon="mdi:brush", + device_class="duration", + entity_category="diagnostic", + enabled_default=False, + ) def side_brush(self) -> timedelta: """Side brush usage time.""" return pretty_seconds(self.data["side_brush_work_time"]) @property + @sensor( + "Side brush left", + unit="s", + icon="mdi:brush", + device_class="duration", + entity_category="diagnostic", + ) def side_brush_left(self) -> timedelta: """How long until the side brush should be changed.""" return self.side_brush_total - self.side_brush @property + @sensor( + "Filter used", + unit="s", + icon="mdi:air-filter", + device_class="duration", + entity_category="diagnostic", + enabled_default=False, + ) def filter(self) -> timedelta: """Filter usage time.""" return pretty_seconds(self.data["filter_work_time"]) @property + @sensor( + "Filter left", + unit="s", + icon="mdi:air-filter", + device_class="duration", + entity_category="diagnostic", + ) def filter_left(self) -> timedelta: """How long until the filter should be changed.""" return self.filter_total - self.filter @property + @sensor( + "Sensor dirty used", + unit="s", + icon="mdi:eye-outline", + device_class="duration", + entity_category="diagnostic", + enabled_default=False, + ) def sensor_dirty(self) -> timedelta: """Return ``sensor_dirty_time``""" return pretty_seconds(self.data["sensor_dirty_time"]) @property + @sensor( + "Sensor dirty left", + unit="s", + icon="mdi:eye-outline", + device_class="duration", + entity_category="diagnostic", + ) def sensor_dirty_left(self) -> timedelta: return self.sensor_dirty_total - self.sensor_dirty + @property + @sensor( + "Dustbin times auto-empty used", + icon="mdi:delete", + entity_category="diagnostic", + enabled_default=False, + ) + def dustbin_auto_empty_used(self) -> Optional[int]: + """Return ``dust_collection_work_times``""" + if "dust_collection_work_times" in self.data: + return self.data["dust_collection_work_times"] + return None + class DNDStatus(DeviceStatus): """A container for the do-not-disturb status.""" @@ -447,17 +666,31 @@ def __init__(self, data: Dict[str, Any]): self.data = data @property - @sensor("Do Not Disturb") + @sensor("Do not disturb", icon="mdi:minus-circle-off", entity_category="diagnostic") def enabled(self) -> bool: """True if DnD is enabled.""" return bool(self.data["enabled"]) @property + @sensor( + "Do not disturb start", + icon="mdi:minus-circle-off", + device_class="timestamp", + entity_category="diagnostic", + enabled_default=False, + ) def start(self) -> time: """Start time of DnD.""" return time(hour=self.data["start_hour"], minute=self.data["start_minute"]) @property + @sensor( + "Do not disturb end", + icon="mdi:minus-circle-off", + device_class="timestamp", + entity_category="diagnostic", + enabled_default=False, + ) def end(self) -> time: """End time of DnD.""" return time(hour=self.data["end_hour"], minute=self.data["end_minute"]) @@ -616,7 +849,7 @@ def __init__(self, data): self.data = data @property - @sensor("Carpet Mode") + @sensor("Carpet mode") def enabled(self) -> bool: """True if carpet mode is enabled.""" return self.data["enable"] == 1