Skip to content

Commit 3fdb963

Browse files
authored
feat: implement RoborockDockState synthesis and RoborockChargeStatus enum (#821)
* feat: implement RoborockDockState synthesis and RoborockChargeStatus enum for improved device status reporting * chore: address review feedback for dock_state
1 parent d125afb commit 3fdb963

4 files changed

Lines changed: 135 additions & 4 deletions

File tree

roborock/data/v1/v1_code_mappings.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from enum import StrEnum
12
from typing import Self
23

34
from ..code_mappings import RoborockEnum
@@ -63,6 +64,60 @@ class RoborockCleanType(RoborockEnum):
6364
pet_patrol = 6
6465

6566

67+
class RoborockChargeStatus(RoborockEnum):
68+
"""Describes the charging status of the device."""
69+
70+
unknown = -1
71+
charge_waiting = 0
72+
charging = 1
73+
74+
75+
class RoborockDockState(StrEnum):
76+
"""Synthesized high-level dock and power state of the device.
77+
78+
This enum represents a unified "UI-level" state that combines multiple raw
79+
device data points (`state`, `charge_status`, `battery`) into a single,
80+
human-readable status that accurately reflects what the vacuum is doing
81+
relative to the dock.
82+
83+
It is highly recommended for consumers of this API
84+
to use this synthesized state to determine if the vacuum is charging or
85+
docked, rather than attempting to parse the raw integer data points, as
86+
this safely handles backward compatibility for older models that lack
87+
explicit off-peak schedule reporting.
88+
"""
89+
90+
unknown = "unknown"
91+
"""The dock state could not be determined or is unmapped."""
92+
93+
idle = "idle"
94+
"""The vacuum is away from the dock (e.g., cleaning, paused, or errored).
95+
In the official app, this state presents the 'Return to Dock' or 'Recharge' action."""
96+
97+
returning = "returning"
98+
"""The vacuum is actively navigating its way back to the dock.
99+
In the official app, this state presents the 'Stop' or 'Pause' action."""
100+
101+
charging = "charging"
102+
"""The vacuum is on the dock and actively receiving electricity.
103+
In the official app, this state is displayed as 'Charging'."""
104+
105+
off_peak_waiting = "off_peak_waiting"
106+
"""The vacuum is on the dock but charging is paused. It is waiting for the
107+
user's scheduled 'Valley Electricity' off-peak hours to begin before
108+
drawing power.
109+
In the official app, this state is displayed as 'Charging paused during peak hours'."""
110+
111+
full = "full"
112+
"""The vacuum is on the dock and the battery is at 100% capacity.
113+
In the official app, this state is displayed as 'Fully charged'."""
114+
115+
dusting = "dusting"
116+
"""The vacuum is on the dock and is currently being evacuated by the
117+
auto-empty base.
118+
In the official app, this state is displayed as 'Emptying dustbin'."""
119+
120+
66121
class RoborockStartType(RoborockEnum):
67122
button = 1
68123
app = 2

roborock/data/v1/v1_containers.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@
4646
ClearWaterBoxStatus,
4747
DirtyWaterBoxStatus,
4848
DustBagStatus,
49+
RoborockChargeStatus,
4950
RoborockCleanType,
5051
RoborockDockDustCollectionModeCode,
5152
RoborockDockErrorCode,
53+
RoborockDockState,
5254
RoborockDockTypeCode,
5355
RoborockErrorCode,
5456
RoborockFanPowerCode,
@@ -161,7 +163,9 @@ class Status(RoborockBase):
161163
collision_avoid_status: int | None = None
162164
switch_map_mode: int | None = None
163165
dock_error_status: RoborockDockErrorCode | None = None
164-
charge_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS})
166+
charge_status: RoborockChargeStatus | None = field(
167+
default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}
168+
)
165169
unsave_map_reason: int | None = None
166170
unsave_map_flag: int | None = None
167171
wash_status: int | None = None
@@ -329,7 +333,9 @@ class StatusV2(RoborockBase):
329333
collision_avoid_status: int | None = None
330334
switch_map_mode: int | None = None
331335
dock_error_status: RoborockDockErrorCode | None = None
332-
charge_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS})
336+
charge_status: RoborockChargeStatus | None = field(
337+
default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}
338+
)
333339
unsave_map_reason: int | None = None
334340
unsave_map_flag: int | None = None
335341
wash_status: int | None = None
@@ -414,6 +420,41 @@ def dock_cool_fan_status(self) -> int | None:
414420
return (self.dss >> 15) & 3
415421
return None
416422

423+
@property
424+
def dock_state(self) -> RoborockDockState:
425+
"""A synthesized, high-level dock state reflecting the UI's display.
426+
427+
This property simplifies integration by handling the complex logic
428+
of checking state, charge_status, and battery level simultaneously. It handles
429+
newer off-peak charging logic seamlessly while maintaining backwards compatibility
430+
with older devices.
431+
"""
432+
if self.state is None or self.state == RoborockStateCode.unknown:
433+
return RoborockDockState.unknown
434+
435+
# 6. DUSTING
436+
if self.state == RoborockStateCode.emptying_the_bin:
437+
return RoborockDockState.dusting
438+
439+
# 5. FULL
440+
if self.state == RoborockStateCode.charging_complete or (
441+
self.state == RoborockStateCode.charging and self.battery == 100
442+
):
443+
return RoborockDockState.full
444+
445+
# 3 & 4. CHARGING and CHARGE_WAITING
446+
if self.state == RoborockStateCode.charging:
447+
if self.charge_status == RoborockChargeStatus.charge_waiting:
448+
return RoborockDockState.off_peak_waiting
449+
return RoborockDockState.charging
450+
451+
# 2. RECHARGING
452+
if self.state in (RoborockStateCode.returning_home, RoborockStateCode.docking):
453+
return RoborockDockState.returning
454+
455+
# 1. IDLE (Not on dock, or doing something else)
456+
return RoborockDockState.idle
457+
417458
def __repr__(self) -> str:
418459
return _attr_repr(self)
419460

tests/data/v1/test_v1_containers.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
from roborock.data.v1 import (
99
MultiMapsList,
10+
RoborockChargeStatus,
1011
RoborockDockErrorCode,
12+
RoborockDockState,
1113
RoborockDockTypeCode,
1214
RoborockErrorCode,
1315
RoborockFanSpeedS7MaxV,
@@ -89,7 +91,7 @@ def test_status():
8991
assert s.collision_avoid_status == 1
9092
assert s.switch_map_mode == 0
9193
assert s.dock_error_status == RoborockDockErrorCode.ok
92-
assert s.charge_status == 1
94+
assert s.charge_status == RoborockChargeStatus.charging
9395
assert s.unsave_map_reason == 0
9496
assert s.unsave_map_flag == 0
9597
assert s.fan_power == RoborockFanSpeedS7MaxV.balanced
@@ -141,6 +143,39 @@ def test_current_map() -> None:
141143
assert not s.current_map
142144

143145

146+
@pytest.mark.parametrize(
147+
"state, charge_status, battery, expected_dock_state",
148+
[
149+
(RoborockStateCode.emptying_the_bin, None, 50, RoborockDockState.dusting),
150+
(RoborockStateCode.charging_complete, None, 100, RoborockDockState.full),
151+
(RoborockStateCode.charging, None, 100, RoborockDockState.full),
152+
(RoborockStateCode.charging, RoborockChargeStatus.charging.value, 90, RoborockDockState.charging),
153+
(RoborockStateCode.charging, RoborockChargeStatus.charge_waiting.value, 50, RoborockDockState.off_peak_waiting),
154+
(RoborockStateCode.charging, None, 50, RoborockDockState.charging),
155+
(RoborockStateCode.returning_home, None, 20, RoborockDockState.returning),
156+
(RoborockStateCode.docking, None, 15, RoborockDockState.returning),
157+
(RoborockStateCode.cleaning, None, 80, RoborockDockState.idle),
158+
(RoborockStateCode.paused, None, 80, RoborockDockState.idle),
159+
(RoborockStateCode.unknown, None, 100, RoborockDockState.unknown),
160+
(None, None, 100, RoborockDockState.unknown),
161+
],
162+
)
163+
def test_dock_state(
164+
state: RoborockStateCode | None,
165+
charge_status: int | None,
166+
battery: int,
167+
expected_dock_state: RoborockDockState,
168+
) -> None:
169+
"""Test that dock_state correctly synthesizes UI state."""
170+
status = copy.deepcopy(STATUS)
171+
status["state"] = state
172+
status["charge_status"] = charge_status
173+
status["battery"] = battery
174+
175+
s = StatusV2.from_dict(status)
176+
assert s.dock_state == expected_dock_state
177+
178+
144179
def test_status_v2() -> None:
145180
"""Test that StatusV2 can be created from a dictionary."""
146181
s = StatusV2.from_dict(STATUS)

tests/devices/__snapshots__/test_v1_device.ambr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,7 @@
870870
})
871871
# ---
872872
# name: test_device_trait_command_parsing[status]
873-
StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, clear_water_box_status=None, collision_avoid_status=None, command=<RoborockCommand.GET_STATUS: 'get_status'>, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=<RoborockErrorCode.none: 0>, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[<VacuumModes.QUIET: 'quiet'>, <VacuumModes.BALANCED: 'balanced'>, <VacuumModes.TURBO: 'turbo'>, <VacuumModes.MAX: 'max'>, <VacuumModes.MAX_PLUS: 'max_plus'>, <VacuumModes.OFF: 'off'>, <VacuumModes.CUSTOMIZED: 'custom'>], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=<RoborockInCleaning.complete: 0>, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[<CleanRoutes.STANDARD: 'standard'>, <CleanRoutes.DEEP: 'deep'>, <CleanRoutes.CUSTOMIZED: 'custom'>], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=<RoborockStateCode.charging: 8>, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[<WaterModes.OFF: 'off'>, <WaterModes.MILD: 'mild'>, <WaterModes.STANDARD: 'standard'>, <WaterModes.INTENSE: 'intense'>, <WaterModes.CUSTOM: 'custom_water_flow'>, <WaterModes.CUSTOMIZED: 'custom'>], water_shortage_status=None)
873+
StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, clear_water_box_status=None, collision_avoid_status=None, command=<RoborockCommand.GET_STATUS: 'get_status'>, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_state=<RoborockDockState.full: 'full'>, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=<RoborockErrorCode.none: 0>, error_code_name='none', fan_power=106, fan_speed_mapping={101: 'quiet', 102: 'balanced', 103: 'turbo', 104: 'max', 108: 'max_plus', 105: 'off', 106: 'custom'}, fan_speed_name='custom', fan_speed_options=[<VacuumModes.QUIET: 'quiet'>, <VacuumModes.BALANCED: 'balanced'>, <VacuumModes.TURBO: 'turbo'>, <VacuumModes.MAX: 'max'>, <VacuumModes.MAX_PLUS: 'max_plus'>, <VacuumModes.OFF: 'off'>, <VacuumModes.CUSTOMIZED: 'custom'>], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=<RoborockInCleaning.complete: 0>, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_route_mapping={300: 'standard', 301: 'deep', 302: 'custom'}, mop_route_name=None, mop_route_options=[<CleanRoutes.STANDARD: 'standard'>, <CleanRoutes.DEEP: 'deep'>, <CleanRoutes.CUSTOMIZED: 'custom'>], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=<RoborockStateCode.charging: 8>, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=204, water_box_status=0, water_mode_mapping={200: 'off', 201: 'mild', 202: 'standard', 203: 'intense', 207: 'custom_water_flow', 204: 'custom'}, water_mode_name='custom', water_mode_options=[<WaterModes.OFF: 'off'>, <WaterModes.MILD: 'mild'>, <WaterModes.STANDARD: 'standard'>, <WaterModes.INTENSE: 'intense'>, <WaterModes.CUSTOM: 'custom_water_flow'>, <WaterModes.CUSTOMIZED: 'custom'>], water_shortage_status=None)
874874
# ---
875875
# name: test_device_trait_command_parsing[status].1
876876
dict({

0 commit comments

Comments
 (0)