Skip to content

Commit cade07d

Browse files
committed
feat: improve dynamic clean
1 parent 93c7be1 commit cade07d

File tree

7 files changed

+299
-52
lines changed

7 files changed

+299
-52
lines changed

roborock/clean_modes.py

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from enum import StrEnum
44

5-
from roborock import DeviceFeatures
5+
from .device_features import DeviceFeatures
66

77

88
class RoborockModeEnum(StrEnum):
@@ -18,7 +18,7 @@ def __new__(cls, value: str, code: int) -> RoborockModeEnum:
1818
return member
1919

2020

21-
class CleanModes(RoborockModeEnum):
21+
class VacuumModes(RoborockModeEnum):
2222
GENTLE = ("gentle", 105)
2323
OFF = ("off", 105)
2424
QUIET = ("quiet", 101)
@@ -61,18 +61,41 @@ class WaterModes(RoborockModeEnum):
6161
SMART_MODE = ("smart_mode", 209)
6262

6363

64-
def get_clean_modes(features: DeviceFeatures) -> list[CleanModes]:
64+
class WashTowelModes(RoborockModeEnum):
65+
SMART = ("smart", 10)
66+
LIGHT = ("light", 0)
67+
BALANCED = ("balanced", 1)
68+
DEEP = ("deep", 2)
69+
SUPER_DEEP = ("super_deep", 8)
70+
71+
72+
def get_wash_towel_modes(features: DeviceFeatures) -> list[WashTowelModes]:
73+
"""Get the valid wash towel modes for the device"""
74+
modes = [WashTowelModes.LIGHT, WashTowelModes.BALANCED, WashTowelModes.DEEP]
75+
if features.is_super_deep_wash_supported and not features.is_dirty_replenish_clean_supported:
76+
modes.append(WashTowelModes.SUPER_DEEP)
77+
elif features.is_dirty_replenish_clean_supported:
78+
modes.append(WashTowelModes.SMART)
79+
return modes
80+
81+
82+
def get_clean_modes(features: DeviceFeatures) -> list[VacuumModes]:
6583
"""Get the valid clean modes for the device - also known as 'fan power' or 'suction mode'"""
66-
modes = [CleanModes.QUIET, CleanModes.BALANCED, CleanModes.TURBO, CleanModes.MAX]
84+
modes = [VacuumModes.QUIET, VacuumModes.BALANCED, VacuumModes.TURBO, VacuumModes.MAX]
6785
if features.is_max_plus_mode_supported or features.is_none_pure_clean_mop_with_max_plus:
6886
# If the vacuum has max plus mode supported
69-
modes.append(CleanModes.MAX_PLUS)
87+
modes.append(VacuumModes.MAX_PLUS)
7088
if features.is_pure_clean_mop_supported:
7189
# If the vacuum is capable of 'pure mop clean' aka no vacuum
72-
modes.append(CleanModes.OFF)
90+
modes.append(VacuumModes.OFF)
7391
else:
7492
# If not, we can add gentle
75-
modes.append(CleanModes.GENTLE)
93+
modes.append(VacuumModes.GENTLE)
94+
if features.is_smart_clean_mode_set_supported:
95+
modes.append(VacuumModes.SMART_MODE)
96+
if features.is_customized_clean_supported:
97+
modes.append(VacuumModes.CUSTOMIZED)
98+
7699
return modes
77100

78101

@@ -85,7 +108,7 @@ def get_clean_routes(features: DeviceFeatures, region: str) -> list[CleanRoutes]
85108
if not (
86109
features.is_corner_clean_mode_supported
87110
and features.is_clean_route_deep_slow_plus_supported
88-
and region == "CN"
111+
and region == "cn"
89112
):
90113
# for some reason there is a china specific deep plus mode
91114
supported.append(CleanRoutes.DEEP_PLUS_CN)
@@ -94,6 +117,11 @@ def get_clean_routes(features: DeviceFeatures, region: str) -> list[CleanRoutes]
94117

95118
if features.is_clean_route_fast_mode_supported:
96119
supported.append(CleanRoutes.FAST)
120+
if features.is_smart_clean_mode_set_supported:
121+
supported.append(CleanRoutes.SMART_MODE)
122+
if features.is_customized_clean_supported:
123+
supported.append(CleanRoutes.CUSTOMIZED)
124+
97125
return supported
98126

99127

@@ -110,4 +138,18 @@ def get_water_modes(features: DeviceFeatures) -> list[WaterModes]:
110138
supported_modes.append(WaterModes.CUSTOM)
111139
if features.is_mop_shake_module_supported and features.is_mop_shake_water_max_supported:
112140
supported_modes.append(WaterModes.EXTREME)
141+
if features.is_smart_clean_mode_set_supported:
142+
supported_modes.append(WaterModes.SMART_MODE)
143+
if features.is_customized_clean_supported:
144+
supported_modes.append(WaterModes.CUSTOMIZED)
145+
113146
return supported_modes
147+
148+
149+
def is_smart_mode_set(water_mode: WaterModes, clean_mode: VacuumModes, mop_mode: CleanRoutes) -> bool:
150+
"""Check if the smart mode is set for the given water mode and clean mode"""
151+
return (
152+
water_mode == WaterModes.SMART_MODE
153+
or clean_mode == VacuumModes.SMART_MODE
154+
or mop_mode == CleanRoutes.SMART_MODE
155+
)

roborock/cli.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ async def status(ctx, device_id):
235235
devices = home_data.devices + home_data.received_devices
236236
device = next(device for device in devices if device.duid == device_id)
237237
product_info: dict[str, HomeDataProduct] = {product.id: product for product in home_data.products}
238-
device_data = DeviceData(device, product_info[device.product_id].model)
238+
device_data = DeviceData(device, product_info[device.product_id].model, region=cache_data.user_data.region)
239239

240240
mqtt_client = RoborockMqttClientV1(cache_data.user_data, device_data)
241241
if not (networking := cache_data.network_info.get(device.duid)):
@@ -245,7 +245,9 @@ async def status(ctx, device_id):
245245
else:
246246
_LOGGER.debug("Using cached networking info for device %s: %s", device.duid, networking)
247247

248-
local_device_data = DeviceData(device, product_info[device.product_id].model, networking.ip)
248+
local_device_data = DeviceData(
249+
device, product_info[device.product_id].model, networking.ip, region=cache_data.user_data.region
250+
)
249251
local_client = RoborockLocalClientV1(local_device_data)
250252
status = await local_client.get_status()
251253
click.echo(json.dumps(status.as_dict(), indent=4))
@@ -270,7 +272,7 @@ async def command(ctx, cmd, device_id, params):
270272
)
271273
if model is None:
272274
raise RoborockException(f"Could not find model for device {device.name}")
273-
device_info = DeviceData(device=device, model=model)
275+
device_info = DeviceData(device=device, model=model, region=cache_data.user_data.region)
274276
mqtt_client = RoborockMqttClientV1(cache_data.user_data, device_info)
275277
await mqtt_client.send_command(cmd, json.loads(params) if params is not None else None)
276278
await mqtt_client.async_release()

roborock/containers.py

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,17 @@
88
from datetime import timezone
99
from enum import Enum
1010
from functools import cached_property
11-
from typing import Any, NamedTuple, get_args, get_origin
12-
11+
from typing import Any, NamedTuple, TypeVar, get_args, get_origin
12+
13+
from .clean_modes import (
14+
CleanRoutes,
15+
RoborockModeEnum,
16+
VacuumModes,
17+
WaterModes,
18+
get_clean_modes,
19+
get_clean_routes,
20+
get_water_modes,
21+
)
1322
from .code_mappings import (
1423
SHORT_MODEL_TO_ENUM,
1524
RoborockCategory,
@@ -19,7 +28,6 @@
1928
RoborockDockTypeCode,
2029
RoborockDockWashTowelModeCode,
2130
RoborockErrorCode,
22-
RoborockFanPowerCode,
2331
RoborockFanSpeedP10,
2432
RoborockFanSpeedQ7Max,
2533
RoborockFanSpeedQRevoCurv,
@@ -33,7 +41,6 @@
3341
RoborockFanSpeedSaros10R,
3442
RoborockFinishReason,
3543
RoborockInCleaning,
36-
RoborockMopIntensityCode,
3744
RoborockMopIntensityP10,
3845
RoborockMopIntensityQ7Max,
3946
RoborockMopIntensityQRevoCurv,
@@ -45,7 +52,6 @@
4552
RoborockMopIntensityS8MaxVUltra,
4653
RoborockMopIntensitySaros10,
4754
RoborockMopIntensitySaros10R,
48-
RoborockMopModeCode,
4955
RoborockMopModeQRevoCurv,
5056
RoborockMopModeQRevoMaster,
5157
RoborockMopModeQRevoMaxV,
@@ -90,9 +96,9 @@
9096
ROBOROCK_G20S_Ultra,
9197
)
9298
from .device_features import DeviceFeatures
93-
from .exceptions import RoborockException
9499

95100
_LOGGER = logging.getLogger(__name__)
101+
T = TypeVar("T", bound="RoborockModeEnum")
96102

97103

98104
def _camelize(s: str):
@@ -353,12 +359,12 @@ class Status(RoborockBase):
353359
back_type: int | None = None
354360
wash_phase: int | None = None
355361
wash_ready: int | None = None
356-
fan_power: RoborockFanPowerCode | None = None
362+
fan_power: int | None = None
357363
dnd_enabled: int | None = None
358364
map_status: int | None = None
359365
is_locating: int | None = None
360366
lock_status: int | None = None
361-
water_box_mode: RoborockMopIntensityCode | None = None
367+
water_box_mode: int | None = None
362368
water_box_carriage_status: int | None = None
363369
mop_forbidden_enable: int | None = None
364370
camera_status: int | None = None
@@ -371,7 +377,7 @@ class Status(RoborockBase):
371377
dust_collection_status: int | None = None
372378
auto_dust_collection: int | None = None
373379
avoid_count: int | None = None
374-
mop_mode: RoborockMopModeCode | None = None
380+
mop_mode: int | None = None
375381
debug_mode: int | None = None
376382
collision_avoid_status: int | None = None
377383
switch_map_mode: int | None = None
@@ -392,38 +398,64 @@ class Status(RoborockBase):
392398
error_code_name: str | None = None
393399
state_name: str | None = None
394400
water_box_mode_name: str | None = None
395-
fan_power_options: list[str] = field(default_factory=list)
396401
fan_power_name: str | None = None
397402
mop_mode_name: str | None = None
403+
supported_fan_powers: list[VacuumModes] = field(default_factory=list, init=False, repr=False)
404+
supported_water_modes: list[WaterModes] = field(default_factory=list, init=False, repr=False)
405+
supported_mop_modes: list[CleanRoutes] = field(default_factory=list, init=False, repr=False)
398406

399407
def __post_init__(self) -> None:
400408
self.square_meter_clean_area = round(self.clean_area / 1000000, 1) if self.clean_area is not None else None
401409
if self.error_code is not None:
402410
self.error_code_name = self.error_code.name
403411
if self.state is not None:
404412
self.state_name = self.state.name
405-
if self.water_box_mode is not None:
406-
self.water_box_mode_name = self.water_box_mode.name
407-
if self.fan_power is not None:
408-
self.fan_power_options = self.fan_power.keys()
409-
self.fan_power_name = self.fan_power.name
410-
if self.mop_mode is not None:
411-
self.mop_mode_name = self.mop_mode.name
413+
414+
@staticmethod
415+
def _find_enum_by_code(code: int | None, enums: list[T]) -> T | None:
416+
"""Helper to find an enum member in a list by its code."""
417+
if code is None:
418+
return None
419+
for enum_member in enums:
420+
if enum_member.code == code:
421+
return enum_member
422+
return None
423+
424+
@staticmethod
425+
def _find_code_by_name(name: str, enums: list[T]) -> int | None:
426+
"""Helper to find an enum member in a list by its code."""
427+
for enum_member in enums:
428+
if enum_member.value == name:
429+
return enum_member.code
430+
return None
431+
432+
def configure(self, features: DeviceFeatures, region: str) -> None:
433+
"""
434+
Configures the status object with device-specific capabilities and processes the data.
435+
This method should be called immediately after creating the Status object.
436+
"""
437+
self.supported_fan_powers = get_clean_modes(features)
438+
self.supported_water_modes = get_water_modes(features)
439+
self.supported_mop_modes = get_clean_routes(features, region)
440+
fan_power_enum = self._find_enum_by_code(self.fan_power, self.supported_fan_powers)
441+
self.fan_power_name = fan_power_enum.value if fan_power_enum else None
442+
443+
# Resolve Water Mode
444+
water_box_mode_enum = self._find_enum_by_code(self.water_box_mode, self.supported_water_modes)
445+
self.water_box_mode_name = water_box_mode_enum.value if water_box_mode_enum else None
446+
447+
# Resolve Mop Mode (Clean Route)
448+
mop_mode_enum = self._find_enum_by_code(self.mop_mode, self.supported_mop_modes)
449+
self.mop_mode_name = mop_mode_enum.value if mop_mode_enum else None
412450

413451
def get_fan_speed_code(self, fan_speed: str) -> int:
414-
if self.fan_power is None:
415-
raise RoborockException("Attempted to get fan speed before status has been updated.")
416-
return self.fan_power.as_dict().get(fan_speed)
452+
return self._find_code_by_name(fan_speed, self.supported_fan_powers)
417453

418454
def get_mop_intensity_code(self, mop_intensity: str) -> int:
419-
if self.water_box_mode is None:
420-
raise RoborockException("Attempted to get mop_intensity before status has been updated.")
421-
return self.water_box_mode.as_dict().get(mop_intensity)
455+
return self._find_code_by_name(mop_intensity, self.supported_water_modes)
422456

423457
def get_mop_mode_code(self, mop_mode: str) -> int:
424-
if self.mop_mode is None:
425-
raise RoborockException("Attempted to get mop_mode before status has been updated.")
426-
return self.mop_mode.as_dict().get(mop_mode)
458+
return self._find_code_by_name(mop_mode, self.supported_mop_modes)
427459

428460
@property
429461
def current_map(self) -> int | None:
@@ -433,6 +465,33 @@ def current_map(self) -> int | None:
433465
return None
434466

435467

468+
class CustomStatus:
469+
"""A factory for creating fully configured Status objects for a specific device."""
470+
471+
def __init__(self, features: DeviceFeatures, region: str):
472+
"""
473+
Initializes the factory with the device-specific configuration.
474+
475+
Args:
476+
features: The DeviceFeatures object for the target device.
477+
region: The region code for the device (e.g., "US", "CN").
478+
"""
479+
self._features = features
480+
self._region = region
481+
482+
def from_dict(self, data: dict) -> Status:
483+
"""
484+
Creates a Status instance from a dictionary and immediately configures it.
485+
"""
486+
# Step 1: Create the base Status object from the raw API data.
487+
instance = Status.from_dict(data)
488+
489+
# Step 2: Configure it with the stored features and region.
490+
instance.configure(features=self._features, region=self._region)
491+
492+
return instance
493+
494+
436495
@dataclass
437496
class S4MaxStatus(Status):
438497
fan_power: RoborockFanSpeedS6Pure | None = None
@@ -730,6 +789,7 @@ class DeviceData(RoborockBase):
730789
device: HomeDataDevice
731790
model: str
732791
host: str | None = None
792+
region: str = "us"
733793
product_nickname: RoborockProductNickname | None = None
734794
device_features: DeviceFeatures | None = None
735795

0 commit comments

Comments
 (0)