diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index bf1be98ba..46ff7d49f 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -1,5 +1,6 @@ import datetime import logging +import warnings from dataclasses import dataclass, field from enum import StrEnum from typing import Any @@ -73,6 +74,39 @@ class StatusField(FieldNameBase): RDT = "rdt" +def get_dock_state( + state: RoborockStateCode | int | None, + battery: int | None, + charge_status: RoborockChargeStatus | int | None = None, + *, + is_supported_valley_electricity: bool = False, +) -> RoborockDockState: + """Return the dock state, accounting for off-peak charging support.""" + if state is None or state == RoborockStateCode.unknown: + return RoborockDockState.unknown + + # 6. DUSTING + if state == RoborockStateCode.emptying_the_bin: + return RoborockDockState.dusting + + # 5. FULL + if state == RoborockStateCode.charging_complete or (state == RoborockStateCode.charging and battery == 100): + return RoborockDockState.full + + # 3 & 4. CHARGING and CHARGE_WAITING + if state == RoborockStateCode.charging: + if is_supported_valley_electricity and charge_status == RoborockChargeStatus.charge_waiting: + return RoborockDockState.off_peak_waiting + return RoborockDockState.charging + + # 2. RECHARGING + if state in (RoborockStateCode.returning_home, RoborockStateCode.docking): + return RoborockDockState.returning + + # 1. IDLE (Not on dock, or doing something else) + return RoborockDockState.idle + + @dataclass class StatusV2(RoborockBase): """The result of a GET_STATUS API request.""" @@ -219,36 +253,26 @@ def dock_cool_fan_status(self) -> int | None: 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. + .. deprecated:: + Use StatusTrait.dock_state or get_dock_state instead. """ - if self.state is None or self.state == RoborockStateCode.unknown: - 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 + warnings.warn( + "StatusV2.dock_state is deprecated and will be removed in a future release. " + "Use StatusTrait.dock_state or get_dock_state instead.", + DeprecationWarning, + stacklevel=2, + ) + return get_dock_state( + state=self.state, + battery=self.battery, + charge_status=self.charge_status, + is_supported_valley_electricity=True, + ) + + @property + def is_battery_charging(self) -> bool: + """Return whether the battery is actively charging.""" + return self.dock_state == RoborockDockState.charging def __repr__(self) -> str: return _attr_repr(self) diff --git a/roborock/devices/traits/v1/status.py b/roborock/devices/traits/v1/status.py index 0ba1fb22a..01eed9a55 100644 --- a/roborock/devices/traits/v1/status.py +++ b/roborock/devices/traits/v1/status.py @@ -5,6 +5,7 @@ from roborock import ( CleaningMode, CleanRoutes, + RoborockDockState, StatusV2, VacuumModes, WaterModes, @@ -13,6 +14,7 @@ get_cleaning_mode_options, get_cleaning_mode_parameters, get_current_cleaning_mode, + get_dock_state, get_water_mode_mapping, get_water_modes, get_water_slide_mode_name, @@ -66,6 +68,21 @@ def __init__(self, device_feature_trait: DeviceFeaturesTrait, region: str | None self._device_features_trait = device_feature_trait self._region = region + @property + def dock_state(self) -> RoborockDockState: + """Return the feature-aware dock state used by the Roborock app.""" + return get_dock_state( + state=self.state, + battery=self.battery, + charge_status=self.charge_status, + is_supported_valley_electricity=self._device_features_trait.is_supported_valley_electricity, + ) + + @property + def is_battery_charging(self) -> bool: + """Return whether the battery is actively charging.""" + return self.dock_state == RoborockDockState.charging + @cached_property def fan_speed_options(self) -> list[VacuumModes]: return get_clean_modes(self._device_features_trait) diff --git a/tests/data/v1/test_v1_containers.py b/tests/data/v1/test_v1_containers.py index 8c5847afa..6e0a95cd3 100644 --- a/tests/data/v1/test_v1_containers.py +++ b/tests/data/v1/test_v1_containers.py @@ -23,6 +23,7 @@ Consumable, DnDTimer, StatusV2, + get_dock_state, ) from tests.mock_data import ( CLEAN_RECORD, @@ -141,36 +142,54 @@ def test_current_map() -> None: @pytest.mark.parametrize( - "state, charge_status, battery, expected_dock_state", + "state, charge_status, battery, is_supported_valley_electricity, 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.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), + (RoborockStateCode.emptying_the_bin, None, 50, False, RoborockDockState.dusting), + (RoborockStateCode.charging_complete, None, 100, False, RoborockDockState.full), + (RoborockStateCode.charging, None, 100, False, RoborockDockState.full), + (RoborockStateCode.charging, RoborockChargeStatus.charging.value, 90, False, RoborockDockState.charging), + ( + RoborockStateCode.charging, + RoborockChargeStatus.charge_waiting.value, + 50, + True, + RoborockDockState.off_peak_waiting, + ), + (RoborockStateCode.charging, RoborockChargeStatus.charge_waiting.value, 50, False, RoborockDockState.charging), + (RoborockStateCode.charging, None, 50, False, RoborockDockState.charging), + (RoborockStateCode.returning_home, None, 20, False, RoborockDockState.returning), + (RoborockStateCode.docking, None, 15, False, RoborockDockState.returning), + (RoborockStateCode.cleaning, None, 80, False, RoborockDockState.idle), + (RoborockStateCode.paused, None, 80, False, RoborockDockState.idle), + (RoborockStateCode.unknown, None, 100, False, RoborockDockState.unknown), + (None, None, 100, False, RoborockDockState.unknown), ], ) -def test_dock_state( +def test_get_dock_state( state: RoborockStateCode | None, charge_status: int | None, battery: int, + is_supported_valley_electricity: bool, 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 + """Test that get_dock_state correctly synthesizes UI state.""" + assert ( + get_dock_state( + state=state, + battery=battery, + charge_status=charge_status, + is_supported_valley_electricity=is_supported_valley_electricity, + ) + == expected_dock_state + ) + +def test_status_v2_dock_state_deprecated() -> None: + """Test that accessing StatusV2.dock_state issues a DeprecationWarning.""" + status = copy.deepcopy(STATUS) s = StatusV2.from_dict(status) - assert s.dock_state == expected_dock_state + with pytest.deprecated_call(): + assert s.dock_state == RoborockDockState.full def test_status_v2() -> None: diff --git a/tests/devices/__snapshots__/test_v1_device.ambr b/tests/devices/__snapshots__/test_v1_device.ambr index 0e5c3f5c5..bc0d8fdb3 100644 --- a/tests/devices/__snapshots__/test_v1_device.ambr +++ b/tests/devices/__snapshots__/test_v1_device.ambr @@ -876,7 +876,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, cleaning_mode_options=[, , , ], clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=, current_cleaning_mode_name='custom', 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=[, , , , , , ], has_am=None, 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, cleaning_mode_options=[, , , ], clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, converter=DefaultConverter, corner_clean_mode=None, current_cleaning_mode=, current_cleaning_mode_name='custom', 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=[, , , , , , ], has_am=None, 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_battery_charging=False, 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({ diff --git a/tests/devices/traits/v1/test_status.py b/tests/devices/traits/v1/test_status.py index 30d1fb722..5749ae313 100644 --- a/tests/devices/traits/v1/test_status.py +++ b/tests/devices/traits/v1/test_status.py @@ -1,6 +1,7 @@ """Tests for the StatusTrait class.""" import asyncio +import warnings from typing import cast from unittest.mock import AsyncMock, call @@ -17,7 +18,9 @@ ) from roborock.data import SHORT_MODEL_TO_ENUM, RoborockProductNickname from roborock.data.v1 import ( + RoborockChargeStatus, RoborockDockErrorCode, + RoborockDockState, RoborockErrorCode, RoborockStateCode, ) @@ -177,6 +180,41 @@ def test_none_values(status_trait: StatusTrait) -> None: assert status_trait.mop_route_name is None +@pytest.mark.parametrize( + ( + "is_supported_valley_electricity", + "charge_status", + "battery", + "expected_dock_state", + "expected_is_battery_charging", + ), + [ + (False, RoborockChargeStatus.charge_waiting, 50, RoborockDockState.charging, True), + (False, None, 50, RoborockDockState.charging, True), + (True, RoborockChargeStatus.charge_waiting, 50, RoborockDockState.off_peak_waiting, False), + (True, RoborockChargeStatus.charging, 50, RoborockDockState.charging, True), + (True, RoborockChargeStatus.charging, 100, RoborockDockState.full, False), + ], +) +def test_feature_aware_dock_and_charging_state( + is_supported_valley_electricity: bool, + charge_status: RoborockChargeStatus | None, + battery: int, + expected_dock_state: RoborockDockState, + expected_is_battery_charging: bool, +) -> None: + """Test dock and charging state against the V1 app's feature-gated logic.""" + status_trait = _create_cleaning_mode_status_trait(is_supported_valley_electricity=is_supported_valley_electricity) + status_trait.state = RoborockStateCode.charging + status_trait.charge_status = charge_status + status_trait.battery = battery + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + assert status_trait.dock_state == expected_dock_state + assert status_trait.is_battery_charging is expected_is_battery_charging + + def test_options(status_trait: StatusTrait) -> None: """Test that fan_speed_options returns a list of options.""" status_trait._device_features_trait.is_clean_route_setting_supported = True