Skip to content

Commit 3e4a0be

Browse files
Lash-LCopilot
andauthored
feat: make status dynamic (#611)
* chore: do dynamic status * feat: add more data and region * chore: change str * chore: add testing * chore: add hash * chore: add dss to status * chore: update e2e * chore: docstring Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: address comments * chore: add some docs and a basic test * chore: add warning about hash for RoborockModeEnum * chore: add more info about the dynamic attributes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent cefa806 commit 3e4a0be

File tree

14 files changed

+436
-32
lines changed

14 files changed

+436
-32
lines changed

roborock/data/code_mappings.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from collections import namedtuple
55
from enum import Enum, IntEnum, StrEnum
6-
from typing import Self
6+
from typing import Any, Self
77

88
_LOGGER = logging.getLogger(__name__)
99
completed_warnings = set()
@@ -105,6 +105,21 @@ def keys(cls) -> list[str]:
105105
"""Returns a list of all member values."""
106106
return [member.value for member in cls]
107107

108+
def __eq__(self, other: Any) -> bool:
109+
if isinstance(other, str):
110+
return self.value == other or self.name == other
111+
if isinstance(other, int):
112+
return self.code == other
113+
return super().__eq__(other)
114+
115+
def __hash__(self) -> int:
116+
"""Hash a RoborockModeEnum.
117+
118+
It is critical that you do not mix RoborockModeEnums with raw strings or ints in hashed situations
119+
(i.e. sets or keys in dictionaries)
120+
"""
121+
return hash((self.code, self._value_))
122+
108123

109124
ProductInfo = namedtuple("ProductInfo", ["nickname", "short_models"])
110125

roborock/data/v1/v1_clean_modes.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ class VacuumModes(RoborockModeEnum):
1616
TURBO = ("turbo", 103)
1717
MAX = ("max", 104)
1818
MAX_PLUS = ("max_plus", 108)
19+
CARPET = ("carpet", 107)
20+
OFF_RAISE_MAIN_BRUSH = ("off_raise_main_brush", 109)
1921
CUSTOMIZED = ("custom", 106)
2022
SMART_MODE = ("smart_mode", 110)
2123

@@ -45,6 +47,8 @@ class WaterModes(RoborockModeEnum):
4547
STANDARD = ("standard", 202)
4648
HIGH = ("high", 203)
4749
INTENSE = ("intense", 203)
50+
MIN = ("min", 205)
51+
MAX = ("max", 206)
4852
CUSTOMIZED = ("custom", 204)
4953
CUSTOM = ("custom_water_flow", 207)
5054
EXTREME = ("extreme", 208)
@@ -81,9 +85,14 @@ def get_clean_modes(features: DeviceFeatures) -> list[VacuumModes]:
8185
if features.is_max_plus_mode_supported or features.is_none_pure_clean_mop_with_max_plus:
8286
# If the vacuum has max plus mode supported
8387
modes.append(VacuumModes.MAX_PLUS)
88+
if features.is_carpet_deep_clean_supported:
89+
modes.append(VacuumModes.CARPET)
8490
if features.is_pure_clean_mop_supported:
8591
# If the vacuum is capable of 'pure mop clean' aka no vacuum
86-
modes.append(VacuumModes.OFF)
92+
if features.is_support_main_brush_up_down_supported:
93+
modes.append(VacuumModes.OFF_RAISE_MAIN_BRUSH)
94+
else:
95+
modes.append(VacuumModes.OFF)
8796
else:
8897
# If not, we can add gentle
8998
modes.append(VacuumModes.GENTLE)

roborock/data/v1/v1_containers.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ def _requires_schema_code(requires_schema_code: str, default=None) -> Any:
121121

122122
@dataclass
123123
class Status(RoborockBase):
124+
"""This status will be deprecated in favor of StatusV2."""
125+
124126
msg_ver: int | None = None
125127
msg_seq: int | None = None
126128
state: RoborockStateCode | None = _requires_schema_code("state", default=None)
@@ -282,6 +284,140 @@ def __repr__(self) -> str:
282284
return _attr_repr(self)
283285

284286

287+
@dataclass
288+
class StatusV2(RoborockBase):
289+
"""
290+
This is a new version of the Status object.
291+
This is the result of GET_STATUS from the api.
292+
"""
293+
294+
msg_ver: int | None = None
295+
msg_seq: int | None = None
296+
state: RoborockStateCode | None = None
297+
battery: int | None = None
298+
clean_time: int | None = None
299+
clean_area: int | None = None
300+
error_code: RoborockErrorCode | None = None
301+
map_present: int | None = None
302+
in_cleaning: RoborockInCleaning | None = None
303+
in_returning: int | None = None
304+
in_fresh_state: int | None = None
305+
lab_status: int | None = None
306+
water_box_status: int | None = None
307+
back_type: int | None = None
308+
wash_phase: int | None = None
309+
wash_ready: int | None = None
310+
fan_power: int | None = None
311+
dnd_enabled: int | None = None
312+
map_status: int | None = None
313+
is_locating: int | None = None
314+
lock_status: int | None = None
315+
water_box_mode: int | None = None
316+
water_box_carriage_status: int | None = None
317+
mop_forbidden_enable: int | None = None
318+
camera_status: int | None = None
319+
is_exploring: int | None = None
320+
home_sec_status: int | None = None
321+
home_sec_enable_password: int | None = None
322+
adbumper_status: list[int] | None = None
323+
water_shortage_status: int | None = None
324+
dock_type: RoborockDockTypeCode | None = None
325+
dust_collection_status: int | None = None
326+
auto_dust_collection: int | None = None
327+
avoid_count: int | None = None
328+
mop_mode: int | None = None
329+
debug_mode: int | None = None
330+
collision_avoid_status: int | None = None
331+
switch_map_mode: int | None = None
332+
dock_error_status: RoborockDockErrorCode | None = None
333+
charge_status: int | None = None
334+
unsave_map_reason: int | None = None
335+
unsave_map_flag: int | None = None
336+
wash_status: int | None = None
337+
distance_off: int | None = None
338+
in_warmup: int | None = None
339+
dry_status: int | None = None
340+
rdt: int | None = None
341+
clean_percent: int | None = None
342+
rss: int | None = None
343+
dss: int | None = None
344+
common_status: int | None = None
345+
corner_clean_mode: int | None = None
346+
last_clean_t: int | None = None
347+
replenish_mode: int | None = None
348+
repeat: int | None = None
349+
kct: int | None = None
350+
subdivision_sets: int | None = None
351+
352+
@property
353+
def square_meter_clean_area(self) -> float | None:
354+
return round(self.clean_area / 1000000, 1) if self.clean_area is not None else None
355+
356+
@property
357+
def error_code_name(self) -> str | None:
358+
return self.error_code.name if self.error_code is not None else None
359+
360+
@property
361+
def state_name(self) -> str | None:
362+
return self.state.name if self.state is not None else None
363+
364+
@property
365+
def current_map(self) -> int | None:
366+
"""Returns the current map ID if the map is present."""
367+
if self.map_status is not None:
368+
map_flag = self.map_status >> 2
369+
if map_flag != NO_MAP:
370+
return map_flag
371+
return None
372+
373+
@property
374+
def clear_water_box_status(self) -> ClearWaterBoxStatus | None:
375+
if self.dss:
376+
return ClearWaterBoxStatus((self.dss >> 2) & 3)
377+
return None
378+
379+
@property
380+
def dirty_water_box_status(self) -> DirtyWaterBoxStatus | None:
381+
if self.dss:
382+
return DirtyWaterBoxStatus((self.dss >> 4) & 3)
383+
return None
384+
385+
@property
386+
def dust_bag_status(self) -> DustBagStatus | None:
387+
if self.dss:
388+
return DustBagStatus((self.dss >> 6) & 3)
389+
return None
390+
391+
@property
392+
def water_box_filter_status(self) -> int | None:
393+
if self.dss:
394+
return (self.dss >> 8) & 3
395+
return None
396+
397+
@property
398+
def clean_fluid_status(self) -> CleanFluidStatus | None:
399+
if self.dss:
400+
value = (self.dss >> 10) & 3
401+
if value == 0:
402+
return None # Feature not supported by this device
403+
return None
404+
405+
@property
406+
def hatch_door_status(self) -> int | None:
407+
if self.dss:
408+
return (self.dss >> 12) & 7
409+
return None
410+
411+
@property
412+
def dock_cool_fan_status(self) -> int | None:
413+
if self.dss:
414+
return (self.dss >> 15) & 3
415+
return None
416+
417+
def __repr__(self) -> str:
418+
return _attr_repr(self)
419+
420+
285421
@dataclass
286422
class S4MaxStatus(Status):
287423
fan_power: RoborockFanSpeedS6Pure | None = None

roborock/devices/device_manager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
239239
web_api,
240240
device_cache=device_cache,
241241
map_parser_config=map_parser_config,
242+
region=user_data.region,
242243
)
243244
case DeviceVersion.A01:
244245
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)

roborock/devices/traits/v1/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@
5454

5555
import logging
5656
from dataclasses import dataclass, field, fields
57-
from functools import cache
5857
from typing import Any, get_args
5958

6059
from roborock.data.containers import HomeData, HomeDataProduct, RoborockBase
@@ -180,6 +179,7 @@ def __init__(
180179
web_api: UserWebApiClient,
181180
device_cache: DeviceCache,
182181
map_parser_config: MapParserConfig | None = None,
182+
region: str | None = None,
183183
) -> None:
184184
"""Initialize the V1TraitProps."""
185185
self._device_uid = device_uid
@@ -188,14 +188,15 @@ def __init__(
188188
self._map_rpc_channel = map_rpc_channel
189189
self._web_api = web_api
190190
self._device_cache = device_cache
191+
self._region = region
191192

192-
self.status = StatusTrait(product)
193+
self.device_features = DeviceFeaturesTrait(product, self._device_cache)
194+
self.status = StatusTrait(self.device_features, region=self._region)
193195
self.consumables = ConsumableTrait()
194196
self.rooms = RoomsTrait(home_data)
195197
self.maps = MapsTrait(self.status)
196198
self.map_content = MapContentTrait(map_parser_config)
197199
self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache)
198-
self.device_features = DeviceFeaturesTrait(product, self._device_cache)
199200
self.network_info = NetworkInfoTrait(device_uid, self._device_cache)
200201
self.routines = RoutinesTrait(device_uid, web_api)
201202

@@ -206,6 +207,8 @@ def __init__(
206207
if (union_args := get_args(item.type)) is None or len(union_args) > 0:
207208
continue
208209
_LOGGER.debug("Trait '%s' is supported, initializing", item.name)
210+
if not callable(item.type):
211+
continue
209212
trait = item.type()
210213
setattr(self, item.name, trait)
211214
# This is a hack to allow setting the rpc_channel on all traits. This is
@@ -324,6 +327,7 @@ def create(
324327
web_api: UserWebApiClient,
325328
device_cache: DeviceCache,
326329
map_parser_config: MapParserConfig | None = None,
330+
region: str | None = None,
327331
) -> PropertiesApi:
328332
"""Create traits for V1 devices."""
329333
return PropertiesApi(
@@ -336,4 +340,5 @@ def create(
336340
web_api,
337341
device_cache,
338342
map_parser_config,
343+
region=region,
339344
)
Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,92 @@
1+
from functools import cached_property
12
from typing import Self
23

3-
from roborock.data import HomeDataProduct, ModelStatus, S7MaxVStatus, Status
4-
from roborock.devices.traits.v1 import common
4+
from roborock import CleanRoutes, StatusV2, VacuumModes, WaterModes, get_clean_modes, get_clean_routes, get_water_modes
55
from roborock.roborock_typing import RoborockCommand
66

7+
from . import common
8+
from .device_features import DeviceFeaturesTrait
79

8-
class StatusTrait(Status, common.V1TraitMixin):
9-
"""Trait for managing the status of Roborock devices."""
10+
11+
class StatusTrait(StatusV2, common.V1TraitMixin):
12+
"""Trait for managing the status of Roborock devices.
13+
14+
The StatusTrait gives you the access to the state of a Roborock vacuum.
15+
The various attribute options on state change per each device.
16+
Values like fan speed, mop mode, etc. have different options for every device
17+
and are dynamically determined.
18+
19+
Usage:
20+
Before accessing status properties, you should call `refresh()` to fetch
21+
the latest data from the device. You must pass in the device feature trait
22+
to this trait so that the dynamic attributes can be pre-determined.
23+
24+
The current dynamic attributes are:
25+
- Fan Speed
26+
- Water Mode
27+
- Mop Route
28+
29+
You should call the _options() version of the attribute to know which are supported for your device
30+
(i.e. fan_speed_options())
31+
Then you can call the _mapping to convert an int value to the actual Enum. (i.e. fan_speed_mapping())
32+
You can call the _name property to get the str value of the enum. (i.e. fan_speed_name)
33+
34+
"""
1035

1136
command = RoborockCommand.GET_STATUS
1237

13-
def __init__(self, product_info: HomeDataProduct) -> None:
38+
def __init__(self, device_feature_trait: DeviceFeaturesTrait, region: str | None = None) -> None:
1439
"""Initialize the StatusTrait."""
15-
self._product_info = product_info
40+
super().__init__()
41+
self._device_features_trait = device_feature_trait
42+
self._region = region
43+
44+
@cached_property
45+
def fan_speed_options(self) -> list[VacuumModes]:
46+
return get_clean_modes(self._device_features_trait)
47+
48+
@cached_property
49+
def fan_speed_mapping(self) -> dict[int, str]:
50+
return {fan.code: fan.value for fan in self.fan_speed_options}
51+
52+
@cached_property
53+
def water_mode_options(self) -> list[WaterModes]:
54+
return get_water_modes(self._device_features_trait)
55+
56+
@cached_property
57+
def water_mode_mapping(self) -> dict[int, str]:
58+
return {mop.code: mop.value for mop in self.water_mode_options}
59+
60+
@cached_property
61+
def mop_route_options(self) -> list[CleanRoutes]:
62+
return get_clean_routes(self._device_features_trait, self._region or "us")
63+
64+
@cached_property
65+
def mop_route_mapping(self) -> dict[int, str]:
66+
return {route.code: route.value for route in self.mop_route_options}
67+
68+
@property
69+
def fan_speed_name(self) -> str | None:
70+
if self.fan_power is None:
71+
return None
72+
return self.fan_speed_mapping.get(self.fan_power)
73+
74+
@property
75+
def water_mode_name(self) -> str | None:
76+
if self.water_box_mode is None:
77+
return None
78+
return self.water_mode_mapping.get(self.water_box_mode)
79+
80+
@property
81+
def mop_route_name(self) -> str | None:
82+
if self.mop_mode is None:
83+
return None
84+
return self.mop_route_mapping.get(self.mop_mode)
1685

1786
def _parse_response(self, response: common.V1ResponseData) -> Self:
18-
"""Parse the response from the device into a CleanSummary."""
19-
status_type: type[Status] = ModelStatus.get(self._product_info.model, S7MaxVStatus)
87+
"""Parse the response from the device into a StatusV2-based status object."""
2088
if isinstance(response, list):
2189
response = response[0]
2290
if isinstance(response, dict):
23-
return status_type.from_dict(response)
91+
return StatusV2.from_dict(response)
2492
raise ValueError(f"Unexpected status format: {response!r}")

0 commit comments

Comments
 (0)