Skip to content
Open
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
55 changes: 55 additions & 0 deletions roborock/data/v1/v1_code_mappings.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from enum import StrEnum
from typing import Self

from ..code_mappings import RoborockEnum
Expand Down Expand Up @@ -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
Expand Down
45 changes: 43 additions & 2 deletions roborock/data/v1/v1_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@
ClearWaterBoxStatus,
DirtyWaterBoxStatus,
DustBagStatus,
RoborockChargeStatus,
RoborockCleanType,
RoborockDockDustCollectionModeCode,
RoborockDockErrorCode,
RoborockDockState,
RoborockDockTypeCode,
RoborockErrorCode,
RoborockFanPowerCode,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 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

def __repr__(self) -> str:
return _attr_repr(self)

Expand Down
37 changes: 36 additions & 1 deletion tests/data/v1/test_v1_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

from roborock.data.v1 import (
MultiMapsList,
RoborockChargeStatus,
RoborockDockErrorCode,
RoborockDockState,
RoborockDockTypeCode,
RoborockErrorCode,
RoborockFanSpeedS7MaxV,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -141,6 +143,39 @@ 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.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),
],
)
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
Comment thread
allenporter marked this conversation as resolved.

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)
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 @@ -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=<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)
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)
# ---
# name: test_device_trait_command_parsing[status].1
dict({
Expand Down
Loading