diff --git a/docs/V1_API_COMMANDS.md b/docs/V1_API_COMMANDS.md index 68776ef4..2816f5eb 100644 --- a/docs/V1_API_COMMANDS.md +++ b/docs/V1_API_COMMANDS.md @@ -6,6 +6,7 @@ Commands do not immediately make it to this page. You can find more commands [he Commands can have multiple parameters that can change from one model to another. +* [app_amethyst_self_check](#app_amethyst_self_check) * [app_charge](#app_charge) * [app_get_dryer_setting](#app_get_dryer_setting) * [app_get_init_status](#app_get_init_status) @@ -26,12 +27,14 @@ Commands can have multiple parameters that can change from one model to another. * [close_dnd_timer](#close_dnd_timer) * [del_server_timer](#del_server_timer) * [dnld_install_sound](#dnld_install_sound) +* [get_auto_delivery_cleaning_fluid](#get_auto_delivery_cleaning_fluid) * [get_clean_sequence](#get_clean_sequence) * [get_consumable](#get_consumable) * [get_custom_mode](#get_custom_mode) * [get_customize_clean_mode](#get_customize_clean_mode) * [get_dnd_timer](#get_dnd_timer) * [get_dust_collection_mode](#get_dust_collection_mode) +* [get_dust_collection_switch_status](#get_dust_collection_switch_status) * [get_clean_follow_ground_material_status](#get_clean_follow_ground_material_status) * [get_identify_furniture_status](#get_identify_furniture_status) * [get_identify_ground_material_status](#get_identify_ground_material_status) @@ -51,6 +54,7 @@ Commands can have multiple parameters that can change from one model to another. * [get_turn_server](#get_turn_server) * [get_valley_electricity_timer](#get_valley_electricity_timer) * [get_wash_towel_mode](#get_wash_towel_mode) +* [get_wash_water_temperature](#get_wash_water_temperature) * [load_multi_map](#load_multi_map) * [name_segment](#name_segment) * [reset_consumable](#reset_consumable) @@ -61,11 +65,13 @@ Commands can have multiple parameters that can change from one model to another. * [save_map](#save_map) * [send_ice_to_robot](#send_ice_to_robot) * [send_sdp_to_robot](#send_sdp_to_robot) +* [set_auto_delivery_cleaning_fluid](#set_auto_delivery_cleaning_fluid) * [set_server_timer](#set_server_timer) * [set_clean_motor_mode](#set_clean_motor_mode) * [set_customize_clean_mode](#set_customize_clean_mode) * [set_dnd_timer](#set_dnd_timer) * [set_dust_collection_mode](#set_dust_collection_mode) +* [set_dust_collection_switch_status](#set_dust_collection_switch_status) * [set_fds_endpoint](#set_fds_endpoint) * [set_identify_furniture_status](#set_identify_furniture_status) * [set_identify_ground_material_status](#set_identify_ground_material_status) @@ -78,6 +84,7 @@ Commands can have multiple parameters that can change from one model to another. * [set_timezone](#set_timezone) * [set_valley_electricity_timer](#set_valley_electricity_timer) * [set_wash_towel_mode](#set_wash_towel_mode) +* [set_wash_water_temperature](#set_wash_water_temperature) * [set_water_box_custom_mode](#set_water_box_custom_mode) * [start_camera_preview](#start_camera_preview) * [start_edit_map](#start_edit_map) @@ -248,13 +255,15 @@ Parameters: None ### app_stop_wash -Description: This stops washing the mop whiloe docked +Description: This stops washing the mop whiloe docked. It also stops a running wash tray self-clean +([app_amethyst_self_check](#app_amethyst_self_check)). Parameters: None **Supported devices:** * Roborock S8 Pro Ultra: ✅ +* Roborock Saros 10R: ✅ ### app_goto_target @@ -712,48 +721,48 @@ Parameters: ### get_smart_wash_params -Description: Returns the smartwash parameters +Description: Returns when the dock washes the mop during a clean (app: mop wash frequency) Parameters: None - - Returns: - smart_wash: 0 is off, 1 is on + smart_wash: 0 by time, 1 by room, 2 new smart (only on devices with is_back_wash_new_smart_supported) - wash_interval: The interval in seconds between washes + wash_interval: The interval in seconds between washes when washing by time (app offers 600, 900, 1200, 1500) Example: ```json -{'smart_wash': 0, 'wash_interval': 1200} +{'smart_wash': 1, 'wash_interval': 1200} ``` +Source: Roborock Saros 10R **Supported devices:** * Roborock S8 Pro Ultra: ✅ +* Roborock Saros 10R: ✅ ### set_smart_wash_params -Description: Sets the smartwash parameters +Description: Sets when the dock washes the mop during a clean Parameters: - smart_wash: 0 is off, 1 is on + smart_wash: 0 by time, 1 by room, 2 new smart (only on devices with is_back_wash_new_smart_supported) - wash_interval: The interval in seconds between washes + wash_interval: The interval in seconds between washes when washing by time: 600, 900, 1200 or 1500 -`{'smart_wash': 0, 'wash_interval': 1200}` +`{'smart_wash': 1, 'wash_interval': 1200}` +Source: Roborock Saros 10R, Roborock app plugin **Supported devices:** * Roborock S8 Pro Ultra: ✅ +* Roborock Saros 10R: ✅ ## Cleaning history @@ -1202,44 +1211,98 @@ Return example: Source: Roborock S7 MaxV Ultra +Saros 10R return example: + +```json + {'mode': 4} +``` + **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ +* Roborock Saros 10R: ✅ ### set_dust_collection_mode -Description: +Description: Set the dock dust collection mode. Parameters: -### get_wash_towel_mode + mode: 0 smart, 1 light, 2 balanced, 3 strong, 4 max. The app offers 0, 1, 2 and 4. -Description: +Example: + +```json + {'mode': 4} +``` + +Source: Roborock Saros 10R + +**Supported devices:** + +* Roborock Saros 10R: ✅ + +### get_dust_collection_switch_status + +Description: Get how often the dock empties the dustbin automatically (app: auto-empty frequency). Parameters: None Returns: - wash_mode: + status: 0 off, 1 balanced, 2 high. Mirrors `auto_dust_collection` in `get_status`. Return example: ```json - {'wash_mode': 1} + {'status': 2} ``` -Source: Roborock S7 MaxV Ultra +Source: Roborock Saros 10R + +**Supported devices:** + +* Roborock Saros 10R: ✅ + +### set_dust_collection_switch_status + +Description: Set how often the dock empties the dustbin automatically. + +Parameters: + + status: 0 off, 1 balanced, 2 high. + +Example: + +```json + {'status': 2} +``` + +Source: Roborock Saros 10R + +**Supported devices:** + +* Roborock Saros 10R: ✅ +### get_wash_towel_mode - unknown = -9999 +Description: - light = 0 +Parameters: None - balanced = 1 +Returns: - deep = 2 + wash_mode: 0 light, 1 balanced, 2 deep, 8 super deep, 10 smart + +Return example: + +```json + {'wash_mode': 1} +``` + +Source: Roborock S7 MaxV Ultra; smart (10) seen on the Roborock Saros 10R **Supported devices:** @@ -1254,6 +1317,9 @@ Description: Sets the wash wash_towel_mode Parameters: {'wash_mode': 2} + wash_mode: 0 light, 1 balanced, 2 deep, 8 super deep, 10 smart. Super deep and smart are offered only on + devices that support them. + Returns: ok or error Source: S8 Pro Ultra @@ -1263,6 +1329,113 @@ Source: S8 Pro Ultra * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ +### app_amethyst_self_check + +Description: Start the self-clean of the dock's mop wash tray (app: tray self-cleaning). Offered on docks with a +self-cleaning wash carousel, such as the Saros 10R dock (dock type 16). + +Parameters: None + +Returns: ok or error + +While it runs, `get_status` reports `state` 23 (washing the mop) and `wash_phase` 11, the same as when the +self-clean is started from the app. The dock needs clean water (`dock_error_status` 38, water empty, otherwise). +It ends by itself after about a minute; `app_stop_wash` stops it early, like the stop button in the app. + +Source: Roborock Saros 10R, Roborock app plugin + +**Supported devices:** + +* Roborock Saros 10R: ✅ + +### get_wash_water_temperature + +Description: Get the water temperature the dock uses to wash the mop (app: water temperature). Available on +washing docks of devices with the hot wash towel feature (`is_hot_wash_towel_supported`). + +Parameters: None + +Returns: + + wash_water_temperature: 0 room temperature, 1 warm, 2 hot, 3 steam, 4 pet steam. + Docks offer 0 and 2; 1 on high temperature docks and docks without steam; 3 only on steam docks. + +Return example: + +```json + {'wash_water_temperature': 2} +``` + +Source: Roborock Saros 10R + +**Supported devices:** + +* Roborock Saros 10R: ✅ + +### set_wash_water_temperature + +Description: Set the water temperature the dock uses to wash the mop. + +Parameters: + + values: 0 room temperature, 1 warm, 2 hot, 3 steam, 4 pet steam. + The key differs from the one `get_wash_water_temperature` returns; `{'wash_water_temperature': 2}` is + rejected with -10007 invalid params. + +Example: + +```json + {'values': 2} +``` + +Source: Roborock Saros 10R + +**Supported devices:** + +* Roborock Saros 10R: ✅ + +### get_auto_delivery_cleaning_fluid + +Description: Get how much cleaning fluid the dock adds automatically (app: cleaning fluid auto-delivery). + +Parameters: None + +Returns: + + status: 0 off, 1 standard, 3 large. Code 2 is not used by the app. + +Return example: + +```json + {'status': 1} +``` + +Source: Roborock Saros 10R, Roborock app plugin + +**Supported devices:** + +* Roborock Saros 10R: ✅ + +### set_auto_delivery_cleaning_fluid + +Description: Set how much cleaning fluid the dock adds automatically. + +Parameters: + + status: 0 off, 1 standard, 3 large. + +Example: + +```json + {'status': 1} +``` + +Source: Roborock Saros 10R, Roborock app plugin + +**Supported devices:** + +* Roborock Saros 10R: ✅ + ### get_collision_avoid_status Description: diff --git a/roborock/data/v1/v1_code_mappings.py b/roborock/data/v1/v1_code_mappings.py index c5d5037f..0c27d81e 100644 --- a/roborock/data/v1/v1_code_mappings.py +++ b/roborock/data/v1/v1_code_mappings.py @@ -390,16 +390,59 @@ class RoborockDockTypeCode(RoborockEnum): class RoborockDockDustCollectionModeCode(RoborockEnum): - """Describes the dust collection mode of the vacuum cleaner.""" + """Describes the dust collection mode of the vacuum cleaner. + + Codes follow the Roborock app: 0 smart, 1 quick (``light``), 2 daily (``balanced``), + 3 strong, 4 max. The app offers every mode except ``strong``, which is kept so that + reading it does not fail. + """ - # TODO: Get the correct values for various different docks unknown = -9999 smart = 0 light = 1 balanced = 2 + strong = 3 max = 4 +class RoborockDockDustCollectionFrequencyCode(RoborockEnum): + """Describes how often the dock empties the dustbin automatically (app: auto-empty frequency).""" + + unknown = -1 + off = 0 + balanced = 1 + high = 2 + + +class RoborockDockWashWaterTemperatureCode(RoborockEnum): + """Describes the water temperature the dock uses to wash the mop (app: water temperature).""" + + unknown = -1 + room_temperature = 0 + warm = 1 + hot = 2 + steam = 3 + pet_steam = 4 + + +class RoborockDockCleaningFluidCode(RoborockEnum): + """Describes how much cleaning fluid the dock adds automatically (app: cleaning fluid auto-delivery).""" + + unknown = -1 + off = 0 + standard = 1 + large = 3 + + +class RoborockDockSmartWashModeCode(RoborockEnum): + """Describes when the dock washes the mop during a clean (app: mop wash frequency).""" + + unknown = -1 + by_time = 0 + by_room = 1 + new_smart = 2 + + class RoborockStateCode(RoborockEnum): unknown = 0 starting = 1 diff --git a/roborock/data/v1/v1_containers.py b/roborock/data/v1/v1_containers.py index 65bcba64..0c3d6e5e 100644 --- a/roborock/data/v1/v1_containers.py +++ b/roborock/data/v1/v1_containers.py @@ -27,10 +27,14 @@ DustBagStatus, RoborockChargeStatus, RoborockCleanType, + RoborockDockCleaningFluidCode, + RoborockDockDustCollectionFrequencyCode, RoborockDockDustCollectionModeCode, RoborockDockErrorCode, + RoborockDockSmartWashModeCode, RoborockDockState, RoborockDockTypeCode, + RoborockDockWashWaterTemperatureCode, RoborockErrorCode, RoborockFinishReason, RoborockInCleaning, @@ -479,8 +483,23 @@ class MultiMapsList(RoborockBase): @dataclass class SmartWashParams(RoborockBase): - smart_wash: int | None = None + smart_wash: RoborockDockSmartWashModeCode | None = None wash_interval: int | None = None + """Seconds between mop washes when washing by time.""" + + +@dataclass +class WashWaterTemperature(RoborockBase): + """Mop wash water temperature, as returned by GET_WASH_WATER_TEMPERATURE.""" + + wash_water_temperature: RoborockDockWashWaterTemperatureCode | None = None + + +@dataclass +class CleaningFluidDelivery(RoborockBase): + """Cleaning fluid auto-delivery amount, as returned by GET_AUTO_DELIVERY_CLEANING_FLUID.""" + + status: RoborockDockCleaningFluidCode | None = None @dataclass @@ -510,6 +529,13 @@ class DustCollectionMode(RoborockBase): mode: RoborockDockDustCollectionModeCode | None = None +@dataclass +class DustCollectionFrequency(RoborockBase): + """Automatic dust collection frequency, as returned by GET_DUST_COLLECTION_SWITCH_STATUS.""" + + status: RoborockDockDustCollectionFrequencyCode | None = None + + @dataclass class WashTowelMode(RoborockBase): wash_mode: WashTowelModes | None = None diff --git a/roborock/devices/traits/v1/__init__.py b/roborock/devices/traits/v1/__init__.py index d8af621b..c627cc91 100644 --- a/roborock/devices/traits/v1/__init__.py +++ b/roborock/devices/traits/v1/__init__.py @@ -71,12 +71,14 @@ from . import ( child_lock, clean_summary, + cleaning_fluid, command, common, consumeable, customize_clean_mode, device_features, do_not_disturb, + dust_collection_frequency, dust_collection_mode, flow_led_status, home, @@ -93,15 +95,18 @@ valley_electricity_timer, volume, wash_towel_mode, + wash_water_temperature, ) from .child_lock import ChildLockTrait from .clean_summary import CleanSummaryTrait +from .cleaning_fluid import CleaningFluidTrait from .command import CommandTrait from .common import V1TraitMixin from .consumeable import ConsumableTrait from .customize_clean_mode import CustomizeCleanModeTrait from .device_features import DeviceFeaturesTrait from .do_not_disturb import DoNotDisturbTrait +from .dust_collection_frequency import DustCollectionFrequencyTrait from .dust_collection_mode import DustCollectionModeTrait from .flow_led_status import FlowLedStatusTrait from .home import HomeTrait @@ -118,6 +123,7 @@ from .valley_electricity_timer import ValleyElectricityTimerTrait from .volume import SoundVolumeTrait from .wash_towel_mode import WashTowelModeTrait +from .wash_water_temperature import WashWaterTemperatureTrait _LOGGER = logging.getLogger(__name__) @@ -125,12 +131,14 @@ "PropertiesApi", "child_lock", "clean_summary", + "cleaning_fluid", "command", "common", "consumeable", "customize_clean_mode", "device_features", "do_not_disturb", + "dust_collection_frequency", "dust_collection_mode", "flow_led_status", "home", @@ -147,6 +155,7 @@ "valley_electricity_timer", "volume", "wash_towel_mode", + "wash_water_temperature", ] @@ -178,8 +187,11 @@ class PropertiesApi(Trait): flow_led_status: FlowLedStatusTrait | None = None valley_electricity_timer: ValleyElectricityTimerTrait | None = None dust_collection_mode: DustCollectionModeTrait | None = None + dust_collection_frequency: DustCollectionFrequencyTrait | None = None wash_towel_mode: WashTowelModeTrait | None = None smart_wash_params: SmartWashParamsTrait | None = None + wash_water_temperature: WashWaterTemperatureTrait | None = None + cleaning_fluid: CleaningFluidTrait | None = None obstacle_photos: ObstaclePhotoTrait | None = None mop_dryer: MopDryerTrait | None = None customize_clean_mode: CustomizeCleanModeTrait | None = None @@ -288,6 +300,23 @@ async def discover_features(self) -> None: wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment] self.wash_towel_mode = wash_towel_mode + if self.smart_wash_params is None and self._is_supported( + SmartWashParamsTrait, "smart_wash_params", dock_features + ): + smart_wash_params = SmartWashParamsTrait(self.device_features) + smart_wash_params._rpc_channel = self._get_rpc_channel(smart_wash_params) # type: ignore[assignment] + self.smart_wash_params = smart_wash_params + + # The wash water temperature needs a washing dock and a device that can heat wash water. + if ( + self.wash_water_temperature is None + and self.device_features.is_hot_wash_towel_supported + and self._is_supported(WashWaterTemperatureTrait, "wash_water_temperature", dock_features) + ): + wash_water_temperature = WashWaterTemperatureTrait(self.device_features) + wash_water_temperature._rpc_channel = self._get_rpc_channel(wash_water_temperature) # type: ignore[assignment] + self.wash_water_temperature = wash_water_temperature + if self.customize_clean_mode is None and self._is_supported( CustomizeCleanModeTrait, "customize_clean_mode", dock_features ): @@ -311,7 +340,8 @@ async def discover_features(self) -> None: # Union args may not be in declared order item_type = union_args[0] if union_args[1] is type(None) else union_args[1] - if item_type is ObstaclePhotoTrait: + # Created above with constructor arguments and extra gating + if item_type in (ObstaclePhotoTrait, WashWaterTemperatureTrait): continue if not self._is_supported(item_type, item.name, dock_features): _LOGGER.debug("Trait '%s' not supported, skipping", item.name) diff --git a/roborock/devices/traits/v1/cleaning_fluid.py b/roborock/devices/traits/v1/cleaning_fluid.py new file mode 100644 index 00000000..a5cab559 --- /dev/null +++ b/roborock/devices/traits/v1/cleaning_fluid.py @@ -0,0 +1,43 @@ +"""Trait for the cleaning fluid auto-delivery of the dock.""" + +from roborock.data import CleaningFluidDelivery, RoborockDockCleaningFluidCode +from roborock.device_features import RoborockDockFeatures +from roborock.devices.traits.v1 import common +from roborock.exceptions import RoborockUnsupportedFeature +from roborock.roborock_typing import RoborockCommand + +_CLEANING_FLUID_OPTIONS = [ + RoborockDockCleaningFluidCode.off, + RoborockDockCleaningFluidCode.standard, + RoborockDockCleaningFluidCode.large, +] + + +def _supports_cleaning_fluid(dock_features: RoborockDockFeatures) -> bool: + return dock_features.is_clean_fluid_auto_delivery_supported + + +class CleaningFluidTrait(CleaningFluidDelivery, common.V1TraitMixin): + """Amount of cleaning fluid the dock adds automatically (app: cleaning fluid auto-delivery). + + The large amount is wire code 3; code 2 is not used by the app. + """ + + command = RoborockCommand.GET_AUTO_DELIVERY_CLEANING_FLUID + converter = common.DefaultConverter(CleaningFluidDelivery) + requires_dock_features = _supports_cleaning_fluid + + @property + def cleaning_fluid_options(self) -> list[RoborockDockCleaningFluidCode]: + """Return the cleaning fluid amounts offered by the app.""" + return list(_CLEANING_FLUID_OPTIONS) + + async def set_cleaning_fluid(self, mode: RoborockDockCleaningFluidCode) -> None: + """Set the cleaning fluid auto-delivery amount.""" + if mode not in self.cleaning_fluid_options: + raise RoborockUnsupportedFeature(f"Cleaning fluid amount {mode.name} is not supported") + await self.rpc_channel.send_command( + RoborockCommand.SET_AUTO_DELIVERY_CLEANING_FLUID, params={"status": mode.value} + ) + # Optimistic update to avoid an extra refresh + self.status = mode diff --git a/roborock/devices/traits/v1/dust_collection_frequency.py b/roborock/devices/traits/v1/dust_collection_frequency.py new file mode 100644 index 00000000..95f5943d --- /dev/null +++ b/roborock/devices/traits/v1/dust_collection_frequency.py @@ -0,0 +1,45 @@ +"""Trait for the automatic dust collection frequency.""" + +from roborock.data import DustCollectionFrequency, RoborockDockDustCollectionFrequencyCode +from roborock.device_features import RoborockDockFeatures +from roborock.devices.traits.v1 import common +from roborock.exceptions import RoborockUnsupportedFeature +from roborock.roborock_typing import RoborockCommand + +_FREQUENCY_OPTIONS = [ + RoborockDockDustCollectionFrequencyCode.off, + RoborockDockDustCollectionFrequencyCode.balanced, + RoborockDockDustCollectionFrequencyCode.high, +] + + +def _supports_dust_collection_frequency(dock_features: RoborockDockFeatures) -> bool: + return dock_features.is_collectable + + +class DustCollectionFrequencyTrait(DustCollectionFrequency, common.V1TraitMixin): + """Automatic dust collection frequency of the dock (app: auto-empty frequency). + + The same value is reported as ``auto_dust_collection`` on the device status, + which only picks up a change made here on its next refresh. Verified on the + Saros 10R; other collecting docks are assumed to answer the same command. + """ + + command = RoborockCommand.GET_DUST_COLLECTION_SWITCH_STATUS + converter = common.DefaultConverter(DustCollectionFrequency) + requires_dock_features = _supports_dust_collection_frequency + + @property + def frequency_options(self) -> list[RoborockDockDustCollectionFrequencyCode]: + """Return the auto-empty frequencies offered by the app.""" + return list(_FREQUENCY_OPTIONS) + + async def set_frequency(self, frequency: RoborockDockDustCollectionFrequencyCode) -> None: + """Set the automatic dust collection frequency.""" + if frequency not in self.frequency_options: + raise RoborockUnsupportedFeature(f"Dust collection frequency {frequency.name} is not supported") + await self.rpc_channel.send_command( + RoborockCommand.SET_DUST_COLLECTION_SWITCH_STATUS, params={"status": frequency.value} + ) + # Optimistic update to avoid an extra refresh + self.status = frequency diff --git a/roborock/devices/traits/v1/dust_collection_mode.py b/roborock/devices/traits/v1/dust_collection_mode.py index e738e30d..72975b35 100644 --- a/roborock/devices/traits/v1/dust_collection_mode.py +++ b/roborock/devices/traits/v1/dust_collection_mode.py @@ -1,18 +1,52 @@ """Trait for dust collection mode.""" -from roborock.data import DustCollectionMode +from roborock.data import DustCollectionMode, RoborockDockDustCollectionModeCode from roborock.device_features import RoborockDockFeatures from roborock.devices.traits.v1 import common +from roborock.exceptions import RoborockUnsupportedFeature from roborock.roborock_typing import RoborockCommand +_DUST_COLLECTION_MODE_OPTIONS = [ + RoborockDockDustCollectionModeCode.smart, + RoborockDockDustCollectionModeCode.light, + RoborockDockDustCollectionModeCode.balanced, + RoborockDockDustCollectionModeCode.max, +] + def _supports_dust_collection_mode(dock_features: RoborockDockFeatures) -> bool: return dock_features.is_collectable class DustCollectionModeTrait(DustCollectionMode, common.V1TraitMixin): - """Trait for dust collection mode.""" + """Trait for the dock dust collection mode. + + ``set_dust_collection_mode`` changes how strongly the dock empties the + dustbin. ``start_dust_collection`` and ``stop_dust_collection`` control a + manual dust collection directly. + """ command = RoborockCommand.GET_DUST_COLLECTION_MODE converter = common.DefaultConverter(DustCollectionMode) requires_dock_features = _supports_dust_collection_mode + + @property + def dust_collection_mode_options(self) -> list[RoborockDockDustCollectionModeCode]: + """Return the dust collection modes offered by the app.""" + return list(_DUST_COLLECTION_MODE_OPTIONS) + + async def set_dust_collection_mode(self, mode: RoborockDockDustCollectionModeCode) -> None: + """Set the dust collection mode.""" + if mode not in self.dust_collection_mode_options: + raise RoborockUnsupportedFeature(f"Dust collection mode {mode.name} is not supported") + await self.rpc_channel.send_command(RoborockCommand.SET_DUST_COLLECTION_MODE, params={"mode": mode.value}) + # Optimistic update to avoid an extra refresh + self.mode = mode + + async def start_dust_collection(self) -> None: + """Start a manual dust collection.""" + await self.rpc_channel.send_command(RoborockCommand.APP_START_COLLECT_DUST) + + async def stop_dust_collection(self) -> None: + """Stop the running dust collection.""" + await self.rpc_channel.send_command(RoborockCommand.APP_STOP_COLLECT_DUST) diff --git a/roborock/devices/traits/v1/smart_wash_params.py b/roborock/devices/traits/v1/smart_wash_params.py index 78da74e2..d4bea8d5 100644 --- a/roborock/devices/traits/v1/smart_wash_params.py +++ b/roborock/devices/traits/v1/smart_wash_params.py @@ -1,18 +1,52 @@ """Trait for smart wash parameters.""" -from roborock.data import SmartWashParams +from roborock.data import RoborockDockSmartWashModeCode, SmartWashParams from roborock.device_features import RoborockDockFeatures from roborock.devices.traits.v1 import common +from roborock.devices.traits.v1.device_features import DeviceFeaturesTrait +from roborock.exceptions import RoborockUnsupportedFeature from roborock.roborock_typing import RoborockCommand +_WASH_INTERVAL_OPTIONS = [600, 900, 1200, 1500] + def _supports_smart_wash_params(dock_features: RoborockDockFeatures) -> bool: return dock_features.is_washable class SmartWashParamsTrait(SmartWashParams, common.V1TraitMixin): - """Trait for smart wash parameters.""" + """When the dock washes the mop during a clean (app: mop wash frequency).""" command = RoborockCommand.GET_SMART_WASH_PARAMS converter = common.DefaultConverter(SmartWashParams) requires_dock_features = _supports_smart_wash_params + + def __init__(self, device_feature_trait: DeviceFeaturesTrait) -> None: + super().__init__() + self.device_feature_trait = device_feature_trait + + @property + def smart_wash_mode_options(self) -> list[RoborockDockSmartWashModeCode]: + """Return the mop wash modes offered by the device.""" + options = [RoborockDockSmartWashModeCode.by_time, RoborockDockSmartWashModeCode.by_room] + if self.device_feature_trait.is_back_wash_new_smart_supported: + options.append(RoborockDockSmartWashModeCode.new_smart) + return options + + @property + def wash_interval_options(self) -> list[int]: + """Return the mop wash intervals in seconds offered by the app.""" + return list(_WASH_INTERVAL_OPTIONS) + + async def set_smart_wash_params(self, mode: RoborockDockSmartWashModeCode, wash_interval: int) -> None: + """Set the mop wash mode and the interval in seconds used when washing by time.""" + if mode not in self.smart_wash_mode_options: + raise RoborockUnsupportedFeature(f"Smart wash mode {mode.name} is not supported") + if wash_interval not in self.wash_interval_options: + raise RoborockUnsupportedFeature(f"Wash interval {wash_interval} is not supported") + await self.rpc_channel.send_command( + RoborockCommand.SET_SMART_WASH_PARAMS, params={"smart_wash": mode.value, "wash_interval": wash_interval} + ) + # Optimistic update to avoid an extra refresh + self.smart_wash = mode + self.wash_interval = wash_interval diff --git a/roborock/devices/traits/v1/wash_towel_mode.py b/roborock/devices/traits/v1/wash_towel_mode.py index 909e96b6..9daaa2ee 100644 --- a/roborock/devices/traits/v1/wash_towel_mode.py +++ b/roborock/devices/traits/v1/wash_towel_mode.py @@ -7,6 +7,7 @@ from roborock.device_features import RoborockDockFeatures from roborock.devices.traits.v1 import common from roborock.devices.traits.v1.device_features import DeviceFeaturesTrait +from roborock.exceptions import RoborockUnsupportedFeature from roborock.roborock_typing import RoborockCommand @@ -42,6 +43,8 @@ def wash_towel_mode_options(self) -> list[WashTowelModes]: async def set_wash_towel_mode(self, mode: WashTowelModes) -> None: """Set the wash towel mode.""" + if mode not in self.wash_towel_mode_options: + raise RoborockUnsupportedFeature(f"Wash towel mode {mode.value} is not supported") await self.rpc_channel.send_command(RoborockCommand.SET_WASH_TOWEL_MODE, params={"wash_mode": mode.code}) async def start_wash(self) -> None: @@ -49,5 +52,14 @@ async def start_wash(self) -> None: await self.rpc_channel.send_command(RoborockCommand.APP_START_WASH) async def stop_wash(self) -> None: - """Stop washing the mop.""" + """Stop washing the mop. Also stops a running wash tray self-clean.""" await self.rpc_channel.send_command(RoborockCommand.APP_STOP_WASH) + + async def self_clean_tray(self) -> None: + """Start the self-clean of the dock's mop wash tray (app: tray self-cleaning). + + It runs for about a minute; `stop_wash()` stops it early, as the app's stop button does. + """ + if not self.device_feature_trait.dock_features.is_clean_carousel_self_clean_supported: + raise RoborockUnsupportedFeature("Wash tray self-clean is not supported by this dock") + await self.rpc_channel.send_command(RoborockCommand.APP_AMETHYST_SELF_CHECK) diff --git a/roborock/devices/traits/v1/wash_water_temperature.py b/roborock/devices/traits/v1/wash_water_temperature.py new file mode 100644 index 00000000..c1d2c79a --- /dev/null +++ b/roborock/devices/traits/v1/wash_water_temperature.py @@ -0,0 +1,58 @@ +"""Trait for the mop wash water temperature.""" + +from roborock.data import RoborockDockWashWaterTemperatureCode, WashWaterTemperature +from roborock.device_features import RoborockDockFeatures +from roborock.devices.traits.v1 import common +from roborock.devices.traits.v1.device_features import DeviceFeaturesTrait +from roborock.exceptions import RoborockUnsupportedFeature +from roborock.roborock_typing import RoborockCommand + + +def get_wash_water_temperatures(dock_features: RoborockDockFeatures) -> list[RoborockDockWashWaterTemperatureCode]: + """Return the wash water temperatures the app offers for a dock, in app order. + + Warm is offered by high temperature docks and by docks without steam; steam only + by steam docks. Pet steam is never offered. + """ + options = [RoborockDockWashWaterTemperatureCode.room_temperature] + if dock_features.is_high_tmp_water_supported or not dock_features.is_steam_wash_supported: + options.append(RoborockDockWashWaterTemperatureCode.warm) + options.append(RoborockDockWashWaterTemperatureCode.hot) + if dock_features.is_steam_wash_supported: + options.append(RoborockDockWashWaterTemperatureCode.steam) + return options + + +def _supports_wash_water_temperature(dock_features: RoborockDockFeatures) -> bool: + return dock_features.is_washable + + +class WashWaterTemperatureTrait(WashWaterTemperature, common.V1TraitMixin): + """Water temperature the dock uses to wash the mop (app: water temperature). + + Created only for washing docks on devices with ``is_hot_wash_towel_supported``. + """ + + command = RoborockCommand.GET_WASH_WATER_TEMPERATURE + converter = common.DefaultConverter(WashWaterTemperature) + requires_dock_features = _supports_wash_water_temperature + + def __init__(self, device_feature_trait: DeviceFeaturesTrait) -> None: + super().__init__() + self.device_feature_trait = device_feature_trait + + @property + def wash_water_temperature_options(self) -> list[RoborockDockWashWaterTemperatureCode]: + """Return the wash water temperatures offered by the dock.""" + return get_wash_water_temperatures(self.device_feature_trait.dock_features) + + async def set_wash_water_temperature(self, temperature: RoborockDockWashWaterTemperatureCode) -> None: + """Set the mop wash water temperature.""" + if temperature not in self.wash_water_temperature_options: + raise RoborockUnsupportedFeature(f"Wash water temperature {temperature.name} is not supported") + # The device reads back "wash_water_temperature" but expects the new value under "values". + await self.rpc_channel.send_command( + RoborockCommand.SET_WASH_WATER_TEMPERATURE, params={"values": temperature.value} + ) + # Optimistic update to avoid an extra refresh + self.wash_water_temperature = temperature diff --git a/tests/devices/traits/v1/test_cleaning_fluid.py b/tests/devices/traits/v1/test_cleaning_fluid.py new file mode 100644 index 00000000..921a1251 --- /dev/null +++ b/tests/devices/traits/v1/test_cleaning_fluid.py @@ -0,0 +1,148 @@ +"""Tests for the CleaningFluidTrait class.""" + +from unittest.mock import AsyncMock + +import pytest + +from roborock.data import RoborockDockCleaningFluidCode, RoborockDockTypeCode +from roborock.devices.device import RoborockDevice +from roborock.devices.traits.v1.cleaning_fluid import CleaningFluidTrait +from roborock.exceptions import RoborockException, RoborockUnsupportedFeature +from roborock.roborock_typing import RoborockCommand +from tests.devices.traits.v1.helpers import dock_types_with_capability + +SAROS_10R_DOCK = RoborockDockTypeCode.shell_3s_dock + + +@pytest.fixture(name="cleaning_fluid") +def cleaning_fluid_trait( + device: RoborockDevice, + discover_features_fixture: None, +) -> CleaningFluidTrait | None: + """Create a CleaningFluidTrait instance with mocked dependencies.""" + assert device.v1_properties + return device.v1_properties.cleaning_fluid + + +@pytest.mark.parametrize( + ("dock_type_code"), + dock_types_with_capability("is_clean_fluid_auto_delivery_supported"), +) +async def test_cleaning_fluid_available( + cleaning_fluid: CleaningFluidTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that the trait is available for docks with cleaning fluid auto-delivery.""" + assert cleaning_fluid is not None + + +@pytest.mark.parametrize( + ("dock_type_code"), + dock_types_with_capability("is_clean_fluid_auto_delivery_supported", expected=False), +) +async def test_unsupported_cleaning_fluid( + cleaning_fluid: CleaningFluidTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that the trait is not available for docks without cleaning fluid auto-delivery.""" + assert cleaning_fluid is None + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +@pytest.mark.parametrize( + ("response", "expected"), + [ + ({"status": 1}, RoborockDockCleaningFluidCode.standard), # Saros 10R + ({"status": 3}, RoborockDockCleaningFluidCode.large), + ({"status": 0}, RoborockDockCleaningFluidCode.off), + ({"status": 2}, RoborockDockCleaningFluidCode.unknown), + ], +) +async def test_refresh_unknown_code( + cleaning_fluid: CleaningFluidTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + response: dict[str, int], + expected: RoborockDockCleaningFluidCode, +) -> None: + """Test that refresh decodes known codes and tolerates unknown ones.""" + assert cleaning_fluid is not None + mock_rpc_channel.send_command.side_effect = [response] + + await cleaning_fluid.refresh() + + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_AUTO_DELIVERY_CLEANING_FLUID) + assert cleaning_fluid.status == expected + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +async def test_options( + cleaning_fluid: CleaningFluidTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test the cleaning fluid amounts offered by the app.""" + assert cleaning_fluid is not None + assert cleaning_fluid.cleaning_fluid_options == [ + RoborockDockCleaningFluidCode.off, + RoborockDockCleaningFluidCode.standard, + RoborockDockCleaningFluidCode.large, + ] + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +@pytest.mark.parametrize( + ("mode", "expected_code"), + [ + (RoborockDockCleaningFluidCode.off, 0), + (RoborockDockCleaningFluidCode.standard, 1), + (RoborockDockCleaningFluidCode.large, 3), + ], +) +async def test_set_cleaning_fluid( + cleaning_fluid: CleaningFluidTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + mode: RoborockDockCleaningFluidCode, + expected_code: int, +) -> None: + """Test setting an offered cleaning fluid amount sends its wire code.""" + assert cleaning_fluid is not None + + await cleaning_fluid.set_cleaning_fluid(mode) + + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.SET_AUTO_DELIVERY_CLEANING_FLUID, params={"status": expected_code} + ) + assert cleaning_fluid.status == mode + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +async def test_set_rejects_unoffered( + cleaning_fluid: CleaningFluidTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that an amount the app does not offer is rejected without sending.""" + assert cleaning_fluid is not None + + with pytest.raises(RoborockUnsupportedFeature): + await cleaning_fluid.set_cleaning_fluid(RoborockDockCleaningFluidCode.unknown) + + mock_rpc_channel.send_command.assert_not_called() + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +async def test_set_failure_keeps_state( + cleaning_fluid: CleaningFluidTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that a failed command leaves the amount unchanged.""" + assert cleaning_fluid is not None + cleaning_fluid.status = RoborockDockCleaningFluidCode.standard + mock_rpc_channel.send_command.side_effect = RoborockException("boom") + + with pytest.raises(RoborockException): + await cleaning_fluid.set_cleaning_fluid(RoborockDockCleaningFluidCode.large) + + assert cleaning_fluid.status == RoborockDockCleaningFluidCode.standard diff --git a/tests/devices/traits/v1/test_dust_collection_frequency.py b/tests/devices/traits/v1/test_dust_collection_frequency.py new file mode 100644 index 00000000..bd624b28 --- /dev/null +++ b/tests/devices/traits/v1/test_dust_collection_frequency.py @@ -0,0 +1,144 @@ +"""Tests for the DustCollectionFrequencyTrait class.""" + +from unittest.mock import AsyncMock + +import pytest + +from roborock.data import RoborockDockDustCollectionFrequencyCode, RoborockDockTypeCode +from roborock.devices.device import RoborockDevice +from roborock.devices.traits.v1.dust_collection_frequency import DustCollectionFrequencyTrait +from roborock.exceptions import RoborockException, RoborockUnsupportedFeature +from roborock.roborock_typing import RoborockCommand +from tests.devices.traits.v1.helpers import dock_types_with_capability + + +@pytest.fixture(name="dust_collection_frequency") +def dust_collection_frequency_trait( + device: RoborockDevice, + discover_features_fixture: None, +) -> DustCollectionFrequencyTrait | None: + """Create a DustCollectionFrequencyTrait instance with mocked dependencies.""" + assert device.v1_properties + return device.v1_properties.dust_collection_frequency + + +@pytest.mark.parametrize( + ("dock_type_code"), + dock_types_with_capability("is_collectable"), +) +async def test_dust_collection_frequency_available( + dust_collection_frequency: DustCollectionFrequencyTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that the trait is available for collecting docks.""" + assert dust_collection_frequency is not None + + +@pytest.mark.parametrize( + ("dock_type_code"), + dock_types_with_capability("is_collectable", expected=False), +) +async def test_unsupported_dust_collection_frequency( + dust_collection_frequency: DustCollectionFrequencyTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that the trait is not available for unsupported dock types.""" + assert dust_collection_frequency is None + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +@pytest.mark.parametrize( + ("response", "expected"), + [ + ({"status": 2}, RoborockDockDustCollectionFrequencyCode.high), # Saros 10R + ({"status": 0}, RoborockDockDustCollectionFrequencyCode.off), + ({"status": 7}, RoborockDockDustCollectionFrequencyCode.unknown), + ], +) +async def test_refresh_unknown_code( + dust_collection_frequency: DustCollectionFrequencyTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + response: dict[str, int], + expected: RoborockDockDustCollectionFrequencyCode, +) -> None: + """Test that refresh decodes known codes and tolerates unknown ones.""" + assert dust_collection_frequency is not None + mock_rpc_channel.send_command.side_effect = [response] + + await dust_collection_frequency.refresh() + + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_DUST_COLLECTION_SWITCH_STATUS) + assert dust_collection_frequency.status == expected + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +async def test_options( + dust_collection_frequency: DustCollectionFrequencyTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test the auto-empty frequencies offered by the app.""" + assert dust_collection_frequency is not None + assert dust_collection_frequency.frequency_options == [ + RoborockDockDustCollectionFrequencyCode.off, + RoborockDockDustCollectionFrequencyCode.balanced, + RoborockDockDustCollectionFrequencyCode.high, + ] + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +@pytest.mark.parametrize( + ("frequency"), + [ + RoborockDockDustCollectionFrequencyCode.off, + RoborockDockDustCollectionFrequencyCode.balanced, + RoborockDockDustCollectionFrequencyCode.high, + ], +) +async def test_set_frequency( + dust_collection_frequency: DustCollectionFrequencyTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + frequency: RoborockDockDustCollectionFrequencyCode, +) -> None: + """Test setting an offered auto-empty frequency.""" + assert dust_collection_frequency is not None + + await dust_collection_frequency.set_frequency(frequency) + + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.SET_DUST_COLLECTION_SWITCH_STATUS, params={"status": frequency.value} + ) + assert dust_collection_frequency.status == frequency + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +async def test_set_rejects_unoffered( + dust_collection_frequency: DustCollectionFrequencyTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that a frequency the app does not offer is rejected without sending.""" + assert dust_collection_frequency is not None + + with pytest.raises(RoborockUnsupportedFeature): + await dust_collection_frequency.set_frequency(RoborockDockDustCollectionFrequencyCode.unknown) + + mock_rpc_channel.send_command.assert_not_called() + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +async def test_set_failure_keeps_state( + dust_collection_frequency: DustCollectionFrequencyTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that a failed command leaves the frequency unchanged.""" + assert dust_collection_frequency is not None + dust_collection_frequency.status = RoborockDockDustCollectionFrequencyCode.high + mock_rpc_channel.send_command.side_effect = RoborockException("boom") + + with pytest.raises(RoborockException): + await dust_collection_frequency.set_frequency(RoborockDockDustCollectionFrequencyCode.off) + + assert dust_collection_frequency.status == RoborockDockDustCollectionFrequencyCode.high diff --git a/tests/devices/traits/v1/test_dust_collection_mode.py b/tests/devices/traits/v1/test_dust_collection_mode.py index f8e847b4..3255179d 100644 --- a/tests/devices/traits/v1/test_dust_collection_mode.py +++ b/tests/devices/traits/v1/test_dust_collection_mode.py @@ -7,6 +7,7 @@ from roborock.data import RoborockDockDustCollectionModeCode, RoborockDockTypeCode from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.dust_collection_mode import DustCollectionModeTrait +from roborock.exceptions import RoborockException, RoborockUnsupportedFeature from roborock.roborock_typing import RoborockCommand from tests.devices.traits.v1.helpers import dock_types_with_capability @@ -60,3 +61,131 @@ async def test_unsupported_dust_collection_mode( ) -> None: """Test that the trait is not available for unsupported dock types.""" assert dust_collection_mode is None + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +async def test_options( + dust_collection_mode: DustCollectionModeTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test the dust collection modes offered by the app.""" + assert dust_collection_mode is not None + assert dust_collection_mode.dust_collection_mode_options == [ + RoborockDockDustCollectionModeCode.smart, + RoborockDockDustCollectionModeCode.light, + RoborockDockDustCollectionModeCode.balanced, + RoborockDockDustCollectionModeCode.max, + ] + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +@pytest.mark.parametrize( + ("mode"), + [ + RoborockDockDustCollectionModeCode.smart, + RoborockDockDustCollectionModeCode.light, + RoborockDockDustCollectionModeCode.balanced, + RoborockDockDustCollectionModeCode.max, + ], +) +async def test_set_dust_collection_mode( + dust_collection_mode: DustCollectionModeTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + mode: RoborockDockDustCollectionModeCode, +) -> None: + """Test setting an offered dust collection mode.""" + assert dust_collection_mode is not None + + await dust_collection_mode.set_dust_collection_mode(mode) + + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.SET_DUST_COLLECTION_MODE, params={"mode": mode.value} + ) + assert dust_collection_mode.mode == mode + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +@pytest.mark.parametrize( + ("mode"), + [RoborockDockDustCollectionModeCode.strong, RoborockDockDustCollectionModeCode.unknown], +) +async def test_set_rejects_unoffered( + dust_collection_mode: DustCollectionModeTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + mode: RoborockDockDustCollectionModeCode, +) -> None: + """Test that a mode the app does not offer is rejected without sending.""" + assert dust_collection_mode is not None + + with pytest.raises(RoborockUnsupportedFeature): + await dust_collection_mode.set_dust_collection_mode(mode) + + mock_rpc_channel.send_command.assert_not_called() + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +async def test_set_failure_keeps_state( + dust_collection_mode: DustCollectionModeTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that a failed command leaves the mode unchanged.""" + assert dust_collection_mode is not None + dust_collection_mode.mode = RoborockDockDustCollectionModeCode.max + mock_rpc_channel.send_command.side_effect = RoborockException("boom") + + with pytest.raises(RoborockException): + await dust_collection_mode.set_dust_collection_mode(RoborockDockDustCollectionModeCode.smart) + + assert dust_collection_mode.mode == RoborockDockDustCollectionModeCode.max + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +@pytest.mark.parametrize( + ("response", "expected"), + [ + ({"mode": 4}, RoborockDockDustCollectionModeCode.max), # Saros 10R + ({"mode": 3}, RoborockDockDustCollectionModeCode.strong), + ({"mode": 5}, RoborockDockDustCollectionModeCode.unknown), + ], +) +async def test_refresh_unknown_code( + dust_collection_mode: DustCollectionModeTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + response: dict[str, int], + expected: RoborockDockDustCollectionModeCode, +) -> None: + """Test that refresh decodes known codes and tolerates unknown ones.""" + assert dust_collection_mode is not None + mock_rpc_channel.send_command.side_effect = [response] + + await dust_collection_mode.refresh() + + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_DUST_COLLECTION_MODE) + assert dust_collection_mode.mode == expected + + +@pytest.mark.parametrize(("dock_type_code"), [RoborockDockTypeCode.o4_dock]) +@pytest.mark.parametrize( + ("method_name", "command"), + [ + ("start_dust_collection", RoborockCommand.APP_START_COLLECT_DUST), + ("stop_dust_collection", RoborockCommand.APP_STOP_COLLECT_DUST), + ], +) +async def test_dust_collection_actions( + dust_collection_mode: DustCollectionModeTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + method_name: str, + command: RoborockCommand, +) -> None: + """Test starting and stopping a manual dust collection.""" + assert dust_collection_mode is not None + + await getattr(dust_collection_mode, method_name)() + + mock_rpc_channel.send_command.assert_called_once_with(command) diff --git a/tests/devices/traits/v1/test_smart_wash_params.py b/tests/devices/traits/v1/test_smart_wash_params.py index 328d5c64..6192dfa9 100644 --- a/tests/devices/traits/v1/test_smart_wash_params.py +++ b/tests/devices/traits/v1/test_smart_wash_params.py @@ -1,18 +1,22 @@ -"""Tests for the DockSummaryTrait class.""" +"""Tests for the SmartWashParamsTrait class.""" from unittest.mock import AsyncMock, call import pytest +from roborock.data import RoborockDockSmartWashModeCode from roborock.data.v1.v1_code_mappings import ( RoborockDockTypeCode, ) from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.smart_wash_params import SmartWashParamsTrait +from roborock.exceptions import RoborockException, RoborockUnsupportedFeature from roborock.roborock_typing import RoborockCommand from tests.devices.traits.v1.helpers import dock_types_with_capability -SMART_WASH_DATA = [{"smart_wash": 5, "wash_interval": 6}] +# Saros 10R: wash the mop after every room, interval 1200 s kept for the time mode +SMART_WASH_DATA = [{"smart_wash": 1, "wash_interval": 1200}] +SAROS_10R_DOCK = RoborockDockTypeCode.shell_3s_dock @pytest.fixture(name="smart_wash_params") @@ -53,8 +57,8 @@ async def test_smart_wash_available( ) # Verify the summary object contains the traits - assert smart_wash_params.smart_wash == 5 - assert smart_wash_params.wash_interval == 6 + assert smart_wash_params.smart_wash == RoborockDockSmartWashModeCode.by_room + assert smart_wash_params.wash_interval == 1200 @pytest.mark.parametrize( @@ -66,3 +70,126 @@ async def test_unsupported_smart_wash_params( ) -> None: """Test successfully refreshing the dock summary.""" assert smart_wash_params is None + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +async def test_refresh_unknown_code( + smart_wash_params: SmartWashParamsTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that refresh tolerates an unknown smart wash mode code.""" + assert smart_wash_params is not None + mock_rpc_channel.send_command.side_effect = [[{"smart_wash": 7, "wash_interval": 1200}]] + + await smart_wash_params.refresh() + + assert smart_wash_params.smart_wash == RoborockDockSmartWashModeCode.unknown + assert smart_wash_params.wash_interval == 1200 + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +@pytest.mark.parametrize( + ("new_smart_supported", "expected"), + [ + (False, [RoborockDockSmartWashModeCode.by_time, RoborockDockSmartWashModeCode.by_room]), + ( + True, + [ + RoborockDockSmartWashModeCode.by_time, + RoborockDockSmartWashModeCode.by_room, + RoborockDockSmartWashModeCode.new_smart, + ], + ), + ], +) +async def test_smart_wash_mode_options( + device: RoborockDevice, + smart_wash_params: SmartWashParamsTrait | None, + dock_type_code: RoborockDockTypeCode, + new_smart_supported: bool, + expected: list[RoborockDockSmartWashModeCode], +) -> None: + """Test that the new smart mode is offered only with its device feature.""" + assert device.v1_properties + assert smart_wash_params is not None + device.v1_properties.device_features.is_back_wash_new_smart_supported = new_smart_supported + + assert smart_wash_params.smart_wash_mode_options == expected + assert smart_wash_params.wash_interval_options == [600, 900, 1200, 1500] + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +@pytest.mark.parametrize( + ("mode", "wash_interval"), + [ + (RoborockDockSmartWashModeCode.by_room, 1200), + (RoborockDockSmartWashModeCode.by_time, 900), + (RoborockDockSmartWashModeCode.by_time, 600), + (RoborockDockSmartWashModeCode.by_time, 1500), + ], +) +async def test_set_smart_wash_params( + smart_wash_params: SmartWashParamsTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + mode: RoborockDockSmartWashModeCode, + wash_interval: int, +) -> None: + """Test setting an offered smart wash mode and interval.""" + assert smart_wash_params is not None + + await smart_wash_params.set_smart_wash_params(mode, wash_interval) + + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.SET_SMART_WASH_PARAMS, params={"smart_wash": mode.value, "wash_interval": wash_interval} + ) + assert smart_wash_params.smart_wash == mode + assert smart_wash_params.wash_interval == wash_interval + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +@pytest.mark.parametrize( + ("mode", "wash_interval"), + [ + (RoborockDockSmartWashModeCode.new_smart, 1200), + (RoborockDockSmartWashModeCode.unknown, 1200), + (RoborockDockSmartWashModeCode.by_time, 1000), + ], +) +async def test_set_rejects_unoffered( + device: RoborockDevice, + smart_wash_params: SmartWashParamsTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + mode: RoborockDockSmartWashModeCode, + wash_interval: int, +) -> None: + """Test that a mode or interval the device does not offer is rejected without sending.""" + assert device.v1_properties + assert smart_wash_params is not None + device.v1_properties.device_features.is_back_wash_new_smart_supported = False + + with pytest.raises(RoborockUnsupportedFeature): + await smart_wash_params.set_smart_wash_params(mode, wash_interval) + + mock_rpc_channel.send_command.assert_not_called() + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +async def test_set_failure_keeps_state( + smart_wash_params: SmartWashParamsTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that a failed command leaves the smart wash params unchanged.""" + assert smart_wash_params is not None + smart_wash_params.smart_wash = RoborockDockSmartWashModeCode.by_room + smart_wash_params.wash_interval = 1200 + mock_rpc_channel.send_command.side_effect = RoborockException("boom") + + with pytest.raises(RoborockException): + await smart_wash_params.set_smart_wash_params(RoborockDockSmartWashModeCode.by_time, 900) + + assert smart_wash_params.smart_wash == RoborockDockSmartWashModeCode.by_room + assert smart_wash_params.wash_interval == 1200 diff --git a/tests/devices/traits/v1/test_wash_towel_mode.py b/tests/devices/traits/v1/test_wash_towel_mode.py index 8395c70c..98bdc4b2 100644 --- a/tests/devices/traits/v1/test_wash_towel_mode.py +++ b/tests/devices/traits/v1/test_wash_towel_mode.py @@ -10,6 +10,7 @@ ) from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.wash_towel_mode import WashTowelModeTrait +from roborock.exceptions import RoborockUnsupportedFeature from roborock.roborock_typing import RoborockCommand from tests.devices.traits.v1.helpers import dock_types_with_capability @@ -83,6 +84,8 @@ async def test_set_wash_towel_mode( ) -> None: """Test setting the wash towel mode.""" assert wash_towel_mode is not None + # Offer the smart mode, as on the Saros 10R + wash_towel_mode.device_feature_trait.is_dirty_replenish_clean_supported = True await wash_towel_mode.set_wash_towel_mode(wash_mode) @@ -189,3 +192,59 @@ async def test_wash_towel_mode_options( wash_towel_mode.device_feature_trait.is_dirty_replenish_clean_supported = is_dirty_replenish_clean_supported assert wash_towel_mode.wash_towel_mode_options == expected_modes + + +@pytest.mark.parametrize( + ("dock_type_code"), + [(RoborockDockTypeCode.o4_dock)], +) +async def test_set_wash_towel_mode_rejects_unoffered( + wash_towel_mode: WashTowelModeTrait, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that a wash mode the device does not offer is rejected without sending.""" + assert wash_towel_mode is not None + wash_towel_mode.device_feature_trait.is_super_deep_wash_supported = True + wash_towel_mode.device_feature_trait.is_dirty_replenish_clean_supported = True + assert WashTowelModes.SUPER_DEEP not in wash_towel_mode.wash_towel_mode_options + + with pytest.raises(RoborockUnsupportedFeature): + await wash_towel_mode.set_wash_towel_mode(WashTowelModes.SUPER_DEEP) + + mock_rpc_channel.send_command.assert_not_called() + + +@pytest.mark.parametrize( + ("dock_type_code"), + [(RoborockDockTypeCode.shell_3s_dock)], +) +async def test_self_clean_tray( + wash_towel_mode: WashTowelModeTrait, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test starting the wash tray self-clean on a dock with the carousel.""" + assert wash_towel_mode is not None + + await wash_towel_mode.self_clean_tray() + + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.APP_AMETHYST_SELF_CHECK) + + +@pytest.mark.parametrize( + ("dock_type_code"), + [(RoborockDockTypeCode.o4_dock)], +) +async def test_self_clean_tray_unsupported( + wash_towel_mode: WashTowelModeTrait, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that the tray self-clean is rejected without sending on a dock without the carousel.""" + assert wash_towel_mode is not None + + with pytest.raises(RoborockUnsupportedFeature): + await wash_towel_mode.self_clean_tray() + + mock_rpc_channel.send_command.assert_not_called() diff --git a/tests/devices/traits/v1/test_wash_water_temperature.py b/tests/devices/traits/v1/test_wash_water_temperature.py new file mode 100644 index 00000000..a2383f58 --- /dev/null +++ b/tests/devices/traits/v1/test_wash_water_temperature.py @@ -0,0 +1,233 @@ +"""Tests for the WashWaterTemperatureTrait class.""" + +from unittest.mock import AsyncMock + +import pytest + +from roborock.data import HomeData, RoborockDockTypeCode, RoborockDockWashWaterTemperatureCode +from roborock.device_features import NewFeatureStrBit, RoborockDockFeatures +from roborock.devices.device import RoborockDevice +from roborock.devices.traits.v1.wash_water_temperature import ( + WashWaterTemperatureTrait, + get_wash_water_temperatures, +) +from roborock.exceptions import RoborockException, RoborockUnsupportedFeature +from roborock.roborock_typing import RoborockCommand +from tests import mock_data +from tests.devices.traits.v1.helpers import dock_types_with_capability + +SAROS_10R_DOCK = RoborockDockTypeCode.shell_3s_dock + + +@pytest.fixture(name="wash_water_temperature") +def wash_water_temperature_trait( + device: RoborockDevice, + discover_features_fixture: None, +) -> WashWaterTemperatureTrait | None: + """Create a WashWaterTemperatureTrait instance with mocked dependencies.""" + assert device.v1_properties + return device.v1_properties.wash_water_temperature + + +@pytest.mark.parametrize( + ("dock_type_code", "expected"), + [ + ( + # Saros 10R: neither high temperature nor steam + RoborockDockTypeCode.shell_3s_dock, + [ + RoborockDockWashWaterTemperatureCode.room_temperature, + RoborockDockWashWaterTemperatureCode.warm, + RoborockDockWashWaterTemperatureCode.hot, + ], + ), + ( + # Steam only + RoborockDockTypeCode.o7h_dock, + [ + RoborockDockWashWaterTemperatureCode.room_temperature, + RoborockDockWashWaterTemperatureCode.hot, + RoborockDockWashWaterTemperatureCode.steam, + ], + ), + ( + # High temperature only + RoborockDockTypeCode.k1r_dock, + [ + RoborockDockWashWaterTemperatureCode.room_temperature, + RoborockDockWashWaterTemperatureCode.warm, + RoborockDockWashWaterTemperatureCode.hot, + ], + ), + ], +) +def test_get_wash_water_temperatures( + dock_type_code: RoborockDockTypeCode, + expected: list[RoborockDockWashWaterTemperatureCode], +) -> None: + """Test the temperatures offered for each dock family.""" + assert get_wash_water_temperatures(RoborockDockFeatures.from_dock_type(dock_type_code)) == expected + + +@pytest.mark.parametrize( + ("dock_type_code"), + dock_types_with_capability("is_washable"), +) +async def test_wash_water_temperature_available( + wash_water_temperature: WashWaterTemperatureTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that the trait is available for washing docks.""" + assert wash_water_temperature is not None + + +@pytest.mark.parametrize( + ("dock_type_code"), + dock_types_with_capability("is_washable", expected=False), +) +async def test_unsupported_wash_water_temperature( + wash_water_temperature: WashWaterTemperatureTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that the trait is not available for docks without mop washing.""" + assert wash_water_temperature is None + + +def _home_data_with_hot_wash(supported: bool) -> HomeData: + """Return home data whose device has the hot wash towel feature bit set or cleared.""" + home_data = HomeData.from_dict(mock_data.HOME_DATA_RAW) + device = home_data.get_all_devices()[0] + assert device.new_feature_set is not None + bits = int(device.new_feature_set, 16) + mask = 1 << NewFeatureStrBit.HOT_WASH_TOWEL + bits = bits | mask if supported else bits & ~mask + device.new_feature_set = format(bits, "x").zfill(len(device.new_feature_set)) + return home_data + + +@pytest.fixture(name="trait_home_data") +def trait_home_data_fixture(request: pytest.FixtureRequest) -> HomeData: + """Default to a device that can heat wash water, as the Saros 10R.""" + return getattr(request, "param", _home_data_with_hot_wash(True)) + + +@pytest.mark.parametrize("trait_home_data", [_home_data_with_hot_wash(False)], indirect=True) +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +async def test_unsupported_without_hot_wash( + trait_home_data: HomeData, + device: RoborockDevice, + wash_water_temperature: WashWaterTemperatureTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that a washing dock on a device that cannot heat wash water has no temperature trait.""" + assert device.v1_properties + assert device.is_connected + assert not device.v1_properties.device_features.is_hot_wash_towel_supported + assert wash_water_temperature is None + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +@pytest.mark.parametrize( + ("response", "expected"), + [ + ({"wash_water_temperature": 2}, RoborockDockWashWaterTemperatureCode.hot), # Saros 10R + ({"wash_water_temperature": 0}, RoborockDockWashWaterTemperatureCode.room_temperature), + ({"wash_water_temperature": 9}, RoborockDockWashWaterTemperatureCode.unknown), + ], +) +async def test_refresh_unknown_code( + wash_water_temperature: WashWaterTemperatureTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + response: dict[str, int], + expected: RoborockDockWashWaterTemperatureCode, +) -> None: + """Test that refresh decodes known codes and tolerates unknown ones.""" + assert wash_water_temperature is not None + mock_rpc_channel.send_command.side_effect = [response] + + await wash_water_temperature.refresh() + + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_WASH_WATER_TEMPERATURE) + assert wash_water_temperature.wash_water_temperature == expected + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +async def test_options( + wash_water_temperature: WashWaterTemperatureTrait | None, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that the trait offers the temperatures of its dock.""" + assert wash_water_temperature is not None + assert wash_water_temperature.wash_water_temperature_options == [ + RoborockDockWashWaterTemperatureCode.room_temperature, + RoborockDockWashWaterTemperatureCode.warm, + RoborockDockWashWaterTemperatureCode.hot, + ] + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +@pytest.mark.parametrize( + ("temperature"), + [ + RoborockDockWashWaterTemperatureCode.room_temperature, + RoborockDockWashWaterTemperatureCode.warm, + RoborockDockWashWaterTemperatureCode.hot, + ], +) +async def test_set_wash_water_temperature( + wash_water_temperature: WashWaterTemperatureTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + temperature: RoborockDockWashWaterTemperatureCode, +) -> None: + """Test setting an offered wash water temperature.""" + assert wash_water_temperature is not None + + await wash_water_temperature.set_wash_water_temperature(temperature) + + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.SET_WASH_WATER_TEMPERATURE, params={"values": temperature.value} + ) + assert wash_water_temperature.wash_water_temperature == temperature + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +@pytest.mark.parametrize( + ("temperature"), + [ + RoborockDockWashWaterTemperatureCode.steam, + RoborockDockWashWaterTemperatureCode.pet_steam, + RoborockDockWashWaterTemperatureCode.unknown, + ], +) +async def test_set_rejects_unoffered( + wash_water_temperature: WashWaterTemperatureTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, + temperature: RoborockDockWashWaterTemperatureCode, +) -> None: + """Test that a temperature the dock does not offer is rejected without sending.""" + assert wash_water_temperature is not None + + with pytest.raises(RoborockUnsupportedFeature): + await wash_water_temperature.set_wash_water_temperature(temperature) + + mock_rpc_channel.send_command.assert_not_called() + + +@pytest.mark.parametrize(("dock_type_code"), [SAROS_10R_DOCK]) +async def test_set_failure_keeps_state( + wash_water_temperature: WashWaterTemperatureTrait | None, + mock_rpc_channel: AsyncMock, + dock_type_code: RoborockDockTypeCode, +) -> None: + """Test that a failed command leaves the temperature unchanged.""" + assert wash_water_temperature is not None + wash_water_temperature.wash_water_temperature = RoborockDockWashWaterTemperatureCode.hot + mock_rpc_channel.send_command.side_effect = RoborockException("boom") + + with pytest.raises(RoborockException): + await wash_water_temperature.set_wash_water_temperature(RoborockDockWashWaterTemperatureCode.warm) + + assert wash_water_temperature.wash_water_temperature == RoborockDockWashWaterTemperatureCode.hot