From a40c4040989fa05489e8ac41d9d7c58b4d0101cc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Apr 2026 14:57:30 -0700 Subject: [PATCH 1/2] feat: implement RoborockDockState synthesis and RoborockChargeStatus enum for improved device status reporting --- roborock/data/v1/v1_code_mappings.py | 55 +++++++++++++++++++ roborock/data/v1/v1_containers.py | 45 ++++++++++++++- tests/data/v1/test_v1_containers.py | 36 +++++++++++- .../devices/__snapshots__/test_v1_device.ambr | 2 +- 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/roborock/data/v1/v1_code_mappings.py b/roborock/data/v1/v1_code_mappings.py index f68ead91..a5c830f2 100644 --- a/roborock/data/v1/v1_code_mappings.py +++ b/roborock/data/v1/v1_code_mappings.py @@ -1,3 +1,4 @@ +from enum import StrEnum from typing import Self from ..code_mappings import RoborockEnum @@ -63,6 +64,60 @@ class RoborockCleanType(RoborockEnum): pet_patrol = 6 +class RoborockChargeStatus(RoborockEnum): + """Describes the charging status of the device.""" + + unknown = -1 + charge_waiting = 0 + charging = 1 + + +class RoborockDockState(StrEnum): + """Synthesized high-level dock and power state of the device. + + This enum represents a unified "UI-level" state that combines multiple raw + device data points (`state`, `charge_status`, `battery`) into a single, + human-readable status that accurately reflects what the vacuum is doing + relative to the dock. + + It is highly recommended for consumers of this API + to use this synthesized state to determine if the vacuum is charging or + docked, rather than attempting to parse the raw integer data points, as + this safely handles backward compatibility for older models that lack + explicit off-peak schedule reporting. + """ + + unknown = "unknown" + """The dock state could not be determined or is unmapped.""" + + idle = "idle" + """The vacuum is away from the dock (e.g., cleaning, paused, or errored). + In the official app, this state presents the 'Return to Dock' or 'Recharge' action.""" + + returning = "returning" + """The vacuum is actively navigating its way back to the dock. + In the official app, this state presents the 'Stop' or 'Pause' action.""" + + charging = "charging" + """The vacuum is on the dock and actively receiving electricity. + In the official app, this state is displayed as 'Charging'.""" + + off_peak_waiting = "off_peak_waiting" + """The vacuum is on the dock but charging is paused. It is waiting for the + user's scheduled 'Valley Electricity' off-peak hours to begin before + drawing power. + In the official app, this state is displayed as 'Charging paused during peak hours'.""" + + full = "full" + """The vacuum is on the dock and the battery is at 100% capacity. + In the official app, this state is displayed as 'Fully charged'.""" + + dusting = "dusting" + """The vacuum is on the dock and is currently being evacuated by the + auto-empty base. + In the official app, this state is displayed as 'Emptying dustbin'.""" + + class RoborockStartType(RoborockEnum): button = 1 app = 2 diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 29fb9c3a..7d5a223d 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -46,9 +46,11 @@ ClearWaterBoxStatus, DirtyWaterBoxStatus, DustBagStatus, + RoborockChargeStatus, RoborockCleanType, RoborockDockDustCollectionModeCode, RoborockDockErrorCode, + RoborockDockState, RoborockDockTypeCode, RoborockErrorCode, RoborockFanPowerCode, @@ -161,7 +163,9 @@ class Status(RoborockBase): collision_avoid_status: int | None = None switch_map_mode: int | None = None dock_error_status: RoborockDockErrorCode | None = None - charge_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}) + charge_status: RoborockChargeStatus | None = field( + default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS} + ) unsave_map_reason: int | None = None unsave_map_flag: int | None = None wash_status: int | None = None @@ -329,7 +333,9 @@ class StatusV2(RoborockBase): collision_avoid_status: int | None = None switch_map_mode: int | None = None dock_error_status: RoborockDockErrorCode | None = None - charge_status: int | None = field(default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS}) + charge_status: RoborockChargeStatus | None = field( + default=None, metadata={"dps": RoborockDataProtocol.CHARGE_STATUS} + ) unsave_map_reason: int | None = None unsave_map_flag: int | None = None wash_status: int | None = None @@ -414,6 +420,41 @@ def dock_cool_fan_status(self) -> int | None: return (self.dss >> 15) & 3 return None + @property + def dock_state(self) -> RoborockDockState: + """A synthesized, high-level dock state reflecting the UI's display. + + This property simplifies integration by handling the complex logic + of checking state, charge_status, and battery level simultaneously. It handles + newer off-peak charging logic seamlessly while maintaining backwards compatibility + with older devices. + """ + if self.state is None: + return RoborockDockState.unknown + + # 6. DUSTING + if self.state == RoborockStateCode.emptying_the_bin: + return RoborockDockState.dusting + + # 5. FULL + if self.state == RoborockStateCode.charging_complete or ( + self.state == RoborockStateCode.charging and self.battery == 100 + ): + return RoborockDockState.full + + # 3 & 4. CHARGING and CHARGE_WAITING + if self.state == RoborockStateCode.charging: + if self.charge_status == RoborockChargeStatus.charge_waiting: + return RoborockDockState.off_peak_waiting + return RoborockDockState.charging + + # 2. RECHARGING + if self.state in (RoborockStateCode.returning_home, RoborockStateCode.docking): + return RoborockDockState.returning + + # 1. IDLE (Not on dock, or doing something else) + return RoborockDockState.idle + def __repr__(self) -> str: return _attr_repr(self) diff --git a/tests/data/v1/test_v1_containers.py b/tests/data/v1/test_v1_containers.py index 604860c2..bf8ce3c9 100644 --- a/tests/data/v1/test_v1_containers.py +++ b/tests/data/v1/test_v1_containers.py @@ -7,7 +7,9 @@ from roborock.data.v1 import ( MultiMapsList, + RoborockChargeStatus, RoborockDockErrorCode, + RoborockDockState, RoborockDockTypeCode, RoborockErrorCode, RoborockFanSpeedS7MaxV, @@ -89,7 +91,7 @@ def test_status(): assert s.collision_avoid_status == 1 assert s.switch_map_mode == 0 assert s.dock_error_status == RoborockDockErrorCode.ok - assert s.charge_status == 1 + assert s.charge_status == RoborockChargeStatus.charging assert s.unsave_map_reason == 0 assert s.unsave_map_flag == 0 assert s.fan_power == RoborockFanSpeedS7MaxV.balanced @@ -141,6 +143,38 @@ def test_current_map() -> None: assert not s.current_map +@pytest.mark.parametrize( + "state, charge_status, battery, expected_dock_state", + [ + (RoborockStateCode.emptying_the_bin, None, 50, RoborockDockState.dusting), + (RoborockStateCode.charging_complete, None, 100, RoborockDockState.full), + (RoborockStateCode.charging, None, 100, RoborockDockState.full), + (RoborockStateCode.charging, RoborockChargeStatus.charging, 90, RoborockDockState.charging), + (RoborockStateCode.charging, RoborockChargeStatus.charge_waiting, 50, RoborockDockState.off_peak_waiting), + (RoborockStateCode.charging, None, 50, RoborockDockState.charging), + (RoborockStateCode.returning_home, None, 20, RoborockDockState.returning), + (RoborockStateCode.docking, None, 15, RoborockDockState.returning), + (RoborockStateCode.cleaning, None, 80, RoborockDockState.idle), + (RoborockStateCode.paused, None, 80, RoborockDockState.idle), + (None, None, 100, RoborockDockState.unknown), + ], +) +def test_dock_state( + state: RoborockStateCode | None, + charge_status: int | None, + battery: int, + expected_dock_state: RoborockDockState, +) -> None: + """Test that dock_state correctly synthesizes UI state.""" + status = copy.deepcopy(STATUS) + status["state"] = state + status["charge_status"] = charge_status + status["battery"] = battery + + s = StatusV2.from_dict(status) + assert s.dock_state == expected_dock_state + + def test_status_v2() -> None: """Test that StatusV2 can be created from a dictionary.""" s = StatusV2.from_dict(STATUS) diff --git a/tests/devices/__snapshots__/test_v1_device.ambr b/tests/devices/__snapshots__/test_v1_device.ambr index 8da5d44c..2f109c5b 100644 --- a/tests/devices/__snapshots__/test_v1_device.ambr +++ b/tests/devices/__snapshots__/test_v1_device.ambr @@ -870,7 +870,7 @@ }) # --- # name: test_device_trait_command_parsing[status] - 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=, 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=, 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=[, , , , , , ], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, 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=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, 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=[, , , , , ], water_shortage_status=None) + 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=, 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=, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, 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=[, , , , , , ], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, 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=[, , ], msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, 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=[, , , , , ], water_shortage_status=None) # --- # name: test_device_trait_command_parsing[status].1 dict({ From 2b0e9a06d4f217ca7ad867f44c5124a58f719aed Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Apr 2026 15:29:36 -0700 Subject: [PATCH 2/2] chore: address review feedback for dock_state --- roborock/data/v1/v1_containers.py | 2 +- tests/data/v1/test_v1_containers.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 7d5a223d..57e61709 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -429,7 +429,7 @@ def dock_state(self) -> RoborockDockState: newer off-peak charging logic seamlessly while maintaining backwards compatibility with older devices. """ - if self.state is None: + if self.state is None or self.state == RoborockStateCode.unknown: return RoborockDockState.unknown # 6. DUSTING diff --git a/tests/data/v1/test_v1_containers.py b/tests/data/v1/test_v1_containers.py index bf8ce3c9..0a03671c 100644 --- a/tests/data/v1/test_v1_containers.py +++ b/tests/data/v1/test_v1_containers.py @@ -149,13 +149,14 @@ def test_current_map() -> None: (RoborockStateCode.emptying_the_bin, None, 50, RoborockDockState.dusting), (RoborockStateCode.charging_complete, None, 100, RoborockDockState.full), (RoborockStateCode.charging, None, 100, RoborockDockState.full), - (RoborockStateCode.charging, RoborockChargeStatus.charging, 90, RoborockDockState.charging), - (RoborockStateCode.charging, RoborockChargeStatus.charge_waiting, 50, RoborockDockState.off_peak_waiting), + (RoborockStateCode.charging, RoborockChargeStatus.charging.value, 90, RoborockDockState.charging), + (RoborockStateCode.charging, RoborockChargeStatus.charge_waiting.value, 50, RoborockDockState.off_peak_waiting), (RoborockStateCode.charging, None, 50, RoborockDockState.charging), (RoborockStateCode.returning_home, None, 20, RoborockDockState.returning), (RoborockStateCode.docking, None, 15, RoborockDockState.returning), (RoborockStateCode.cleaning, None, 80, RoborockDockState.idle), (RoborockStateCode.paused, None, 80, RoborockDockState.idle), + (RoborockStateCode.unknown, None, 100, RoborockDockState.unknown), (None, None, 100, RoborockDockState.unknown), ], )