Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 53 additions & 29 deletions roborock/data/v1/v1_containers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import logging
import warnings
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions roborock/devices/traits/v1/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from roborock import (
CleaningMode,
CleanRoutes,
RoborockDockState,
StatusV2,
VacuumModes,
WaterModes,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
59 changes: 39 additions & 20 deletions tests/data/v1/test_v1_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Consumable,
DnDTimer,
StatusV2,
get_dock_state,
)
from tests.mock_data import (
CLEAN_RECORD,
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/devices/__snapshots__/test_v1_device.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -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=[<CleaningMode.VACUUM: 'vacuum'>, <CleaningMode.VAC_AND_MOP: 'vac_and_mop'>, <CleaningMode.MOP: 'mop'>, <CleaningMode.CUSTOM: 'custom'>], 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_cleaning_mode=<CleaningMode.CUSTOM: 'custom'>, 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=<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'>], has_am=None, 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)
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=[<CleaningMode.VACUUM: 'vacuum'>, <CleaningMode.VAC_AND_MOP: 'vac_and_mop'>, <CleaningMode.MOP: 'mop'>, <CleaningMode.CUSTOM: 'custom'>], 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_cleaning_mode=<CleaningMode.CUSTOM: 'custom'>, 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=<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'>], has_am=None, 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_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=[<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)
# ---
# name: test_device_trait_command_parsing[status].1
dict({
Expand Down
38 changes: 38 additions & 0 deletions tests/devices/traits/v1/test_status.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the StatusTrait class."""

import asyncio
import warnings
from typing import cast
from unittest.mock import AsyncMock, call

Expand All @@ -17,7 +18,9 @@
)
from roborock.data import SHORT_MODEL_TO_ENUM, RoborockProductNickname
from roborock.data.v1 import (
RoborockChargeStatus,
RoborockDockErrorCode,
RoborockDockState,
RoborockErrorCode,
RoborockStateCode,
)
Expand Down Expand Up @@ -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
Expand Down
Loading