From 3691ee9671053ccfa2a90d6dfb439ee2d3af922c Mon Sep 17 00:00:00 2001 From: Yangqian Date: Mon, 22 Dec 2025 11:37:31 +0800 Subject: [PATCH 01/33] initial support for roborock washing machine --- homeassistant/components/roborock/button.py | 88 ++++++++++- .../components/roborock/coordinator.py | 10 ++ homeassistant/components/roborock/select.py | 146 ++++++++++++++++- homeassistant/components/roborock/sensor.py | 120 ++++++++++++-- .../components/roborock/strings.json | 147 +++++++++++++++++- homeassistant/components/roborock/switch.py | 87 ++++++++++- 6 files changed, 578 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 2365a86c703a9..34ff4b7854866 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -10,6 +10,8 @@ from roborock.devices.traits.v1.consumeable import ConsumableAttribute from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockZeoProtocol +from roborock.roborock_typing import RoborockCommand from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory @@ -18,8 +20,16 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator -from .entity import RoborockEntity, RoborockEntityV1 +from .coordinator import ( + RoborockConfigEntry, + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, +) +from .entity import ( + RoborockCoordinatedEntityA01, + RoborockEntity, + RoborockEntityV1, +) _LOGGER = logging.getLogger(__name__) @@ -65,6 +75,36 @@ class RoborockButtonDescription(ButtonEntityDescription): ] +@dataclass(frozen=True, kw_only=True) +class RoborockButtonDescriptionA01(ButtonEntityDescription): + """Describes a Roborock A01 button entity.""" + + data_protocol: RoborockZeoProtocol + param: Any = None + + +A01_BUTTON_DESCRIPTIONS = [ + RoborockButtonDescriptionA01( + key="start", + data_protocol=RoborockZeoProtocol.START, + translation_key="start", + #entity_category=EntityCategory.CONFIG, + ), + RoborockButtonDescriptionA01( + key="pause", + data_protocol=RoborockZeoProtocol.PAUSE, + translation_key="pause", + #entity_category=EntityCategory.CONFIG, + ), + RoborockButtonDescriptionA01( + key="shutdown", + data_protocol=RoborockZeoProtocol.SHUTDOWN, + translation_key="shutdown", + #entity_category=EntityCategory.CONFIG, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, @@ -98,6 +138,14 @@ async def async_setup_entry( ) for routine in routines ), + ( + RoborockButtonEntityA01( + coordinator, + description, + ) + for coordinator in config_entry.runtime_data.a01 + for description in A01_BUTTON_DESCRIPTIONS + ), ) ) @@ -160,3 +208,39 @@ def __init__( async def async_press(self, **kwargs: Any) -> None: """Press the button.""" await self._coordinator.execute_routines(self._routine_id) + + +class RoborockButtonEntityA01(RoborockCoordinatedEntityA01, ButtonEntity): + """A class to define Roborock A01 button entities.""" + + entity_description: RoborockButtonDescriptionA01 + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinatorA01, + entity_description: RoborockButtonDescriptionA01, + ) -> None: + """Create an A01 button entity.""" + self.entity_description = entity_description + super().__init__(f"{entity_description.key}_{coordinator.duid_slug}", coordinator) + + async def async_press(self) -> None: + """Press the button.""" + try: + if self.entity_description.param is not None: + await self.coordinator.api.set_value( + self.entity_description.data_protocol, + self.entity_description.param + ) + else: + await self.coordinator.api.set_value( + self.entity_description.data_protocol, + 1 # Default value for button press + ) + await self.coordinator.async_request_refresh() + except Exception as err: + from homeassistant.exceptions import HomeAssistantError + raise HomeAssistantError( + translation_domain="roborock", + translation_key="button_press_failed", + ) from err diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 6100d997d63ce..c96c213026168 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -432,6 +432,16 @@ def __init__( RoborockZeoProtocol.COUNTDOWN, RoborockZeoProtocol.WASHING_LEFT, RoborockZeoProtocol.ERROR, + RoborockZeoProtocol.TIMES_AFTER_CLEAN, + RoborockZeoProtocol.DETERGENT_EMPTY, + RoborockZeoProtocol.SOFTENER_EMPTY, + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.SOUND_SET, ] async def _async_update_data( diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 341dea0b267ef..9fc239946e854 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -1,18 +1,29 @@ """Support for Roborock select.""" import asyncio +import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any from roborock import B01Props, CleanTypeMapping -from roborock.data import RoborockDockDustCollectionModeCode, WaterLevelMapping +from roborock.data import ( + RoborockDockDustCollectionModeCode, + WaterLevelMapping, + ZeoDryingMode, + ZeoMode, + ZeoProgram, + ZeoRinse, + ZeoSpin, + ZeoTemperature, +) from roborock.devices.traits.b01 import Q7PropertiesApi from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.home import HomeTrait from roborock.devices.traits.v1.maps import MapsTrait from roborock.exceptions import RoborockException -from roborock.roborock_typing import RoborockCommand +from roborock.roborock_message import RoborockDataProtocol, RoborockZeoProtocol +from roborock.roborock_typing import DeviceProp, RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -25,11 +36,14 @@ RoborockB01Q7UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, ) -from .entity import RoborockCoordinatedEntityB01, RoborockCoordinatedEntityV1 +from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityB01, RoborockCoordinatedEntityV1 PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, kw_only=True) class RoborockSelectDescription(SelectEntityDescription): @@ -65,6 +79,18 @@ class RoborockB01SelectDescription(SelectEntityDescription): """Function to get all options of the select entity or returns None if not supported.""" +@dataclass(frozen=True, kw_only=True) +class RoborockSelectDescriptionA01(SelectEntityDescription): + """Class to describe a Roborock A01 select entity.""" + + # The protocol that the select entity will send to the api. + data_protocol: RoborockZeoProtocol + # Available options for the select entity + options: list[str] + # Maps option names to their protocol values + option_values: dict[str, int] + + B01_SELECT_DESCRIPTIONS: list[RoborockB01SelectDescription] = [ RoborockB01SelectDescription( key="water_flow", @@ -133,6 +159,58 @@ class RoborockB01SelectDescription(SelectEntityDescription): ] +A01_SELECT_DESCRIPTIONS: list[RoborockSelectDescriptionA01] = [ + RoborockSelectDescriptionA01( + key="program", + data_protocol=RoborockZeoProtocol.PROGRAM, + translation_key="zeo_program", + entity_category=EntityCategory.CONFIG, + options=list(ZeoProgram.keys()), + option_values={name: code for name, code in ZeoProgram.as_dict().items()}, + ), + RoborockSelectDescriptionA01( + key="mode", + data_protocol=RoborockZeoProtocol.MODE, + translation_key="zeo_mode", + entity_category=EntityCategory.CONFIG, + options=list(ZeoMode.keys()), + option_values={name: code for name, code in ZeoMode.as_dict().items()}, + ), + RoborockSelectDescriptionA01( + key="temperature", + data_protocol=RoborockZeoProtocol.TEMP, + translation_key="zeo_temperature", + entity_category=EntityCategory.CONFIG, + options=list(ZeoTemperature.keys()), + option_values={name: code for name, code in ZeoTemperature.as_dict().items()}, + ), + RoborockSelectDescriptionA01( + key="drying_mode", + data_protocol=RoborockZeoProtocol.DRYING_MODE, + translation_key="zeo_drying_mode", + entity_category=EntityCategory.CONFIG, + options=list(ZeoDryingMode.keys()), + option_values={name: code for name, code in ZeoDryingMode.as_dict().items()}, + ), + RoborockSelectDescriptionA01( + key="spin_level", + data_protocol=RoborockZeoProtocol.SPIN_LEVEL, + translation_key="zeo_spin_level", + entity_category=EntityCategory.CONFIG, + options=list(ZeoSpin.keys()), + option_values={name: code for name, code in ZeoSpin.as_dict().items()}, + ), + RoborockSelectDescriptionA01( + key="rinse_times", + data_protocol=RoborockZeoProtocol.RINSE_TIMES, + translation_key="zeo_rinse_times", + entity_category=EntityCategory.CONFIG, + options=list(ZeoRinse.keys()), + option_values={name: code for name, code in ZeoRinse.as_dict().items()}, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, @@ -164,6 +242,11 @@ async def async_setup_entry( if isinstance(coordinator, RoborockB01Q7UpdateCoordinator) if (options := description.options_lambda(coordinator.api)) is not None ) + async_add_entities( + RoborockSelectEntityA01(coordinator, description) + for coordinator in config_entry.runtime_data.a01 + for description in A01_SELECT_DESCRIPTIONS + ) class RoborockB01SelectEntity(RoborockCoordinatedEntityB01, SelectEntity): @@ -303,3 +386,60 @@ def current_option(self) -> str | None: if current_map_info := self._home_trait.current_map_data: return current_map_info.name or f"Map {current_map_info.map_flag}" return None + + +class RoborockSelectEntityA01(RoborockCoordinatedEntityA01, SelectEntity): + """A class to let you set options on a Roborock A01 device.""" + + entity_description: RoborockSelectDescriptionA01 + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinatorA01, + entity_description: RoborockSelectDescriptionA01, + ) -> None: + """Create an A01 select entity.""" + self.entity_description = entity_description + super().__init__( + f"{entity_description.key}_{coordinator.duid_slug}", + coordinator, + ) + self._attr_options = entity_description.options + + async def async_select_option(self, option: str) -> None: + """Set the option.""" + try: + # Get the protocol value for the selected option + value = self.entity_description.option_values.get(option) + if value is not None: + await self.coordinator.api.set_value( + self.entity_description.data_protocol, + value + ) + await self.coordinator.async_request_refresh() + except Exception as err: + from homeassistant.exceptions import HomeAssistantError + raise HomeAssistantError( + translation_domain="roborock", + translation_key="select_option_failed", + ) from err + + @property + def current_option(self) -> str | None: + """Get the current status of the select entity from coordinator data.""" + if self.entity_description.data_protocol not in self.coordinator.data: + return None + + current_value = self.coordinator.data[self.entity_description.data_protocol] + if current_value is None: + return None + _LOGGER.debug(f"current_value: {current_value} for {self.entity_description.key} with values {self.entity_description.option_values}") + # Find the option name that matches the current value + return current_value + #return self.entity_description.option_values.get(current_value, None) + #for option_name, option_value in self.entity_description.option_values.items(): + # _LOGGER.debug(f"Checking option: {option_name} with value: {option_value}") + # if option_name == current_value: + # return option_value + # + #return None diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 0b05996cf8c6a..7160bb737bf81 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -16,8 +16,16 @@ RoborockErrorCode, RoborockStateCode, WorkStatusMapping, + ZeoDetergentType, + ZeoDryingMode, ZeoError, + ZeoMode, + ZeoProgram, + ZeoRinse, + ZeoSoftenerType, + ZeoSpin, ZeoState, + ZeoTemperature, ) from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol @@ -261,13 +269,6 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: device_class=SensorDeviceClass.ENUM, options=RoborockDyadStateCode.keys(), ), - RoborockSensorDescriptionA01( - key="battery", - data_protocol=RoborockDyadDataProtocol.POWER, - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, - ), RoborockSensorDescriptionA01( key="filter_time_left", data_protocol=RoborockDyadDataProtocol.MESH_LEFT, @@ -287,7 +288,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescriptionA01( - key="error", + key="dyad_error", data_protocol=RoborockDyadDataProtocol.ERROR, device_class=SensorDeviceClass.ENUM, translation_key="a01_error", @@ -307,7 +308,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: key="state", data_protocol=RoborockZeoProtocol.STATE, translation_key="zeo_state", - entity_category=EntityCategory.DIAGNOSTIC, + #entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=ZeoState.keys(), ), @@ -325,16 +326,111 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: data_protocol=RoborockZeoProtocol.WASHING_LEFT, device_class=SensorDeviceClass.DURATION, translation_key="washing_left", - entity_category=EntityCategory.DIAGNOSTIC, + #entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescriptionA01( - key="error", + key="zeo_error", data_protocol=RoborockZeoProtocol.ERROR, device_class=SensorDeviceClass.ENUM, translation_key="zeo_error", - entity_category=EntityCategory.DIAGNOSTIC, + #entity_category=EntityCategory.DIAGNOSTIC, options=ZeoError.keys(), ), + # Additional Zeo sensors + RoborockSensorDescriptionA01( + key="times_after_clean", + data_protocol=RoborockZeoProtocol.TIMES_AFTER_CLEAN, + translation_key="times_after_clean", + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescriptionA01( + key="detergent_empty", + data_protocol=RoborockZeoProtocol.DETERGENT_EMPTY, + device_class=SensorDeviceClass.ENUM, + translation_key="detergent_empty", + entity_category=EntityCategory.DIAGNOSTIC, + options=[True, False], + ), + RoborockSensorDescriptionA01( + key="softener_empty", + data_protocol=RoborockZeoProtocol.SOFTENER_EMPTY, + device_class=SensorDeviceClass.ENUM, + translation_key="softener_empty", + entity_category=EntityCategory.DIAGNOSTIC, + options=[True, False], + ), + #RoborockSensorDescriptionA01( + # key="mode", + # data_protocol=RoborockZeoProtocol.MODE, + # device_class=SensorDeviceClass.ENUM, + # translation_key="zeo_mode", + # entity_category=EntityCategory.DIAGNOSTIC, + # options=ZeoMode.keys(), + #), + #RoborockSensorDescriptionA01( + # key="program", + # data_protocol=RoborockZeoProtocol.PROGRAM, + # device_class=SensorDeviceClass.ENUM, + # translation_key="zeo_program", + # entity_category=EntityCategory.DIAGNOSTIC, + # options=ZeoProgram.keys(), + #), + #RoborockSensorDescriptionA01( + # key="temperature", + # data_protocol=RoborockZeoProtocol.TEMP, + # device_class=SensorDeviceClass.ENUM, + # translation_key="zeo_temperature", + # entity_category=EntityCategory.DIAGNOSTIC, + # options=ZeoTemperature.keys(), + #), + #RoborockSensorDescriptionA01( + # key="rinse_times", + # data_protocol=RoborockZeoProtocol.RINSE_TIMES, + # device_class=SensorDeviceClass.ENUM, + # translation_key="zeo_rinse_times", + # entity_category=EntityCategory.DIAGNOSTIC, + # options=ZeoRinse.keys(), + #), + RoborockSensorDescriptionA01( + key="spin_level", + data_protocol=RoborockZeoProtocol.SPIN_LEVEL, + device_class=SensorDeviceClass.ENUM, + translation_key="zeo_spin_level", + entity_category=EntityCategory.DIAGNOSTIC, + options=ZeoSpin.keys(), + ), + #RoborockSensorDescriptionA01( + # key="drying_mode", + # data_protocol=RoborockZeoProtocol.DRYING_MODE, + # device_class=SensorDeviceClass.ENUM, + # translation_key="zeo_drying_mode", + # entity_category=EntityCategory.DIAGNOSTIC, + # options=ZeoDryingMode.keys(), + #), + RoborockSensorDescriptionA01( + key="detergent_type", + data_protocol=RoborockZeoProtocol.DETERGENT_TYPE, + device_class=SensorDeviceClass.ENUM, + translation_key="zeo_detergent_type", + entity_category=EntityCategory.DIAGNOSTIC, + options=ZeoDetergentType.keys(), + ), + RoborockSensorDescriptionA01( + key="softener_type", + data_protocol=RoborockZeoProtocol.SOFTENER_TYPE, + device_class=SensorDeviceClass.ENUM, + translation_key="zeo_softener_type", + entity_category=EntityCategory.DIAGNOSTIC, + options=ZeoSoftenerType.keys(), + ), + #RoborockSensorDescriptionA01( + # key="sound_setting", + # data_protocol=RoborockZeoProtocol.SOUND_SET, + # device_class=SensorDeviceClass.ENUM, + # translation_key="zeo_sound_setting", + # entity_category=EntityCategory.DIAGNOSTIC, + # options=[True, False], + #), ] Q7_B01_SENSOR_DESCRIPTIONS = [ diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 7c051ba129934..c241f8df4f922 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -81,6 +81,15 @@ }, "reset_side_brush_consumable": { "name": "Reset side brush consumable" + }, + "start": { + "name": "Start" + }, + "pause": { + "name": "Pause" + }, + "shutdown": { + "name": "Shutdown" } }, "number": { @@ -145,6 +154,101 @@ "low": "[%key:common::state::low%]", "medium": "[%key:common::state::medium%]" } + }, + "zeo_mode": { + "name": "Operating mode", + "state": { + "wash": "Wash", + "wash_and_dry": "Wash and Dry", + "dry": "Dry", + "heavy": "Heavy", + "pre_wash": "Pre-wash", + "rinse_spin": "Rinse & Spin", + "spin": "Spin", + "drain": "Drain" + } + }, + "zeo_program": { + "name": "Wash program", + "state": { + "standard": "Standard", + "quick": "Quick", + "sanitize": "Sanitize", + "wool": "Wool", + "air_refresh": "Air refresh", + "custom": "Custom", + "bedding": "Bedding", + "down": "Down", + "silk": "Silk", + "rinse_and_spin": "Rinse and spin", + "down_clean": "Down clean", + "baby_care": "Baby care", + "anti_allergen": "Anti-allergen", + "sportswear": "Sportswear", + "night": "Night", + "new_clothes": "New clothes", + "shirts": "Shirts", + "synthetics": "Synthetics", + "underwear": "Underwear", + "gentle": "Gentle", + "intensive": "Intensive", + "cotton_linen": "Cotton/Linen", + "season": "Season", + "warming": "Warming", + "bra": "Bra", + "panties": "Panties", + "boiling_wash": "Boiling wash", + "socks": "Socks", + "towels": "Towels", + "anti_mites": "Anti-mites", + "exo_40_60": "Exo 40/60", + "twenty_c": "20°C", + "t_shirts": "T-shirts", + "stain_removal": "Stain removal" + } + }, + "zeo_temperature": { + "name": "Water temperature", + "state": { + "cold": "Cold", + "30": "30°C", + "40": "40°C", + "60": "60°C", + "90": "90°C", + "auto": "Auto" + } + }, + "zeo_rinse_times": { + "name": "Rinse times", + "state": { + "none": "Default", + "min": "1", + "low": "2", + "mid": "3", + "high": "4", + "max": "5" + } + }, + "zeo_spin_level": { + "name": "Spin level", + "state": { + "none": "Default", + "very_low": "600 RPM", + "mid": "800 RPM", + "high": "1000 RPM", + "very_high": "1200 RPM", + "max": "1400 RPM" + } + }, + "zeo_drying_mode": { + "name": "Drying mode", + "state": { + "none": "No drying", + "quick": "Quick", + "iron": "Iron", + "store": "Store", + "time_dry": "Time dry" + } } }, "sensor": { @@ -405,6 +509,45 @@ "washing": "Washing", "weighing": "Weighing" } + }, + "times_after_clean": { + "name": "Times after clean" + }, + "detergent_empty": { + "name": "Detergent empty", + "state": { + "True": "Empty", + "False": "Available" + } + }, + "softener_empty": { + "name": "Softener empty", + "state": { + "True": "Empty", + "False": "Available" + } + }, + "zeo_detergent_type": { + "name": "Detergent type", + "state": { + "liquid": "Liquid", + "powder": "Powder", + "none": "None" + } + }, + "zeo_softener_type": { + "name": "Softener type", + "state": { + "liquid": "Liquid", + "none": "None" + } + }, + "zeo_sound_setting": { + "name": "Sound setting", + "state": { + "True": "Enabled", + "False": "Disabled" + } } }, "switch": { @@ -419,6 +562,9 @@ }, "status_indicator": { "name": "Status indicator light" + }, + "zeo_sound_setting": { + "name": "Sound setting" } }, "time": { @@ -500,7 +646,6 @@ "title": "Cloud API used" } }, - "options": { "step": { "drawables": { diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index b1d61461eb64a..d7028f79b0e60 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -10,6 +10,7 @@ from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.common import RoborockSwitchBase from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -18,8 +19,15 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator -from .entity import RoborockEntityV1 +from .coordinator import ( + RoborockConfigEntry, + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, +) +from .entity import ( + RoborockCoordinatedEntityA01, + RoborockEntityV1, +) _LOGGER = logging.getLogger(__name__) @@ -67,12 +75,30 @@ class RoborockSwitchDescription(SwitchEntityDescription): ] +@dataclass(frozen=True, kw_only=True) +class RoborockSwitchDescriptionA01(SwitchEntityDescription): + """Class to describe a Roborock A01 switch entity.""" + + data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol + + +A01_SWITCH_DESCRIPTIONS: list[RoborockSwitchDescriptionA01] = [ + RoborockSwitchDescriptionA01( + key="sound_setting", + data_protocol=RoborockZeoProtocol.SOUND_SET, + translation_key="zeo_sound_setting", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock switch platform.""" + # V1 switches - using trait pattern from HEAD async_add_entities( [ RoborockSwitch( @@ -86,6 +112,17 @@ async def async_setup_entry( if (trait := description.trait(coordinator.properties_api)) is not None ] ) + + # A01 switches + async_add_entities( + RoborockSwitchA01( + coordinator, + description, + ) + for coordinator in config_entry.runtime_data.a01 + for description in A01_SWITCH_DESCRIPTIONS + if description.data_protocol in coordinator.data + ) class RoborockSwitch(RoborockEntityV1, SwitchEntity): @@ -137,3 +174,49 @@ async def async_turn_on(self, **kwargs: Any) -> None: def is_on(self) -> bool | None: """Return True if entity is on.""" return self._trait.is_on + + +class RoborockSwitchA01(RoborockCoordinatedEntityA01, SwitchEntity): + """A class to let you turn functionality on Roborock A01 devices on and off.""" + + entity_description: RoborockSwitchDescriptionA01 + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinatorA01, + description: RoborockSwitchDescriptionA01, + ) -> None: + """Initialize the entity.""" + self.entity_description = description + super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + try: + await self.coordinator.api.set_value(self.entity_description.data_protocol, 0) + await self.coordinator.async_request_refresh() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + try: + await self.coordinator.api.set_value(self.entity_description.data_protocol, 1) + await self.coordinator.async_request_refresh() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err + + @property + def is_on(self) -> bool | None: + """Return True if entity is on.""" + status = self.coordinator.data.get(self.entity_description.data_protocol) + if status is None: + return None + return bool(status) + From 6f46863f30fc6dceeb26f52cbcfe78c674a8c26e Mon Sep 17 00:00:00 2001 From: Yangqian Date: Mon, 22 Dec 2025 12:57:12 +0800 Subject: [PATCH 02/33] fix commented out code --- homeassistant/components/roborock/select.py | 7 --- homeassistant/components/roborock/sensor.py | 56 --------------------- 2 files changed, 63 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 9fc239946e854..fee57fda93c54 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -436,10 +436,3 @@ def current_option(self) -> str | None: _LOGGER.debug(f"current_value: {current_value} for {self.entity_description.key} with values {self.entity_description.option_values}") # Find the option name that matches the current value return current_value - #return self.entity_description.option_values.get(current_value, None) - #for option_name, option_value in self.entity_description.option_values.items(): - # _LOGGER.debug(f"Checking option: {option_name} with value: {option_value}") - # if option_name == current_value: - # return option_value - # - #return None diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 7160bb737bf81..1ec5e7143f272 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -359,54 +359,6 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: entity_category=EntityCategory.DIAGNOSTIC, options=[True, False], ), - #RoborockSensorDescriptionA01( - # key="mode", - # data_protocol=RoborockZeoProtocol.MODE, - # device_class=SensorDeviceClass.ENUM, - # translation_key="zeo_mode", - # entity_category=EntityCategory.DIAGNOSTIC, - # options=ZeoMode.keys(), - #), - #RoborockSensorDescriptionA01( - # key="program", - # data_protocol=RoborockZeoProtocol.PROGRAM, - # device_class=SensorDeviceClass.ENUM, - # translation_key="zeo_program", - # entity_category=EntityCategory.DIAGNOSTIC, - # options=ZeoProgram.keys(), - #), - #RoborockSensorDescriptionA01( - # key="temperature", - # data_protocol=RoborockZeoProtocol.TEMP, - # device_class=SensorDeviceClass.ENUM, - # translation_key="zeo_temperature", - # entity_category=EntityCategory.DIAGNOSTIC, - # options=ZeoTemperature.keys(), - #), - #RoborockSensorDescriptionA01( - # key="rinse_times", - # data_protocol=RoborockZeoProtocol.RINSE_TIMES, - # device_class=SensorDeviceClass.ENUM, - # translation_key="zeo_rinse_times", - # entity_category=EntityCategory.DIAGNOSTIC, - # options=ZeoRinse.keys(), - #), - RoborockSensorDescriptionA01( - key="spin_level", - data_protocol=RoborockZeoProtocol.SPIN_LEVEL, - device_class=SensorDeviceClass.ENUM, - translation_key="zeo_spin_level", - entity_category=EntityCategory.DIAGNOSTIC, - options=ZeoSpin.keys(), - ), - #RoborockSensorDescriptionA01( - # key="drying_mode", - # data_protocol=RoborockZeoProtocol.DRYING_MODE, - # device_class=SensorDeviceClass.ENUM, - # translation_key="zeo_drying_mode", - # entity_category=EntityCategory.DIAGNOSTIC, - # options=ZeoDryingMode.keys(), - #), RoborockSensorDescriptionA01( key="detergent_type", data_protocol=RoborockZeoProtocol.DETERGENT_TYPE, @@ -423,14 +375,6 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: entity_category=EntityCategory.DIAGNOSTIC, options=ZeoSoftenerType.keys(), ), - #RoborockSensorDescriptionA01( - # key="sound_setting", - # data_protocol=RoborockZeoProtocol.SOUND_SET, - # device_class=SensorDeviceClass.ENUM, - # translation_key="zeo_sound_setting", - # entity_category=EntityCategory.DIAGNOSTIC, - # options=[True, False], - #), ] Q7_B01_SENSOR_DESCRIPTIONS = [ From 71e78d27e75d49933ceafe5d3952459c9ebc658c Mon Sep 17 00:00:00 2001 From: Yangqian Date: Mon, 22 Dec 2025 13:06:03 +0800 Subject: [PATCH 03/33] Format A01 code: remove commented lines, fix trailing spaces, use lazy logging --- homeassistant/components/roborock/button.py | 14 +++++--------- homeassistant/components/roborock/select.py | 16 ++++++++++------ homeassistant/components/roborock/sensor.py | 3 --- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 34ff4b7854866..f000cee7b96f1 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -88,19 +88,16 @@ class RoborockButtonDescriptionA01(ButtonEntityDescription): key="start", data_protocol=RoborockZeoProtocol.START, translation_key="start", - #entity_category=EntityCategory.CONFIG, ), RoborockButtonDescriptionA01( key="pause", data_protocol=RoborockZeoProtocol.PAUSE, translation_key="pause", - #entity_category=EntityCategory.CONFIG, ), RoborockButtonDescriptionA01( key="shutdown", data_protocol=RoborockZeoProtocol.SHUTDOWN, translation_key="shutdown", - #entity_category=EntityCategory.CONFIG, ), ] @@ -229,18 +226,17 @@ async def async_press(self) -> None: try: if self.entity_description.param is not None: await self.coordinator.api.set_value( - self.entity_description.data_protocol, - self.entity_description.param + self.entity_description.data_protocol, + self.entity_description.param, ) else: await self.coordinator.api.set_value( - self.entity_description.data_protocol, - 1 # Default value for button press + self.entity_description.data_protocol, + 1, # Default value for button press ) await self.coordinator.async_request_refresh() except Exception as err: - from homeassistant.exceptions import HomeAssistantError raise HomeAssistantError( - translation_domain="roborock", + translation_domain=DOMAIN, translation_key="button_press_failed", ) from err diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index fee57fda93c54..6081670b94a19 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -413,14 +413,13 @@ async def async_select_option(self, option: str) -> None: value = self.entity_description.option_values.get(option) if value is not None: await self.coordinator.api.set_value( - self.entity_description.data_protocol, - value + self.entity_description.data_protocol, + value, ) await self.coordinator.async_request_refresh() except Exception as err: - from homeassistant.exceptions import HomeAssistantError raise HomeAssistantError( - translation_domain="roborock", + translation_domain=DOMAIN, translation_key="select_option_failed", ) from err @@ -429,10 +428,15 @@ def current_option(self) -> str | None: """Get the current status of the select entity from coordinator data.""" if self.entity_description.data_protocol not in self.coordinator.data: return None - + current_value = self.coordinator.data[self.entity_description.data_protocol] if current_value is None: return None - _LOGGER.debug(f"current_value: {current_value} for {self.entity_description.key} with values {self.entity_description.option_values}") + _LOGGER.debug( + "current_value: %s for %s with values %s", + current_value, + self.entity_description.key, + self.entity_description.option_values, + ) # Find the option name that matches the current value return current_value diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 1ec5e7143f272..62a487da31547 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -308,7 +308,6 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: key="state", data_protocol=RoborockZeoProtocol.STATE, translation_key="zeo_state", - #entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=ZeoState.keys(), ), @@ -326,14 +325,12 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: data_protocol=RoborockZeoProtocol.WASHING_LEFT, device_class=SensorDeviceClass.DURATION, translation_key="washing_left", - #entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescriptionA01( key="zeo_error", data_protocol=RoborockZeoProtocol.ERROR, device_class=SensorDeviceClass.ENUM, translation_key="zeo_error", - #entity_category=EntityCategory.DIAGNOSTIC, options=ZeoError.keys(), ), # Additional Zeo sensors From 1d8c12d36f33f1154f10d947f7c8f47e22bf9e34 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Mon, 22 Dec 2025 13:10:10 +0800 Subject: [PATCH 04/33] ruff format --- homeassistant/components/roborock/button.py | 6 +----- homeassistant/components/roborock/select.py | 2 +- homeassistant/components/roborock/switch.py | 5 +---- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index f000cee7b96f1..3f44bb0a19ee0 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -25,11 +25,7 @@ RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, ) -from .entity import ( - RoborockCoordinatedEntityA01, - RoborockEntity, - RoborockEntityV1, -) +from .entity import RoborockCoordinatedEntityA01, RoborockEntity, RoborockEntityV1 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 6081670b94a19..874fd06c33272 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -1,9 +1,9 @@ """Support for Roborock select.""" import asyncio -import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass +import logging from typing import Any from roborock import B01Props, CleanTypeMapping diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index d7028f79b0e60..5e267ee58d0c9 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -24,10 +24,7 @@ RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, ) -from .entity import ( - RoborockCoordinatedEntityA01, - RoborockEntityV1, -) +from .entity import RoborockCoordinatedEntityA01, RoborockEntityV1 _LOGGER = logging.getLogger(__name__) From 19650441720f5c198809f92d5e45cda92e8ca04c Mon Sep 17 00:00:00 2001 From: Yangqian Date: Mon, 22 Dec 2025 13:21:29 +0800 Subject: [PATCH 05/33] Refactor imports in Roborock components: remove unused imports and clean up code --- homeassistant/components/roborock/button.py | 1 - homeassistant/components/roborock/select.py | 16 ++++++++-------- homeassistant/components/roborock/sensor.py | 6 ------ homeassistant/components/roborock/switch.py | 2 +- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 3f44bb0a19ee0..c18f3520e0f94 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -11,7 +11,6 @@ from roborock.devices.traits.v1.consumeable import ConsumableAttribute from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockZeoProtocol -from roborock.roborock_typing import RoborockCommand from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 874fd06c33272..506adc580a138 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -22,8 +22,8 @@ from roborock.devices.traits.v1.home import HomeTrait from roborock.devices.traits.v1.maps import MapsTrait from roborock.exceptions import RoborockException -from roborock.roborock_message import RoborockDataProtocol, RoborockZeoProtocol -from roborock.roborock_typing import DeviceProp, RoborockCommand +from roborock.roborock_message import RoborockZeoProtocol +from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -166,7 +166,7 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): translation_key="zeo_program", entity_category=EntityCategory.CONFIG, options=list(ZeoProgram.keys()), - option_values={name: code for name, code in ZeoProgram.as_dict().items()}, + option_values=dict(ZeoProgram.as_dict().items()), ), RoborockSelectDescriptionA01( key="mode", @@ -174,7 +174,7 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): translation_key="zeo_mode", entity_category=EntityCategory.CONFIG, options=list(ZeoMode.keys()), - option_values={name: code for name, code in ZeoMode.as_dict().items()}, + option_values=dict(ZeoMode.as_dict().items()), ), RoborockSelectDescriptionA01( key="temperature", @@ -182,7 +182,7 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): translation_key="zeo_temperature", entity_category=EntityCategory.CONFIG, options=list(ZeoTemperature.keys()), - option_values={name: code for name, code in ZeoTemperature.as_dict().items()}, + option_values=dict(ZeoTemperature.as_dict().items()), ), RoborockSelectDescriptionA01( key="drying_mode", @@ -190,7 +190,7 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): translation_key="zeo_drying_mode", entity_category=EntityCategory.CONFIG, options=list(ZeoDryingMode.keys()), - option_values={name: code for name, code in ZeoDryingMode.as_dict().items()}, + option_values=dict(ZeoDryingMode.as_dict().items()), ), RoborockSelectDescriptionA01( key="spin_level", @@ -198,7 +198,7 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): translation_key="zeo_spin_level", entity_category=EntityCategory.CONFIG, options=list(ZeoSpin.keys()), - option_values={name: code for name, code in ZeoSpin.as_dict().items()}, + option_values=dict(ZeoSpin.as_dict().items()), ), RoborockSelectDescriptionA01( key="rinse_times", @@ -206,7 +206,7 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): translation_key="zeo_rinse_times", entity_category=EntityCategory.CONFIG, options=list(ZeoRinse.keys()), - option_values={name: code for name, code in ZeoRinse.as_dict().items()}, + option_values=dict(ZeoRinse.as_dict().items()), ), ] diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 62a487da31547..8cb79b533bc32 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -17,15 +17,9 @@ RoborockStateCode, WorkStatusMapping, ZeoDetergentType, - ZeoDryingMode, ZeoError, - ZeoMode, - ZeoProgram, - ZeoRinse, ZeoSoftenerType, - ZeoSpin, ZeoState, - ZeoTemperature, ) from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 5e267ee58d0c9..edc44de056864 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -109,7 +109,7 @@ async def async_setup_entry( if (trait := description.trait(coordinator.properties_api)) is not None ] ) - + # A01 switches async_add_entities( RoborockSwitchA01( From 67b1510e6ae5718c713d750b6d13afd4261b305c Mon Sep 17 00:00:00 2001 From: Yangqian Date: Mon, 22 Dec 2025 13:23:00 +0800 Subject: [PATCH 06/33] Format button and switch code: improve readability by breaking long lines --- homeassistant/components/roborock/button.py | 4 +++- homeassistant/components/roborock/switch.py | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index c18f3520e0f94..9fb94e318ec84 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -214,7 +214,9 @@ def __init__( ) -> None: """Create an A01 button entity.""" self.entity_description = entity_description - super().__init__(f"{entity_description.key}_{coordinator.duid_slug}", coordinator) + super().__init__( + f"{entity_description.key}_{coordinator.duid_slug}", coordinator + ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index edc44de056864..b242b6d12a833 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -190,7 +190,9 @@ def __init__( async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" try: - await self.coordinator.api.set_value(self.entity_description.data_protocol, 0) + await self.coordinator.api.set_value( + self.entity_description.data_protocol, 0 + ) await self.coordinator.async_request_refresh() except RoborockException as err: raise HomeAssistantError( @@ -201,7 +203,9 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" try: - await self.coordinator.api.set_value(self.entity_description.data_protocol, 1) + await self.coordinator.api.set_value( + self.entity_description.data_protocol, 1 + ) await self.coordinator.async_request_refresh() except RoborockException as err: raise HomeAssistantError( @@ -216,4 +220,3 @@ def is_on(self) -> bool | None: if status is None: return None return bool(status) - From 628f6bc9bf570ea93e635e6469830c608df27da8 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 30 Dec 2025 00:22:11 +0000 Subject: [PATCH 07/33] Address PR review comments for roborock washing machine integration - Add missing translation keys for button_press_failed and select_option_failed exceptions - Replace bare Exception with RoborockException in button, select, and switch entities - Refactor select entity descriptions to use single enum_class field instead of options + option_values - Fix select entity async_select_option to validate options and always call API with value - Fix current_option to return string representation of current value - Change sound_setting entity_category from DIAGNOSTIC to CONFIG - Remove unused param field from RoborockButtonDescriptionA01 - Fix sentence case in strings.json (Wash and Dry -> Wash and dry, Rinse & Spin -> Rinse & spin) - Remove 'zeo_' prefix from all translation keys (12 occurrences) - Add value_fn support to sensor descriptions for boolean-to-string conversion - Fix detergent_empty and softener_empty sensors to use 'empty'/'available' instead of True/False - Add RoborockEnum import and type hints - Add type: ignore comments for mypy attr-defined checks --- homeassistant/components/roborock/button.py | 17 +- homeassistant/components/roborock/select.py | 62 ++-- homeassistant/components/roborock/sensor.py | 18 +- .../components/roborock/strings.json | 327 +++++++++--------- homeassistant/components/roborock/switch.py | 8 +- 5 files changed, 211 insertions(+), 221 deletions(-) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 9fb94e318ec84..ed1a74b0e8930 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -75,7 +75,6 @@ class RoborockButtonDescriptionA01(ButtonEntityDescription): """Describes a Roborock A01 button entity.""" data_protocol: RoborockZeoProtocol - param: Any = None A01_BUTTON_DESCRIPTIONS = [ @@ -221,18 +220,12 @@ def __init__( async def async_press(self) -> None: """Press the button.""" try: - if self.entity_description.param is not None: - await self.coordinator.api.set_value( - self.entity_description.data_protocol, - self.entity_description.param, - ) - else: - await self.coordinator.api.set_value( - self.entity_description.data_protocol, - 1, # Default value for button press - ) + await self.coordinator.api.set_value( # type: ignore[attr-defined] + self.entity_description.data_protocol, + 1, + ) await self.coordinator.async_request_refresh() - except Exception as err: + except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="button_press_failed", diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 506adc580a138..72399b3834c48 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -9,6 +9,7 @@ from roborock import B01Props, CleanTypeMapping from roborock.data import ( RoborockDockDustCollectionModeCode, + RoborockEnum, WaterLevelMapping, ZeoDryingMode, ZeoMode, @@ -85,10 +86,8 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): # The protocol that the select entity will send to the api. data_protocol: RoborockZeoProtocol - # Available options for the select entity - options: list[str] - # Maps option names to their protocol values - option_values: dict[str, int] + # Enum class for the select entity + enum_class: type[RoborockEnum] B01_SELECT_DESCRIPTIONS: list[RoborockB01SelectDescription] = [ @@ -163,50 +162,44 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): RoborockSelectDescriptionA01( key="program", data_protocol=RoborockZeoProtocol.PROGRAM, - translation_key="zeo_program", + translation_key="program", entity_category=EntityCategory.CONFIG, - options=list(ZeoProgram.keys()), - option_values=dict(ZeoProgram.as_dict().items()), + enum_class=ZeoProgram, ), RoborockSelectDescriptionA01( key="mode", data_protocol=RoborockZeoProtocol.MODE, - translation_key="zeo_mode", + translation_key="mode", entity_category=EntityCategory.CONFIG, - options=list(ZeoMode.keys()), - option_values=dict(ZeoMode.as_dict().items()), + enum_class=ZeoMode, ), RoborockSelectDescriptionA01( key="temperature", data_protocol=RoborockZeoProtocol.TEMP, - translation_key="zeo_temperature", + translation_key="temperature", entity_category=EntityCategory.CONFIG, - options=list(ZeoTemperature.keys()), - option_values=dict(ZeoTemperature.as_dict().items()), + enum_class=ZeoTemperature, ), RoborockSelectDescriptionA01( key="drying_mode", data_protocol=RoborockZeoProtocol.DRYING_MODE, - translation_key="zeo_drying_mode", + translation_key="drying_mode", entity_category=EntityCategory.CONFIG, - options=list(ZeoDryingMode.keys()), - option_values=dict(ZeoDryingMode.as_dict().items()), + enum_class=ZeoDryingMode, ), RoborockSelectDescriptionA01( key="spin_level", data_protocol=RoborockZeoProtocol.SPIN_LEVEL, - translation_key="zeo_spin_level", + translation_key="spin_level", entity_category=EntityCategory.CONFIG, - options=list(ZeoSpin.keys()), - option_values=dict(ZeoSpin.as_dict().items()), + enum_class=ZeoSpin, ), RoborockSelectDescriptionA01( key="rinse_times", data_protocol=RoborockZeoProtocol.RINSE_TIMES, - translation_key="zeo_rinse_times", + translation_key="rinse_times", entity_category=EntityCategory.CONFIG, - options=list(ZeoRinse.keys()), - option_values=dict(ZeoRinse.as_dict().items()), + enum_class=ZeoRinse, ), ] @@ -404,20 +397,22 @@ def __init__( f"{entity_description.key}_{coordinator.duid_slug}", coordinator, ) - self._attr_options = entity_description.options + self._attr_options = list(entity_description.enum_class.keys()) async def async_select_option(self, option: str) -> None: """Set the option.""" try: # Get the protocol value for the selected option - value = self.entity_description.option_values.get(option) - if value is not None: - await self.coordinator.api.set_value( - self.entity_description.data_protocol, - value, - ) - await self.coordinator.async_request_refresh() - except Exception as err: + option_values = self.entity_description.enum_class.as_dict() + if option not in option_values: + raise ValueError(f"Invalid option: {option}") + value = option_values[option] + await self.coordinator.api.set_value( # type: ignore[attr-defined] + self.entity_description.data_protocol, + value, + ) + await self.coordinator.async_request_refresh() + except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="select_option_failed", @@ -433,10 +428,9 @@ def current_option(self) -> str | None: if current_value is None: return None _LOGGER.debug( - "current_value: %s for %s with values %s", + "current_value: %s for %s", current_value, self.entity_description.key, - self.entity_description.option_values, ) # Find the option name that matches the current value - return current_value + return str(current_value) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 8cb79b533bc32..c227674258507 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -68,6 +68,7 @@ class RoborockSensorDescriptionA01(SensorEntityDescription): """A class that describes Roborock sensors.""" data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol + value_fn: Callable[[StateType], StateType] | None = None @dataclass(frozen=True, kw_only=True) @@ -324,7 +325,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: key="zeo_error", data_protocol=RoborockZeoProtocol.ERROR, device_class=SensorDeviceClass.ENUM, - translation_key="zeo_error", + translation_key="error", options=ZeoError.keys(), ), # Additional Zeo sensors @@ -340,7 +341,8 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: device_class=SensorDeviceClass.ENUM, translation_key="detergent_empty", entity_category=EntityCategory.DIAGNOSTIC, - options=[True, False], + options=["empty", "available"], + value_fn=lambda x: "empty" if x else "available", ), RoborockSensorDescriptionA01( key="softener_empty", @@ -348,13 +350,14 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: device_class=SensorDeviceClass.ENUM, translation_key="softener_empty", entity_category=EntityCategory.DIAGNOSTIC, - options=[True, False], + options=["empty", "available"], + value_fn=lambda x: "empty" if x else "available", ), RoborockSensorDescriptionA01( key="detergent_type", data_protocol=RoborockZeoProtocol.DETERGENT_TYPE, device_class=SensorDeviceClass.ENUM, - translation_key="zeo_detergent_type", + translation_key="detergent_type", entity_category=EntityCategory.DIAGNOSTIC, options=ZeoDetergentType.keys(), ), @@ -362,7 +365,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: key="softener_type", data_protocol=RoborockZeoProtocol.SOFTENER_TYPE, device_class=SensorDeviceClass.ENUM, - translation_key="zeo_softener_type", + translation_key="softener_type", entity_category=EntityCategory.DIAGNOSTIC, options=ZeoSoftenerType.keys(), ), @@ -543,7 +546,10 @@ def __init__( @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.coordinator.data[self.entity_description.data_protocol] + value = self.coordinator.data[self.entity_description.data_protocol] + if self.entity_description.value_fn is not None: + return self.entity_description.value_fn(value) + return value class RoborockSensorEntityB01(RoborockCoordinatedEntityB01, SensorEntity): diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index c241f8df4f922..139dc6e10bbb2 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -70,6 +70,9 @@ } }, "button": { + "pause": { + "name": "Pause" + }, "reset_air_filter_consumable": { "name": "Reset air filter consumable" }, @@ -82,14 +85,11 @@ "reset_side_brush_consumable": { "name": "Reset side brush consumable" }, - "start": { - "name": "Start" - }, - "pause": { - "name": "Pause" - }, "shutdown": { "name": "Shutdown" + }, + "start": { + "name": "Start" } }, "number": { @@ -106,6 +106,16 @@ "vacuum": "Vacuum only" } }, + "drying_mode": { + "name": "Drying mode", + "state": { + "iron": "Iron", + "none": "No drying", + "quick": "Quick", + "store": "Store", + "time_dry": "Time dry" + } + }, "dust_collection_mode": { "name": "Empty mode", "state": { @@ -115,6 +125,19 @@ "smart": "Smart" } }, + "mode": { + "name": "Operating mode", + "state": { + "drain": "Drain", + "dry": "Dry", + "heavy": "Heavy", + "pre_wash": "Pre-wash", + "rinse_spin": "Rinse & spin", + "spin": "Spin", + "wash": "Wash", + "wash_and_dry": "Wash and dry" + } + }, "mop_intensity": { "name": "Mop intensity", "state": { @@ -144,110 +167,87 @@ "standard": "Standard" } }, - "selected_map": { - "name": "Selected map" - }, - "water_flow": { - "name": "Water flow", - "state": { - "high": "[%key:common::state::high%]", - "low": "[%key:common::state::low%]", - "medium": "[%key:common::state::medium%]" - } - }, - "zeo_mode": { - "name": "Operating mode", - "state": { - "wash": "Wash", - "wash_and_dry": "Wash and Dry", - "dry": "Dry", - "heavy": "Heavy", - "pre_wash": "Pre-wash", - "rinse_spin": "Rinse & Spin", - "spin": "Spin", - "drain": "Drain" - } - }, - "zeo_program": { + "program": { "name": "Wash program", "state": { - "standard": "Standard", - "quick": "Quick", - "sanitize": "Sanitize", - "wool": "Wool", "air_refresh": "Air refresh", - "custom": "Custom", + "anti_allergen": "Anti-allergen", + "anti_mites": "Anti-mites", + "baby_care": "Baby care", "bedding": "Bedding", + "boiling_wash": "Boiling wash", + "bra": "Bra", + "cotton_linen": "Cotton/Linen", + "custom": "Custom", "down": "Down", - "silk": "Silk", - "rinse_and_spin": "Rinse and spin", "down_clean": "Down clean", - "baby_care": "Baby care", - "anti_allergen": "Anti-allergen", - "sportswear": "Sportswear", - "night": "Night", - "new_clothes": "New clothes", - "shirts": "Shirts", - "synthetics": "Synthetics", - "underwear": "Underwear", + "exo_40_60": "Exo 40/60", "gentle": "Gentle", "intensive": "Intensive", - "cotton_linen": "Cotton/Linen", - "season": "Season", - "warming": "Warming", - "bra": "Bra", + "new_clothes": "New clothes", + "night": "Night", "panties": "Panties", - "boiling_wash": "Boiling wash", + "quick": "Quick", + "rinse_and_spin": "Rinse and spin", + "sanitize": "Sanitize", + "season": "Season", + "shirts": "Shirts", + "silk": "Silk", "socks": "Socks", + "sportswear": "Sportswear", + "stain_removal": "Stain removal", + "standard": "Standard", + "synthetics": "Synthetics", + "t_shirts": "T-shirts", "towels": "Towels", - "anti_mites": "Anti-mites", - "exo_40_60": "Exo 40/60", "twenty_c": "20°C", - "t_shirts": "T-shirts", - "stain_removal": "Stain removal" - } - }, - "zeo_temperature": { - "name": "Water temperature", - "state": { - "cold": "Cold", - "30": "30°C", - "40": "40°C", - "60": "60°C", - "90": "90°C", - "auto": "Auto" + "underwear": "Underwear", + "warming": "Warming", + "wool": "Wool" } }, - "zeo_rinse_times": { + "rinse_times": { "name": "Rinse times", "state": { - "none": "Default", - "min": "1", + "high": "4", "low": "2", + "max": "5", "mid": "3", - "high": "4", - "max": "5" + "min": "1", + "none": "Default" } }, - "zeo_spin_level": { + "selected_map": { + "name": "Selected map" + }, + "spin_level": { "name": "Spin level", "state": { - "none": "Default", - "very_low": "600 RPM", - "mid": "800 RPM", "high": "1000 RPM", + "max": "1400 RPM", + "mid": "800 RPM", + "none": "Default", "very_high": "1200 RPM", - "max": "1400 RPM" + "very_low": "600 RPM" } }, - "zeo_drying_mode": { - "name": "Drying mode", + "temperature": { + "name": "Water temperature", "state": { - "none": "No drying", - "quick": "Quick", - "iron": "Iron", - "store": "Store", - "time_dry": "Time dry" + "30": "30°C", + "40": "40°C", + "60": "60°C", + "90": "90°C", + "auto": "Auto", + "cold": "Cold" + } + }, + "water_flow": { + "name": "Water flow", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]" } } }, @@ -318,6 +318,21 @@ "current_room": { "name": "Current room" }, + "detergent_empty": { + "name": "Detergent empty", + "state": { + "available": "Available", + "empty": "Empty" + } + }, + "detergent_type": { + "name": "Detergent type", + "state": { + "liquid": "Liquid", + "none": "None", + "powder": "Powder" + } + }, "dock_error": { "name": "Dock error", "state": { @@ -330,6 +345,29 @@ "water_empty": "Water empty" } }, + "error": { + "name": "Error", + "state": { + "communication_error": "Communication error", + "door_lock_error": "Door lock error", + "drain_error": "Drain error", + "drying_error": "Drying error", + "drying_error_e_12": "Drying error E12", + "drying_error_e_13": "Drying error E13", + "drying_error_e_14": "Drying error E14", + "drying_error_e_15": "Drying error E15", + "drying_error_e_16": "Drying error E16", + "drying_error_restart": "Restart the washer", + "drying_error_water_flow": "Check water flow", + "heating_error": "Heating error", + "inverter_error": "Inverter error", + "none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]", + "refill_error": "Refill error", + "spin_error": "Re-arrange clothes", + "temperature_error": "Temperature error", + "water_level_error": "Water level error" + } + }, "filter_time_left": { "name": "Filter time left" }, @@ -370,6 +408,35 @@ "side_brush_time_left": { "name": "Side brush time left" }, + "softener_empty": { + "name": "Softener empty", + "state": { + "available": "Available", + "empty": "Empty" + } + }, + "softener_type": { + "name": "Softener type", + "state": { + "liquid": "Liquid", + "none": "None" + } + }, + "state": { + "name": "State", + "state": { + "cooling": "Cooling", + "done": "Done", + "drying": "Drying", + "rinsing": "Rinsing", + "soaking": "Soaking", + "spinning": "Spinning", + "standby": "[%key:common::state::standby%]", + "under_delay_start": "Delayed start", + "washing": "Washing", + "weighing": "Weighing" + } + }, "status": { "name": "Status", "state": { @@ -408,6 +475,9 @@ "strainer_time_left": { "name": "Strainer time left" }, + "times_after_clean": { + "name": "Times after clean" + }, "total_cleaning_area": { "name": "Total cleaning area" }, @@ -469,85 +539,6 @@ }, "washing_left": { "name": "Washing left" - }, - "zeo_error": { - "name": "Error", - "state": { - "communication_error": "Communication error", - "door_lock_error": "Door lock error", - "drain_error": "Drain error", - "drying_error": "Drying error", - "drying_error_e_12": "Drying error E12", - "drying_error_e_13": "Drying error E13", - "drying_error_e_14": "Drying error E14", - "drying_error_e_15": "Drying error E15", - "drying_error_e_16": "Drying error E16", - "drying_error_restart": "Restart the washer", - "drying_error_water_flow": "Check water flow", - "heating_error": "Heating error", - "inverter_error": "Inverter error", - "none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]", - "refill_error": "Refill error", - "spin_error": "Re-arrange clothes", - "temperature_error": "Temperature error", - "water_level_error": "Water level error" - } - }, - "zeo_state": { - "name": "State", - "state": { - "aftercare": "Aftercare", - "cooling": "Cooling", - "done": "Done", - "drying": "Drying", - "rinsing": "Rinsing", - "soaking": "Soaking", - "spinning": "Spinning", - "standby": "[%key:common::state::standby%]", - "under_delay_start": "Delayed start", - "waiting_for_aftercare": "Waiting for aftercare", - "washing": "Washing", - "weighing": "Weighing" - } - }, - "times_after_clean": { - "name": "Times after clean" - }, - "detergent_empty": { - "name": "Detergent empty", - "state": { - "True": "Empty", - "False": "Available" - } - }, - "softener_empty": { - "name": "Softener empty", - "state": { - "True": "Empty", - "False": "Available" - } - }, - "zeo_detergent_type": { - "name": "Detergent type", - "state": { - "liquid": "Liquid", - "powder": "Powder", - "none": "None" - } - }, - "zeo_softener_type": { - "name": "Softener type", - "state": { - "liquid": "Liquid", - "none": "None" - } - }, - "zeo_sound_setting": { - "name": "Sound setting", - "state": { - "True": "Enabled", - "False": "Disabled" - } } }, "switch": { @@ -560,11 +551,11 @@ "off_peak_switch": { "name": "Off-peak charging" }, + "sound_setting": { + "name": "Sound setting" + }, "status_indicator": { "name": "Status indicator light" - }, - "zeo_sound_setting": { - "name": "Sound setting" } }, "time": { @@ -606,6 +597,9 @@ } }, "exceptions": { + "button_press_failed": { + "message": "Failed to press button" + }, "command_failed": { "message": "Error while calling {command}" }, @@ -633,6 +627,9 @@ "position_not_found": { "message": "Robot position not found" }, + "select_option_failed": { + "message": "Failed to set selected option" + }, "update_data_fail": { "message": "Failed to update data" }, diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index b242b6d12a833..6f65efcb6892d 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -83,8 +83,8 @@ class RoborockSwitchDescriptionA01(SwitchEntityDescription): RoborockSwitchDescriptionA01( key="sound_setting", data_protocol=RoborockZeoProtocol.SOUND_SET, - translation_key="zeo_sound_setting", - entity_category=EntityCategory.DIAGNOSTIC, + translation_key="sound_setting", + entity_category=EntityCategory.CONFIG, ), ] @@ -190,7 +190,7 @@ def __init__( async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" try: - await self.coordinator.api.set_value( + await self.coordinator.api.set_value( # type: ignore[attr-defined] self.entity_description.data_protocol, 0 ) await self.coordinator.async_request_refresh() @@ -203,7 +203,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" try: - await self.coordinator.api.set_value( + await self.coordinator.api.set_value( # type: ignore[attr-defined] self.entity_description.data_protocol, 1 ) await self.coordinator.async_request_refresh() From b68d653da65755f111a1b316d67a3e2ec17b5da2 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 30 Dec 2025 11:38:04 +0000 Subject: [PATCH 08/33] revert changes on battery sensor and error descriptions for A01 devices --- homeassistant/components/roborock/sensor.py | 16 ++++++-- .../components/roborock/strings.json | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index c227674258507..18dc18f01e417 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -264,6 +264,13 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: device_class=SensorDeviceClass.ENUM, options=RoborockDyadStateCode.keys(), ), + RoborockSensorDescriptionA01( + key="battery", + data_protocol=RoborockDyadDataProtocol.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), RoborockSensorDescriptionA01( key="filter_time_left", data_protocol=RoborockDyadDataProtocol.MESH_LEFT, @@ -283,7 +290,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescriptionA01( - key="dyad_error", + key="error", data_protocol=RoborockDyadDataProtocol.ERROR, device_class=SensorDeviceClass.ENUM, translation_key="a01_error", @@ -303,6 +310,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: key="state", data_protocol=RoborockZeoProtocol.STATE, translation_key="zeo_state", + entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=ZeoState.keys(), ), @@ -320,12 +328,14 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: data_protocol=RoborockZeoProtocol.WASHING_LEFT, device_class=SensorDeviceClass.DURATION, translation_key="washing_left", + entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescriptionA01( - key="zeo_error", + key="error", data_protocol=RoborockZeoProtocol.ERROR, device_class=SensorDeviceClass.ENUM, - translation_key="error", + translation_key="zeo_error", + entity_category=EntityCategory.DIAGNOSTIC, options=ZeoError.keys(), ), # Additional Zeo sensors diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 139dc6e10bbb2..ab95037ce4934 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -539,6 +539,44 @@ }, "washing_left": { "name": "Washing left" + }, + "zeo_error": { + "name": "Error", + "state": { + "communication_error": "Communication error", + "door_lock_error": "Door lock error", + "drain_error": "Drain error", + "drying_error": "Drying error", + "drying_error_e_12": "Drying error E12", + "drying_error_e_13": "Drying error E13", + "drying_error_e_14": "Drying error E14", + "drying_error_e_15": "Drying error E15", + "drying_error_e_16": "Drying error E16", + "drying_error_restart": "Restart the washer", + "drying_error_water_flow": "Check water flow", + "heating_error": "Heating error", + "inverter_error": "Inverter error", + "none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]", + "refill_error": "Refill error", + "spin_error": "Re-arrange clothes", + "temperature_error": "Temperature error", + "water_level_error": "Water level error" + } + }, + "zeo_state": { + "name": "State", + "state": { + "cooling": "Cooling", + "done": "Done", + "drying": "Drying", + "rinsing": "Rinsing", + "soaking": "Soaking", + "spinning": "Spinning", + "standby": "[%key:common::state::standby%]", + "under_delay_start": "Delayed start", + "washing": "Washing", + "weighing": "Weighing" + } } }, "switch": { From 85d60008b21ce20cc00b943786bd13514e7841a1 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 30 Dec 2025 23:00:53 +0000 Subject: [PATCH 09/33] Remove unnecessary condition for A01 switch descriptions in setup entry --- homeassistant/components/roborock/switch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 6f65efcb6892d..66e5aca8b599b 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -118,7 +118,6 @@ async def async_setup_entry( ) for coordinator in config_entry.runtime_data.a01 for description in A01_SWITCH_DESCRIPTIONS - if description.data_protocol in coordinator.data ) From 2c0d9125f54638ba6291e3579caa744ffc6b33cb Mon Sep 17 00:00:00 2001 From: Yangqian Date: Sat, 10 Jan 2026 13:24:15 +0000 Subject: [PATCH 10/33] Rename A01 button descriptions to ZEO_BUTTON_DESCRIPTIONS and update setup logic for washing machine coordinator --- homeassistant/components/roborock/button.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index ed1a74b0e8930..6d6e19985adf9 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -23,6 +23,7 @@ RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, + RoborockWashingMachineUpdateCoordinator, ) from .entity import RoborockCoordinatedEntityA01, RoborockEntity, RoborockEntityV1 @@ -77,7 +78,7 @@ class RoborockButtonDescriptionA01(ButtonEntityDescription): data_protocol: RoborockZeoProtocol -A01_BUTTON_DESCRIPTIONS = [ +ZEO_BUTTON_DESCRIPTIONS = [ RoborockButtonDescriptionA01( key="start", data_protocol=RoborockZeoProtocol.START, @@ -135,7 +136,8 @@ async def async_setup_entry( description, ) for coordinator in config_entry.runtime_data.a01 - for description in A01_BUTTON_DESCRIPTIONS + if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator) + for description in ZEO_BUTTON_DESCRIPTIONS ), ) ) From 9c2123b1f5071cbc94ef030b25983621fc84f220 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Fri, 13 Feb 2026 16:29:35 +0000 Subject: [PATCH 11/33] Format import statements for improved readability --- .../components/roborock/coordinator.py | 2 + homeassistant/components/roborock/select.py | 6 +- homeassistant/components/roborock/sensor.py | 23 +- tests/components/roborock/conftest.py | 12 + .../roborock/snapshots/test_sensor.ambr | 293 ++++++++++++++++++ 5 files changed, 332 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index c96c213026168..4a9d147e6b4d0 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -435,6 +435,8 @@ def __init__( RoborockZeoProtocol.TIMES_AFTER_CLEAN, RoborockZeoProtocol.DETERGENT_EMPTY, RoborockZeoProtocol.SOFTENER_EMPTY, + RoborockZeoProtocol.DETERGENT_TYPE, + RoborockZeoProtocol.SOFTENER_TYPE, RoborockZeoProtocol.MODE, RoborockZeoProtocol.PROGRAM, RoborockZeoProtocol.TEMP, diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 72399b3834c48..a9d9132a44a3b 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -39,7 +39,11 @@ RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, ) -from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityB01, RoborockCoordinatedEntityV1 +from .entity import ( + RoborockCoordinatedEntityA01, + RoborockCoordinatedEntityB01, + RoborockCoordinatedEntityV1, +) PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 18dc18f01e417..8d50b51cb2809 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -39,6 +39,8 @@ RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, RoborockDataUpdateCoordinatorB01, + RoborockWashingMachineUpdateCoordinator, + RoborockWetDryVacUpdateCoordinator, ) from .entity import ( RoborockCoordinatedEntityA01, @@ -255,7 +257,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: ), ] -A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ +DYAD_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ RoborockSensorDescriptionA01( key="status", data_protocol=RoborockDyadDataProtocol.STATUS, @@ -306,6 +308,9 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: translation_key="total_cleaning_time", entity_category=EntityCategory.DIAGNOSTIC, ), +] + +ZEO_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ RoborockSensorDescriptionA01( key="state", data_protocol=RoborockZeoProtocol.STATE, @@ -338,7 +343,6 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: entity_category=EntityCategory.DIAGNOSTIC, options=ZeoError.keys(), ), - # Additional Zeo sensors RoborockSensorDescriptionA01( key="times_after_clean", data_protocol=RoborockZeoProtocol.TIMES_AFTER_CLEAN, @@ -370,6 +374,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: translation_key="detergent_type", entity_category=EntityCategory.DIAGNOSTIC, options=ZeoDetergentType.keys(), + value_fn=lambda x: ZeoDetergentType(int(x)).name, # type: ignore[arg-type] ), RoborockSensorDescriptionA01( key="softener_type", @@ -378,6 +383,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: translation_key="softener_type", entity_category=EntityCategory.DIAGNOSTIC, options=ZeoSoftenerType.keys(), + value_fn=lambda x: ZeoSoftenerType(int(x)).name, # type: ignore[arg-type] ), ] @@ -462,7 +468,18 @@ async def async_setup_entry( description, ) for coordinator in coordinators.a01 - for description in A01_SENSOR_DESCRIPTIONS + if isinstance(coordinator, RoborockWetDryVacUpdateCoordinator) + for description in DYAD_SENSOR_DESCRIPTIONS + if description.data_protocol in coordinator.request_protocols + ) + entities.extend( + RoborockSensorEntityA01( + coordinator, + description, + ) + for coordinator in coordinators.a01 + if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator) + for description in ZEO_SENSOR_DESCRIPTIONS if description.data_protocol in coordinator.request_protocols ) entities.extend( diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 7e3655782d4f2..602c133ac9a9e 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -104,6 +104,18 @@ def create_zeo_trait() -> Mock: RoborockZeoProtocol.COUNTDOWN: 0, RoborockZeoProtocol.WASHING_LEFT: 253, RoborockZeoProtocol.ERROR: ZeoError.none.name, + RoborockZeoProtocol.TIMES_AFTER_CLEAN: 5, + RoborockZeoProtocol.DETERGENT_EMPTY: 0, + RoborockZeoProtocol.SOFTENER_EMPTY: 0, + RoborockZeoProtocol.DETERGENT_TYPE: 2, + RoborockZeoProtocol.SOFTENER_TYPE: 2, + RoborockZeoProtocol.MODE: 0, + RoborockZeoProtocol.PROGRAM: 1, + RoborockZeoProtocol.TEMP: 1, + RoborockZeoProtocol.RINSE_TIMES: 1, + RoborockZeoProtocol.SPIN_LEVEL: 5, + RoborockZeoProtocol.DRYING_MODE: 3, + RoborockZeoProtocol.SOUND_SET: False, } return zeo_trait diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index 3c639a14e6240..8aa433edfabd6 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -3191,6 +3191,128 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.zeo_one_detergent_empty-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'empty', + 'available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zeo_one_detergent_empty', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Detergent empty', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Detergent empty', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'detergent_empty', + 'unique_id': 'detergent_empty_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.zeo_one_detergent_empty-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zeo One Detergent empty', + 'options': list([ + 'empty', + 'available', + ]), + }), + 'context': , + 'entity_id': 'sensor.zeo_one_detergent_empty', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensors[sensor.zeo_one_detergent_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'empty', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zeo_one_detergent_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Detergent type', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Detergent type', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'detergent_type', + 'unique_id': 'detergent_type_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.zeo_one_detergent_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zeo One Detergent type', + 'options': list([ + 'empty', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.zeo_one_detergent_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'medium', + }) +# --- # name: test_sensors[sensor.zeo_one_error-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3282,6 +3404,128 @@ 'state': 'none', }) # --- +# name: test_sensors[sensor.zeo_one_softener_empty-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'empty', + 'available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zeo_one_softener_empty', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Softener empty', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Softener empty', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'softener_empty', + 'unique_id': 'softener_empty_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.zeo_one_softener_empty-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zeo One Softener empty', + 'options': list([ + 'empty', + 'available', + ]), + }), + 'context': , + 'entity_id': 'sensor.zeo_one_softener_empty', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensors[sensor.zeo_one_softener_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'empty', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zeo_one_softener_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Softener type', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Softener type', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'softener_type', + 'unique_id': 'softener_type_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.zeo_one_softener_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Zeo One Softener type', + 'options': list([ + 'empty', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.zeo_one_softener_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- # name: test_sensors[sensor.zeo_one_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3361,6 +3605,55 @@ 'state': 'drying', }) # --- +# name: test_sensors[sensor.zeo_one_times_after_clean-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zeo_one_times_after_clean', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Times after clean', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Times after clean', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'times_after_clean', + 'unique_id': 'times_after_clean_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.zeo_one_times_after_clean-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Zeo One Times after clean', + }), + 'context': , + 'entity_id': 'sensor.zeo_one_times_after_clean', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- # name: test_sensors[sensor.zeo_one_washing_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1f2baaccf4d6a61b47f04ddd7f7a9acf1d2b836e Mon Sep 17 00:00:00 2001 From: Yangqian Yan <5144644+yangqian@users.noreply.github.com> Date: Sat, 14 Feb 2026 01:34:46 +0800 Subject: [PATCH 12/33] Update homeassistant/components/roborock/strings.json Co-authored-by: Norbert Rittel --- homeassistant/components/roborock/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index ab95037ce4934..96f45ae6b19b2 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -238,7 +238,7 @@ "40": "40°C", "60": "60°C", "90": "90°C", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "cold": "Cold" } }, From f053d2d9b57abd7243912d53461a908e6365a9c7 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Fri, 13 Feb 2026 17:43:22 +0000 Subject: [PATCH 13/33] Update softener_type snapshot to match mock data (medium, not low) --- tests/components/roborock/snapshots/test_sensor.ambr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index 8aa433edfabd6..558e2d639130a 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -3523,7 +3523,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'low', + 'state': 'medium', }) # --- # name: test_sensors[sensor.zeo_one_state-entry] From 18b290d9dfc47ed031567ce849f190250998d5b5 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Sat, 14 Feb 2026 12:47:48 +0000 Subject: [PATCH 14/33] Move detergent_type and softener_type from sensor to select --- homeassistant/components/roborock/select.py | 16 +++ homeassistant/components/roborock/sensor.py | 20 --- .../components/roborock/strings.json | 33 ++--- .../roborock/snapshots/test_sensor.ambr | 126 ------------------ 4 files changed, 34 insertions(+), 161 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index a9d9132a44a3b..1b7fcefe8a55d 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -11,10 +11,12 @@ RoborockDockDustCollectionModeCode, RoborockEnum, WaterLevelMapping, + ZeoDetergentType, ZeoDryingMode, ZeoMode, ZeoProgram, ZeoRinse, + ZeoSoftenerType, ZeoSpin, ZeoTemperature, ) @@ -205,6 +207,20 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): entity_category=EntityCategory.CONFIG, enum_class=ZeoRinse, ), + RoborockSelectDescriptionA01( + key="detergent_type", + data_protocol=RoborockZeoProtocol.DETERGENT_TYPE, + translation_key="detergent_type", + entity_category=EntityCategory.CONFIG, + enum_class=ZeoDetergentType, + ), + RoborockSelectDescriptionA01( + key="softener_type", + data_protocol=RoborockZeoProtocol.SOFTENER_TYPE, + translation_key="softener_type", + entity_category=EntityCategory.CONFIG, + enum_class=ZeoSoftenerType, + ), ] diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 8d50b51cb2809..3782a5e9dfc79 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -16,9 +16,7 @@ RoborockErrorCode, RoborockStateCode, WorkStatusMapping, - ZeoDetergentType, ZeoError, - ZeoSoftenerType, ZeoState, ) from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol @@ -367,24 +365,6 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: options=["empty", "available"], value_fn=lambda x: "empty" if x else "available", ), - RoborockSensorDescriptionA01( - key="detergent_type", - data_protocol=RoborockZeoProtocol.DETERGENT_TYPE, - device_class=SensorDeviceClass.ENUM, - translation_key="detergent_type", - entity_category=EntityCategory.DIAGNOSTIC, - options=ZeoDetergentType.keys(), - value_fn=lambda x: ZeoDetergentType(int(x)).name, # type: ignore[arg-type] - ), - RoborockSensorDescriptionA01( - key="softener_type", - data_protocol=RoborockZeoProtocol.SOFTENER_TYPE, - device_class=SensorDeviceClass.ENUM, - translation_key="softener_type", - entity_category=EntityCategory.DIAGNOSTIC, - options=ZeoSoftenerType.keys(), - value_fn=lambda x: ZeoSoftenerType(int(x)).name, # type: ignore[arg-type] - ), ] Q7_B01_SENSOR_DESCRIPTIONS = [ diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 96f45ae6b19b2..7e1c5c3b42aa5 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -106,6 +106,15 @@ "vacuum": "Vacuum only" } }, + "detergent_type": { + "name": "Detergent type", + "state": { + "empty": "Empty", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]" + } + }, "drying_mode": { "name": "Drying mode", "state": { @@ -220,6 +229,15 @@ "selected_map": { "name": "Selected map" }, + "softener_type": { + "name": "Softener type", + "state": { + "empty": "Empty", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]" + } + }, "spin_level": { "name": "Spin level", "state": { @@ -325,14 +343,6 @@ "empty": "Empty" } }, - "detergent_type": { - "name": "Detergent type", - "state": { - "liquid": "Liquid", - "none": "None", - "powder": "Powder" - } - }, "dock_error": { "name": "Dock error", "state": { @@ -415,13 +425,6 @@ "empty": "Empty" } }, - "softener_type": { - "name": "Softener type", - "state": { - "liquid": "Liquid", - "none": "None" - } - }, "state": { "name": "State", "state": { diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index 558e2d639130a..568117a63ba8f 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -3250,69 +3250,6 @@ 'state': 'available', }) # --- -# name: test_sensors[sensor.zeo_one_detergent_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'empty', - 'low', - 'medium', - 'high', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.zeo_one_detergent_type', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Detergent type', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Detergent type', - 'platform': 'roborock', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'detergent_type', - 'unique_id': 'detergent_type_zeo_duid', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.zeo_one_detergent_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Zeo One Detergent type', - 'options': list([ - 'empty', - 'low', - 'medium', - 'high', - ]), - }), - 'context': , - 'entity_id': 'sensor.zeo_one_detergent_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'medium', - }) -# --- # name: test_sensors[sensor.zeo_one_error-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3463,69 +3400,6 @@ 'state': 'available', }) # --- -# name: test_sensors[sensor.zeo_one_softener_type-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'empty', - 'low', - 'medium', - 'high', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.zeo_one_softener_type', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Softener type', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Softener type', - 'platform': 'roborock', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'softener_type', - 'unique_id': 'softener_type_zeo_duid', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.zeo_one_softener_type-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Zeo One Softener type', - 'options': list([ - 'empty', - 'low', - 'medium', - 'high', - ]), - }), - 'context': , - 'entity_id': 'sensor.zeo_one_softener_type', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'medium', - }) -# --- # name: test_sensors[sensor.zeo_one_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From bd92367b7cf562d9e585ae278a0435e4e45c546e Mon Sep 17 00:00:00 2001 From: Yangqian Yan <5144644+yangqian@users.noreply.github.com> Date: Tue, 17 Feb 2026 06:36:22 +0800 Subject: [PATCH 15/33] Update homeassistant/components/roborock/strings.json Co-authored-by: Norbert Rittel --- homeassistant/components/roborock/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 7e1c5c3b42aa5..5fe291df7f46d 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -109,7 +109,7 @@ "detergent_type": { "name": "Detergent type", "state": { - "empty": "Empty", + "empty": "[%key:common::state::empty%]", "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", "medium": "[%key:common::state::medium%]" From 2f65e2b91229ba56935d8f2e2ec8b4d10fe5a714 Mon Sep 17 00:00:00 2001 From: Yangqian Yan <5144644+yangqian@users.noreply.github.com> Date: Tue, 17 Feb 2026 06:36:49 +0800 Subject: [PATCH 16/33] Update homeassistant/components/roborock/strings.json Co-authored-by: Norbert Rittel --- homeassistant/components/roborock/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 5fe291df7f46d..44fae5170883a 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -422,7 +422,7 @@ "name": "Softener empty", "state": { "available": "Available", - "empty": "Empty" + "empty": "[%key:common::state::empty%]" } }, "state": { From 8c8537984d9e9871ae2e0310468ea4de4b08524d Mon Sep 17 00:00:00 2001 From: Yangqian Date: Mon, 16 Feb 2026 22:57:56 +0000 Subject: [PATCH 17/33] Move Zeo detergent and softener empty to binary sensors --- .../components/roborock/binary_sensor.py | 73 ++++++++++- homeassistant/components/roborock/sensor.py | 18 --- .../components/roborock/strings.json | 20 +-- .../snapshots/test_binary_sensor.ambr | 100 +++++++++++++++ .../roborock/snapshots/test_sensor.ambr | 118 ------------------ 5 files changed, 176 insertions(+), 153 deletions(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index dfeae5f9dd9f9..114656a6d17ab 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from roborock.data import CleanFluidStatus, RoborockStateCode +from roborock.roborock_message import RoborockZeoProtocol from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -15,9 +16,15 @@ from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType -from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator -from .entity import RoborockCoordinatedEntityV1 +from .coordinator import ( + RoborockConfigEntry, + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, + RoborockWashingMachineUpdateCoordinator, +) +from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 from .models import DeviceState PARALLEL_UPDATES = 0 @@ -34,6 +41,14 @@ class RoborockBinarySensorDescription(BinarySensorEntityDescription): """Whether this sensor is for the dock.""" +@dataclass(frozen=True, kw_only=True) +class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription): + """A class that describes Roborock A01 binary sensors.""" + + data_protocol: RoborockZeoProtocol + value_fn: Callable[[StateType], bool] + + BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="dry_status", @@ -111,13 +126,33 @@ class RoborockBinarySensorDescription(BinarySensorEntityDescription): ] +ZEO_BINARY_SENSOR_DESCRIPTIONS: list[RoborockBinarySensorDescriptionA01] = [ + RoborockBinarySensorDescriptionA01( + key="detergent_empty", + data_protocol=RoborockZeoProtocol.DETERGENT_EMPTY, + device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="detergent_empty", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=bool, + ), + RoborockBinarySensorDescriptionA01( + key="softener_empty", + data_protocol=RoborockZeoProtocol.SOFTENER_EMPTY, + device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="softener_empty", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=bool, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Roborock vacuum binary sensors.""" - async_add_entities( + entities: list[BinarySensorEntity] = [ RoborockBinarySensorEntity( coordinator, description, @@ -125,7 +160,18 @@ async def async_setup_entry( for coordinator in config_entry.runtime_data.v1 for description in BINARY_SENSOR_DESCRIPTIONS if description.value_fn(coordinator.data) is not None + ] + entities.extend( + RoborockBinarySensorEntityA01( + coordinator, + description, + ) + for coordinator in config_entry.runtime_data.a01 + if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator) + for description in ZEO_BINARY_SENSOR_DESCRIPTIONS + if description.data_protocol in coordinator.request_protocols ) + async_add_entities(entities) class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity): @@ -150,3 +196,24 @@ def __init__( def is_on(self) -> bool: """Return the value reported by the sensor.""" return bool(self.entity_description.value_fn(self.coordinator.data)) + + +class RoborockBinarySensorEntityA01(RoborockCoordinatedEntityA01, BinarySensorEntity): + """Representation of a A01 Roborock binary sensor.""" + + entity_description: RoborockBinarySensorDescriptionA01 + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinatorA01, + description: RoborockBinarySensorDescriptionA01, + ) -> None: + """Initialize the entity.""" + self.entity_description = description + super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator) + + @property + def is_on(self) -> bool: + """Return the value reported by the sensor.""" + value = self.coordinator.data[self.entity_description.data_protocol] + return self.entity_description.value_fn(value) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 3782a5e9dfc79..39df38537ba42 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -347,24 +347,6 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: translation_key="times_after_clean", entity_category=EntityCategory.DIAGNOSTIC, ), - RoborockSensorDescriptionA01( - key="detergent_empty", - data_protocol=RoborockZeoProtocol.DETERGENT_EMPTY, - device_class=SensorDeviceClass.ENUM, - translation_key="detergent_empty", - entity_category=EntityCategory.DIAGNOSTIC, - options=["empty", "available"], - value_fn=lambda x: "empty" if x else "available", - ), - RoborockSensorDescriptionA01( - key="softener_empty", - data_protocol=RoborockZeoProtocol.SOFTENER_EMPTY, - device_class=SensorDeviceClass.ENUM, - translation_key="softener_empty", - entity_category=EntityCategory.DIAGNOSTIC, - options=["empty", "available"], - value_fn=lambda x: "empty" if x else "available", - ), ] Q7_B01_SENSOR_DESCRIPTIONS = [ diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 44fae5170883a..06ac139ae8484 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -50,6 +50,9 @@ "clean_fluid_empty": { "name": "Cleaning fluid" }, + "detergent_empty": { + "name": "Detergent empty" + }, "dirty_box_full": { "name": "Dirty water box" }, @@ -62,6 +65,9 @@ "mop_drying_status": { "name": "Mop drying" }, + "softener_empty": { + "name": "Softener empty" + }, "water_box_attached": { "name": "Water box attached" }, @@ -336,13 +342,6 @@ "current_room": { "name": "Current room" }, - "detergent_empty": { - "name": "Detergent empty", - "state": { - "available": "Available", - "empty": "Empty" - } - }, "dock_error": { "name": "Dock error", "state": { @@ -418,13 +417,6 @@ "side_brush_time_left": { "name": "Side brush time left" }, - "softener_empty": { - "name": "Softener empty", - "state": { - "available": "Available", - "empty": "[%key:common::state::empty%]" - } - }, "state": { "name": "State", "state": { diff --git a/tests/components/roborock/snapshots/test_binary_sensor.ambr b/tests/components/roborock/snapshots/test_binary_sensor.ambr index 902e9f3d34690..31f6d63cdea88 100644 --- a/tests/components/roborock/snapshots/test_binary_sensor.ambr +++ b/tests/components/roborock/snapshots/test_binary_sensor.ambr @@ -699,3 +699,103 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.zeo_one_detergent_empty-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zeo_one_detergent_empty', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Detergent empty', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Detergent empty', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'detergent_empty', + 'unique_id': 'detergent_empty_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.zeo_one_detergent_empty-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Zeo One Detergent empty', + }), + 'context': , + 'entity_id': 'binary_sensor.zeo_one_detergent_empty', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.zeo_one_softener_empty-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.zeo_one_softener_empty', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Softener empty', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Softener empty', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'softener_empty', + 'unique_id': 'softener_empty_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.zeo_one_softener_empty-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Zeo One Softener empty', + }), + 'context': , + 'entity_id': 'binary_sensor.zeo_one_softener_empty', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index 568117a63ba8f..1c81d2585fe25 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -3191,65 +3191,6 @@ 'state': '0', }) # --- -# name: test_sensors[sensor.zeo_one_detergent_empty-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'empty', - 'available', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.zeo_one_detergent_empty', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Detergent empty', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Detergent empty', - 'platform': 'roborock', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'detergent_empty', - 'unique_id': 'detergent_empty_zeo_duid', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.zeo_one_detergent_empty-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Zeo One Detergent empty', - 'options': list([ - 'empty', - 'available', - ]), - }), - 'context': , - 'entity_id': 'sensor.zeo_one_detergent_empty', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'available', - }) -# --- # name: test_sensors[sensor.zeo_one_error-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3341,65 +3282,6 @@ 'state': 'none', }) # --- -# name: test_sensors[sensor.zeo_one_softener_empty-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'empty', - 'available', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.zeo_one_softener_empty', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Softener empty', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Softener empty', - 'platform': 'roborock', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'softener_empty', - 'unique_id': 'softener_empty_zeo_duid', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.zeo_one_softener_empty-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'Zeo One Softener empty', - 'options': list([ - 'empty', - 'available', - ]), - }), - 'context': , - 'entity_id': 'sensor.zeo_one_softener_empty', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'available', - }) -# --- # name: test_sensors[sensor.zeo_one_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 0c1135478b159d4f494846030e27e67ee1e1a220 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Mon, 16 Feb 2026 23:21:46 +0000 Subject: [PATCH 18/33] move async requet out of try block --- homeassistant/components/roborock/button.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 6d6e19985adf9..65f2e1713596c 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -226,9 +226,10 @@ async def async_press(self) -> None: self.entity_description.data_protocol, 1, ) - await self.coordinator.async_request_refresh() except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="button_press_failed", ) from err + finally: + await self.coordinator.async_request_refresh() From 3d0309da50e68bd9d6b3307e18ee3620fa58a971 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Mon, 16 Feb 2026 23:41:56 +0000 Subject: [PATCH 19/33] Add ServiceValidationError for invalid select options in RoborockSelectEntityA01 --- homeassistant/components/roborock/select.py | 28 +++++++++------------ 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 1b7fcefe8a55d..a2d5cfac63246 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -31,7 +31,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MAP_SLEEP @@ -421,22 +421,19 @@ def __init__( async def async_select_option(self, option: str) -> None: """Set the option.""" - try: - # Get the protocol value for the selected option - option_values = self.entity_description.enum_class.as_dict() - if option not in option_values: - raise ValueError(f"Invalid option: {option}") - value = option_values[option] - await self.coordinator.api.set_value( # type: ignore[attr-defined] - self.entity_description.data_protocol, - value, - ) - await self.coordinator.async_request_refresh() - except RoborockException as err: - raise HomeAssistantError( + # Get the protocol value for the selected option + option_values = self.entity_description.enum_class.as_dict() + if option not in option_values: + raise ServiceValidationError( translation_domain=DOMAIN, translation_key="select_option_failed", - ) from err + ) + value = option_values[option] + await self.coordinator.api.set_value( # type: ignore[attr-defined] + self.entity_description.data_protocol, + value, + ) + await self.coordinator.async_request_refresh() @property def current_option(self) -> str | None: @@ -452,5 +449,4 @@ def current_option(self) -> str | None: current_value, self.entity_description.key, ) - # Find the option name that matches the current value return str(current_value) From 389d6908e4f74e9686d40030ddf92442671cb930 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 17 Feb 2026 00:11:15 +0000 Subject: [PATCH 20/33] Handle Roborock command failures with appropriate error messaging in RoborockSelectEntityA01 --- homeassistant/components/roborock/select.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index a2d5cfac63246..68dfb7a4e5f5e 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -429,10 +429,20 @@ async def async_select_option(self, option: str) -> None: translation_key="select_option_failed", ) value = option_values[option] - await self.coordinator.api.set_value( # type: ignore[attr-defined] - self.entity_description.data_protocol, - value, - ) + try: + await self.coordinator.api.set_value( # type: ignore[attr-defined] + self.entity_description.data_protocol, + value, + ) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": self.entity_description.key, + }, + ) from err + await self.coordinator.async_request_refresh() @property From c08ff689a67aa5a02f36970149bd079fee35c392 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 17 Feb 2026 00:21:30 +0000 Subject: [PATCH 21/33] Add tests for A01 Zeo select entities including success and failure scenarios --- tests/components/roborock/test_select.py | 109 ++++++++++++++++++++++- 1 file changed, 106 insertions(+), 3 deletions(-) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 95cc70d561257..b5b09d2666630 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -1,17 +1,27 @@ """Test Roborock Select platform.""" from typing import Any -from unittest.mock import AsyncMock, call +from unittest.mock import AsyncMock, Mock, call import pytest from roborock import CleanTypeMapping, RoborockCommand -from roborock.data import RoborockDockDustCollectionModeCode, WaterLevelMapping +from roborock.data import ( + RoborockDockDustCollectionModeCode, + WaterLevelMapping, + ZeoProgram, +) from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockZeoProtocol from homeassistant.components.roborock import DOMAIN +from homeassistant.components.roborock.select import ( + A01_SELECT_DESCRIPTIONS, + RoborockSelectEntityA01, +) from homeassistant.const import SERVICE_SELECT_OPTION, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .conftest import FakeDevice @@ -278,3 +288,96 @@ async def test_update_success_q7_cleaning_mode( assert q7_device.b01_q7_properties.set_mode.call_count == 1 q7_device.b01_q7_properties.set_mode.assert_called_with(CleanTypeMapping.VACUUM) + + +@pytest.fixture +def zeo_device(fake_devices: list[FakeDevice]) -> FakeDevice: + """Get the fake Zeo washing machine device.""" + return next(device for device in fake_devices if getattr(device, "zeo", None)) + + +async def test_update_success_zeo_program( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + zeo_device: FakeDevice, +) -> None: + """Test changing values for A01 Zeo select entities.""" + option = ZeoProgram.keys()[0] + entity_id = entity_registry.async_get_entity_id( + "select", DOMAIN, "program_zeo_duid" + ) + assert entity_id is not None + assert hass.states.get(entity_id) is not None + + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": option}, + blocking=True, + target={"entity_id": entity_id}, + ) + + assert zeo_device.zeo + zeo_device.zeo.set_value.assert_awaited_once_with( + RoborockZeoProtocol.PROGRAM, + ZeoProgram.as_dict()[option], + ) + + +async def test_current_option_zeo_program() -> None: + """Test current option retrieval for A01 Zeo select entities.""" + coordinator = Mock( + duid_slug="zeo_duid", + device_info=Mock(), + data={RoborockZeoProtocol.PROGRAM: 1}, + api=AsyncMock(), + async_request_refresh=AsyncMock(), + ) + entity = RoborockSelectEntityA01(coordinator, A01_SELECT_DESCRIPTIONS[0]) + + assert entity.current_option == "1" + coordinator.data = {} + assert entity.current_option is None + + +async def test_update_failure_zeo_program( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + zeo_device: FakeDevice, +) -> None: + """Test failure while setting an A01 Zeo select option.""" + assert zeo_device.zeo + zeo_device.zeo.set_value.side_effect = RoborockException + option = ZeoProgram.keys()[0] + entity_id = entity_registry.async_get_entity_id( + "select", DOMAIN, "program_zeo_duid" + ) + assert entity_id is not None + + with pytest.raises(HomeAssistantError, match="Error while calling program"): + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": option}, + blocking=True, + target={"entity_id": entity_id}, + ) + + +async def test_update_failure_zeo_invalid_option() -> None: + """Test invalid option handling in A01 select entity.""" + coordinator = Mock( + duid_slug="zeo_duid", + device_info=Mock(), + data={}, + api=AsyncMock(), + async_request_refresh=AsyncMock(), + ) + entity = RoborockSelectEntityA01(coordinator, A01_SELECT_DESCRIPTIONS[0]) + + with pytest.raises(ServiceValidationError): + await entity.async_select_option("invalid_option") + + coordinator.api.set_value.assert_not_called() From 31028ad20b2df62d21638616544239aba47e2722 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 17 Feb 2026 01:19:54 +0000 Subject: [PATCH 22/33] Add detergent and softener states in translations --- homeassistant/components/roborock/strings.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 06ac139ae8484..c291509031fb4 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -51,7 +51,11 @@ "name": "Cleaning fluid" }, "detergent_empty": { - "name": "Detergent empty" + "name": "Detergent", + "state": { + "off": "Available", + "on": "[%key:common::state::empty%]" + } }, "dirty_box_full": { "name": "Dirty water box" @@ -66,7 +70,11 @@ "name": "Mop drying" }, "softener_empty": { - "name": "Softener empty" + "name": "Softener", + "state": { + "off": "Available", + "on": "[%key:common::state::empty%]" + } }, "water_box_attached": { "name": "Water box attached" From 5def17bcd8818032a24693c24a684cf980a14fbe Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 17 Feb 2026 01:38:21 +0000 Subject: [PATCH 23/33] Add snapshot tests for roborock button and switch entities --- .../roborock/snapshots/test_button.ambr | 736 ++++++++++++++++++ .../roborock/snapshots/test_switch.ambr | 491 ++++++++++++ tests/components/roborock/test_button.py | 95 ++- tests/components/roborock/test_switch.py | 102 ++- 4 files changed, 1422 insertions(+), 2 deletions(-) create mode 100644 tests/components/roborock/snapshots/test_button.ambr create mode 100644 tests/components/roborock/snapshots/test_switch.ambr diff --git a/tests/components/roborock/snapshots/test_button.ambr b/tests/components/roborock/snapshots/test_button.ambr new file mode 100644 index 0000000000000..dc4ea7ca120cd --- /dev/null +++ b/tests/components/roborock/snapshots/test_button.ambr @@ -0,0 +1,736 @@ +# serializer version: 1 +# name: test_buttons[button.roborock_s7_2_reset_air_filter_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_2_reset_air_filter_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset air filter consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset air filter consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_air_filter_consumable', + 'unique_id': 'reset_air_filter_consumable_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_reset_air_filter_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Reset air filter consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_reset_air_filter_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_2_reset_main_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_2_reset_main_brush_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset main brush consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset main brush consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_main_brush_consumable', + 'unique_id': 'reset_main_brush_consumable_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_reset_main_brush_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Reset main brush consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_reset_main_brush_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_2_reset_sensor_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_2_reset_sensor_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset sensor consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset sensor consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_sensor_consumable', + 'unique_id': 'reset_sensor_consumable_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_reset_sensor_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Reset sensor consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_reset_sensor_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_2_reset_side_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_2_reset_side_brush_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset side brush consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brush consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_side_brush_consumable', + 'unique_id': 'reset_side_brush_consumable_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_reset_side_brush_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Reset side brush consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_reset_side_brush_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_2_sc1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.roborock_s7_2_sc1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'sc1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'sc1', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_sc1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 sc1', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_sc1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_2_sc2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.roborock_s7_2_sc2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'sc2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'sc2', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '24_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_2_sc2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 sc2', + }), + 'context': , + 'entity_id': 'button.roborock_s7_2_sc2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_reset_air_filter_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_maxv_reset_air_filter_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset air filter consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset air filter consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_air_filter_consumable', + 'unique_id': 'reset_air_filter_consumable_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_reset_air_filter_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Reset air filter consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_reset_air_filter_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_reset_main_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_maxv_reset_main_brush_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset main brush consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset main brush consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_main_brush_consumable', + 'unique_id': 'reset_main_brush_consumable_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_reset_main_brush_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Reset main brush consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_reset_main_brush_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_reset_sensor_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_maxv_reset_sensor_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset sensor consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset sensor consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_sensor_consumable', + 'unique_id': 'reset_sensor_consumable_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_reset_sensor_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Reset sensor consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_reset_sensor_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_reset_side_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.roborock_s7_maxv_reset_side_brush_consumable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reset side brush consumable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brush consumable', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_side_brush_consumable', + 'unique_id': 'reset_side_brush_consumable_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_reset_side_brush_consumable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Reset side brush consumable', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_reset_side_brush_consumable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_sc1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.roborock_s7_maxv_sc1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'sc1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'sc1', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_sc1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV sc1', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_sc1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_sc2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.roborock_s7_maxv_sc2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'sc2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'sc2', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '24_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.roborock_s7_maxv_sc2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV sc2', + }), + 'context': , + 'entity_id': 'button.roborock_s7_maxv_sc2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.zeo_one_pause-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.zeo_one_pause', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pause', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pause', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pause', + 'unique_id': 'pause_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.zeo_one_pause-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Zeo One Pause', + }), + 'context': , + 'entity_id': 'button.zeo_one_pause', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.zeo_one_shutdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.zeo_one_shutdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Shutdown', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Shutdown', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'shutdown', + 'unique_id': 'shutdown_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.zeo_one_shutdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Zeo One Shutdown', + }), + 'context': , + 'entity_id': 'button.zeo_one_shutdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.zeo_one_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.zeo_one_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Start', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Start', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'start_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.zeo_one_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Zeo One Start', + }), + 'context': , + 'entity_id': 'button.zeo_one_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/roborock/snapshots/test_switch.ambr b/tests/components/roborock/snapshots/test_switch.ambr new file mode 100644 index 0000000000000..39aa4f4f3cf88 --- /dev/null +++ b/tests/components/roborock/snapshots/test_switch.ambr @@ -0,0 +1,491 @@ +# serializer version: 1 +# name: test_switches[switch.dyad_pro_sound_setting-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.dyad_pro_sound_setting', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sound setting', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound setting', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_setting', + 'unique_id': 'sound_setting_dyad_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.dyad_pro_sound_setting-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dyad Pro Sound setting', + }), + 'context': , + 'entity_id': 'switch.dyad_pro_sound_setting', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_switches[switch.roborock_s7_2_do_not_disturb-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_s7_2_do_not_disturb', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Do not disturb', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Do not disturb', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dnd_switch', + 'unique_id': 'dnd_switch_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_s7_2_do_not_disturb-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Do not disturb', + }), + 'context': , + 'entity_id': 'switch.roborock_s7_2_do_not_disturb', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.roborock_s7_2_dock_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_s7_2_dock_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'child_lock_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_s7_2_dock_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Dock Child lock', + }), + 'context': , + 'entity_id': 'switch.roborock_s7_2_dock_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.roborock_s7_2_dock_status_indicator_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_s7_2_dock_status_indicator_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status indicator light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status indicator light', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status_indicator', + 'unique_id': 'status_indicator_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_s7_2_dock_status_indicator_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Dock Status indicator light', + }), + 'context': , + 'entity_id': 'switch.roborock_s7_2_dock_status_indicator_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.roborock_s7_2_off_peak_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_s7_2_off_peak_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Off-peak charging', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak charging', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'off_peak_switch', + 'unique_id': 'off_peak_switch_device_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_s7_2_off_peak_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 2 Off-peak charging', + }), + 'context': , + 'entity_id': 'switch.roborock_s7_2_off_peak_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.roborock_s7_maxv_do_not_disturb-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_s7_maxv_do_not_disturb', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Do not disturb', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Do not disturb', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dnd_switch', + 'unique_id': 'dnd_switch_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_s7_maxv_do_not_disturb-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Do not disturb', + }), + 'context': , + 'entity_id': 'switch.roborock_s7_maxv_do_not_disturb', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.roborock_s7_maxv_dock_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_s7_maxv_dock_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'child_lock_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_s7_maxv_dock_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Dock Child lock', + }), + 'context': , + 'entity_id': 'switch.roborock_s7_maxv_dock_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.roborock_s7_maxv_dock_status_indicator_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_s7_maxv_dock_status_indicator_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status indicator light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status indicator light', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status_indicator', + 'unique_id': 'status_indicator_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_s7_maxv_dock_status_indicator_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Dock Status indicator light', + }), + 'context': , + 'entity_id': 'switch.roborock_s7_maxv_dock_status_indicator_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.roborock_s7_maxv_off_peak_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.roborock_s7_maxv_off_peak_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Off-peak charging', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak charging', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'off_peak_switch', + 'unique_id': 'off_peak_switch_abc123', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_s7_maxv_off_peak_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock S7 MaxV Off-peak charging', + }), + 'context': , + 'entity_id': 'switch.roborock_s7_maxv_off_peak_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.zeo_one_sound_setting-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.zeo_one_sound_setting', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sound setting', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound setting', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_setting', + 'unique_id': 'sound_setting_zeo_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.zeo_one_sound_setting-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Zeo One Sound setting', + }), + 'context': , + 'entity_id': 'switch.zeo_one_sound_setting', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 296a8d33f1f24..287bbf4c82f99 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -5,15 +5,17 @@ import pytest from roborock import RoborockException from roborock.exceptions import RoborockTimeout +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import SERVICE_PRESS from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import FakeDevice -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture @@ -28,6 +30,17 @@ def platforms() -> list[Platform]: return [Platform.BUTTON] +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_buttons( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test buttons and check test values are correctly set.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_entry.entry_id) + + @pytest.fixture(name="consumeables_trait", autouse=True) def consumeables_trait_fixture(fake_vacuum: FakeDevice) -> Mock: """Get the fake vacuum device command trait for asserting that commands happened.""" @@ -179,3 +192,83 @@ async def test_press_routine_button_failure( routine_id ) assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id", "data_protocol"), + [ + ("button.zeo_one_start", "START"), + ("button.zeo_one_pause", "PAUSE"), + ("button.zeo_one_shutdown", "SHUTDOWN"), + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_press_a01_button_success( + hass: HomeAssistant, + bypass_api_client_fixture: None, + setup_entry: MockConfigEntry, + entity_id: str, + data_protocol: str, + fake_devices: list[FakeDevice], +) -> None: + """Test pressing A01 button entities.""" + # Get the washing machine (A01) device + washing_machine = next( + device + for device in fake_devices + if hasattr(device, "zeo") and device.zeo is not None + ) + + # Ensure entity exists + assert hass.states.get(entity_id) is not None + + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + + # Verify the set_value was called with correct protocol and value + washing_machine.zeo.set_value.assert_called_once() + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("button.zeo_one_start"), + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_press_a01_button_failure( + hass: HomeAssistant, + bypass_api_client_fixture: None, + setup_entry: MockConfigEntry, + entity_id: str, + fake_devices: list[FakeDevice], +) -> None: + """Test failure while pressing A01 button entity.""" + # Get the washing machine (A01) device + washing_machine = next( + device + for device in fake_devices + if hasattr(device, "zeo") and device.zeo is not None + ) + washing_machine.zeo.set_value.side_effect = RoborockException + + # Ensure entity exists + assert hass.states.get(entity_id) is not None + + with pytest.raises(HomeAssistantError, match="Failed to press button"): + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + + washing_machine.zeo.set_value.assert_called_once() + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index a9c458bf4f070..4dadaa8d885d7 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -5,15 +5,18 @@ import pytest import roborock +from roborock.roborock_message import RoborockZeoProtocol +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import FakeDevice -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture @@ -22,6 +25,17 @@ def platforms() -> list[Platform]: return [Platform.SWITCH] +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switches( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test switches and check test values are correctly set.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_entry.entry_id) + + @pytest.mark.parametrize( ("entity_id"), [ @@ -115,3 +129,89 @@ async def test_update_failed( ) assert len(expected_call.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("switch.zeo_one_sound_setting"), + ], +) +async def test_a01_switch_success( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, + fake_devices: list[FakeDevice], +) -> None: + """Test turning A01 switch entities on and off.""" + # Get the washing machine (A01) device + washing_machine = next( + device + for device in fake_devices + if hasattr(device, "zeo") and device.zeo is not None + ) + + # Verify entity exists + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" + + # Turn on the switch + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + # Verify set_value was called with the correct value (1 for on) + washing_machine.zeo.set_value.assert_called_with(RoborockZeoProtocol.SOUND_SET, 1) + + # Turn off the switch + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + # Verify set_value was called with the correct value (0 for off) + washing_machine.zeo.set_value.assert_called_with(RoborockZeoProtocol.SOUND_SET, 0) + + +@pytest.mark.parametrize( + ("entity_id", "service"), + [ + ("switch.zeo_one_sound_setting", SERVICE_TURN_ON), + ("switch.zeo_one_sound_setting", SERVICE_TURN_OFF), + ], +) +async def test_a01_switch_failure( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, + service: str, + fake_devices: list[FakeDevice], +) -> None: + """Test a failure while updating an A01 switch.""" + # Get the washing machine (A01) device + washing_machine = next( + device + for device in fake_devices + if hasattr(device, "zeo") and device.zeo is not None + ) + washing_machine.zeo.set_value.side_effect = roborock.exceptions.RoborockTimeout + + # Ensure that the entity exists + assert hass.states.get(entity_id) is not None + + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): + await hass.services.async_call( + "switch", + service, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + + assert len(washing_machine.zeo.set_value.mock_calls) >= 1 From 8abb99f6a62902d459a0d40f63d80830a9ad1966 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 17 Feb 2026 01:53:04 +0000 Subject: [PATCH 24/33] Update binary sensor snapshots for washing machine entities - Changed entity_id from binary_sensor.zeo_one_detergent_empty to binary_sensor.zeo_one_detergent - Changed entity_id from binary_sensor.zeo_one_softener_empty to binary_sensor.zeo_one_softener - Updated entity names from 'Detergent empty' to 'Detergent' and 'Softener empty' to 'Softener' - Friendly names updated to match new entity naming --- .../snapshots/test_binary_sensor.ambr | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/components/roborock/snapshots/test_binary_sensor.ambr b/tests/components/roborock/snapshots/test_binary_sensor.ambr index 31f6d63cdea88..a802bd43764e0 100644 --- a/tests/components/roborock/snapshots/test_binary_sensor.ambr +++ b/tests/components/roborock/snapshots/test_binary_sensor.ambr @@ -699,7 +699,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[binary_sensor.zeo_one_detergent_empty-entry] +# name: test_binary_sensors[binary_sensor.zeo_one_detergent-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -712,7 +712,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.zeo_one_detergent_empty', + 'entity_id': 'binary_sensor.zeo_one_detergent', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -720,12 +720,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Detergent empty', + 'object_id_base': 'Detergent', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Detergent empty', + 'original_name': 'Detergent', 'platform': 'roborock', 'previous_unique_id': None, 'suggested_object_id': None, @@ -735,21 +735,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[binary_sensor.zeo_one_detergent_empty-state] +# name: test_binary_sensors[binary_sensor.zeo_one_detergent-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Zeo One Detergent empty', + 'friendly_name': 'Zeo One Detergent', }), 'context': , - 'entity_id': 'binary_sensor.zeo_one_detergent_empty', + 'entity_id': 'binary_sensor.zeo_one_detergent', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_binary_sensors[binary_sensor.zeo_one_softener_empty-entry] +# name: test_binary_sensors[binary_sensor.zeo_one_softener-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -762,7 +762,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.zeo_one_softener_empty', + 'entity_id': 'binary_sensor.zeo_one_softener', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -770,12 +770,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Softener empty', + 'object_id_base': 'Softener', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Softener empty', + 'original_name': 'Softener', 'platform': 'roborock', 'previous_unique_id': None, 'suggested_object_id': None, @@ -785,14 +785,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[binary_sensor.zeo_one_softener_empty-state] +# name: test_binary_sensors[binary_sensor.zeo_one_softener-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Zeo One Softener empty', + 'friendly_name': 'Zeo One Softener', }), 'context': , - 'entity_id': 'binary_sensor.zeo_one_softener_empty', + 'entity_id': 'binary_sensor.zeo_one_softener', 'last_changed': , 'last_reported': , 'last_updated': , From 02e5e9c18739845682ab6885f20f348fdbd1ee1a Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 17 Feb 2026 02:12:14 +0000 Subject: [PATCH 25/33] Add test coverage for A01 select entity None value handling --- tests/components/roborock/test_select.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index b5b09d2666630..31e77ee29c8af 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -339,6 +339,8 @@ async def test_current_option_zeo_program() -> None: assert entity.current_option == "1" coordinator.data = {} assert entity.current_option is None + coordinator.data = {RoborockZeoProtocol.PROGRAM: None} + assert entity.current_option is None async def test_update_failure_zeo_program( From 2ac66e2937b6fa4532f7f2297a586425f76d0a1f Mon Sep 17 00:00:00 2001 From: Yangqian Yan <5144644+yangqian@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:21:28 +0800 Subject: [PATCH 26/33] Update homeassistant/components/roborock/strings.json Co-authored-by: Norbert Rittel --- homeassistant/components/roborock/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index c291509031fb4..031bdab4d5a9d 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -246,7 +246,7 @@ "softener_type": { "name": "Softener type", "state": { - "empty": "Empty", + "empty": "[%key:common::state::empty%]", "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", "medium": "[%key:common::state::medium%]" From 66712382126c9ea8198a73947fb4dab93183aaf3 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 24 Feb 2026 10:37:22 +0800 Subject: [PATCH 27/33] Remove unused RoborockDataUpdateCoordinatorB01 import from sensor.py --- homeassistant/components/roborock/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 3990fa6f6fe93..6f8158151f5c4 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -37,7 +37,6 @@ RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, - RoborockDataUpdateCoordinatorB01, RoborockWashingMachineUpdateCoordinator, RoborockWetDryVacUpdateCoordinator, ) From da05a60362d1ae8d0ea44b2a46383da4e595b650 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 24 Feb 2026 04:55:46 +0000 Subject: [PATCH 28/33] Filter A01 select and switch entities by supported data protocols --- homeassistant/components/roborock/select.py | 1 + homeassistant/components/roborock/switch.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index f8d8a5323b06f..0ff27d8145f94 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -264,6 +264,7 @@ async def async_setup_entry( RoborockSelectEntityA01(coordinator, description) for coordinator in config_entry.runtime_data.a01 for description in A01_SELECT_DESCRIPTIONS + if description.data_protocol in coordinator.request_protocols ) diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 66e5aca8b599b..27f901740ec44 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -118,6 +118,7 @@ async def async_setup_entry( ) for coordinator in config_entry.runtime_data.a01 for description in A01_SWITCH_DESCRIPTIONS + if description.data_protocol in coordinator.request_protocols ) From 52ef3eeeaae61c96c7c0203eca0c3cf52aa7c4d1 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 24 Feb 2026 05:39:06 +0000 Subject: [PATCH 29/33] Remove unused Dyad Pro Sound setting snapshots from test_switch.ambr --- .../roborock/snapshots/test_switch.ambr | 49 ------------------- 1 file changed, 49 deletions(-) diff --git a/tests/components/roborock/snapshots/test_switch.ambr b/tests/components/roborock/snapshots/test_switch.ambr index 39aa4f4f3cf88..5dd492540326d 100644 --- a/tests/components/roborock/snapshots/test_switch.ambr +++ b/tests/components/roborock/snapshots/test_switch.ambr @@ -1,53 +1,4 @@ # serializer version: 1 -# name: test_switches[switch.dyad_pro_sound_setting-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.dyad_pro_sound_setting', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Sound setting', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Sound setting', - 'platform': 'roborock', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sound_setting', - 'unique_id': 'sound_setting_dyad_duid', - 'unit_of_measurement': None, - }) -# --- -# name: test_switches[switch.dyad_pro_sound_setting-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dyad Pro Sound setting', - }), - 'context': , - 'entity_id': 'switch.dyad_pro_sound_setting', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_switches[switch.roborock_s7_2_do_not_disturb-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 0c1157318021d3c844f79a74f4d6ec4442198e08 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Tue, 24 Feb 2026 15:56:36 +0000 Subject: [PATCH 30/33] Remove unused error states and add aftercare status in Roborock strings --- .../components/roborock/strings.json | 40 +------------------ 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 74c1f2be1848f..51e64d14f80e2 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -365,29 +365,6 @@ "water_empty": "Water empty" } }, - "error": { - "name": "Error", - "state": { - "communication_error": "Communication error", - "door_lock_error": "Door lock error", - "drain_error": "Drain error", - "drying_error": "Drying error", - "drying_error_e_12": "Drying error E12", - "drying_error_e_13": "Drying error E13", - "drying_error_e_14": "Drying error E14", - "drying_error_e_15": "Drying error E15", - "drying_error_e_16": "Drying error E16", - "drying_error_restart": "Restart the washer", - "drying_error_water_flow": "Check water flow", - "heating_error": "Heating error", - "inverter_error": "Inverter error", - "none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]", - "refill_error": "Refill error", - "spin_error": "Re-arrange clothes", - "temperature_error": "Temperature error", - "water_level_error": "Water level error" - } - }, "filter_time_left": { "name": "Filter time left" }, @@ -428,21 +405,6 @@ "side_brush_time_left": { "name": "Side brush time left" }, - "state": { - "name": "State", - "state": { - "cooling": "Cooling", - "done": "Done", - "drying": "Drying", - "rinsing": "Rinsing", - "soaking": "Soaking", - "spinning": "Spinning", - "standby": "[%key:common::state::standby%]", - "under_delay_start": "Delayed start", - "washing": "Washing", - "weighing": "Weighing" - } - }, "status": { "name": "Status", "state": { @@ -572,6 +534,7 @@ "zeo_state": { "name": "State", "state": { + "aftercare": "Aftercare", "cooling": "Cooling", "done": "Done", "drying": "Drying", @@ -580,6 +543,7 @@ "spinning": "Spinning", "standby": "[%key:common::state::standby%]", "under_delay_start": "Delayed start", + "waiting_for_aftercare": "Waiting for aftercare", "washing": "Washing", "weighing": "Weighing" } From 2df26e9168d7670d93db30863e67339a1e8abc64 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Wed, 25 Feb 2026 00:49:39 +0000 Subject: [PATCH 31/33] Add edge test for A01 switch handling unknown state when API omits protocol key --- tests/components/roborock/test_switch.py | 42 +++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 4dadaa8d885d7..794e70a7d46b8 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -1,6 +1,7 @@ """Test Roborock Switch platform.""" from collections.abc import Callable +from datetime import timedelta from typing import Any import pytest @@ -13,10 +14,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .conftest import FakeDevice -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture @@ -215,3 +217,41 @@ async def test_a01_switch_failure( ) assert len(washing_machine.zeo.set_value.mock_calls) >= 1 + + +async def test_a01_switch_unknown_state( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_devices: list[FakeDevice], +) -> None: + """Test A01 switch returns unknown when API omits the protocol key.""" + entity_id = "switch.zeo_one_sound_setting" + + # Verify entity exists with a known state initially + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" + + # Simulate the API returning data without the SOUND_SET key + washing_machine = next( + device + for device in fake_devices + if hasattr(device, "zeo") and device.zeo is not None + ) + incomplete_data = { + k: v + for k, v in washing_machine.zeo.query_values.return_value.items() + if k != RoborockZeoProtocol.SOUND_SET + } + washing_machine.zeo.query_values.return_value = incomplete_data + + # Trigger a coordinator refresh + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=61), + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "unknown" From 87a88c0341c484549862e973816d3346680d3d92 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Wed, 25 Feb 2026 00:55:09 +0000 Subject: [PATCH 32/33] Update drying error messages for clarity in Roborock integration --- homeassistant/components/roborock/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index f4e82717950d3..39eeed3e0f8a5 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -514,14 +514,14 @@ "communication_error": "Communication error", "door_lock_error": "Door lock error", "drain_error": "Drain error", - "drying_error": "Drying error", - "drying_error_e_12": "Drying error E12", + "drying_error": "Drying error: check air inlet temperature sensor", + "drying_error_e_12": "Drying error: check air outlet temperature sensor", "drying_error_e_13": "Drying error E13", - "drying_error_e_14": "Drying error E14", - "drying_error_e_15": "Drying error E15", - "drying_error_e_16": "Drying error E16", - "drying_error_restart": "Restart the washer", - "drying_error_water_flow": "Check water flow", + "drying_error_e_14": "Drying error: check inlet condenser temperature sensor", + "drying_error_e_15": "Drying error: check heating element or turntable", + "drying_error_e_16": "Drying error: check drying fan", + "drying_error_restart": "Drying error: restart the washer", + "drying_error_water_flow": "Drying error: check water flow", "heating_error": "Heating error", "inverter_error": "Inverter error", "none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]", From 2cf37087a4936b9754ebfedbf7a66945b994dea4 Mon Sep 17 00:00:00 2001 From: Yangqian Date: Wed, 25 Feb 2026 01:36:14 +0000 Subject: [PATCH 33/33] Remove unused value_fn from RoborockSensorDescriptionA01 --- homeassistant/components/roborock/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 6f8158151f5c4..bb0240a78da14 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -68,7 +68,6 @@ class RoborockSensorDescriptionA01(SensorEntityDescription): """A class that describes Roborock sensors.""" data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol - value_fn: Callable[[StateType], StateType] | None = None @dataclass(frozen=True, kw_only=True) @@ -535,10 +534,7 @@ def __init__( @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - value = self.coordinator.data[self.entity_description.data_protocol] - if self.entity_description.value_fn is not None: - return self.entity_description.value_fn(value) - return value + return self.coordinator.data[self.entity_description.data_protocol] class RoborockSensorEntityB01Q7(RoborockCoordinatedEntityB01Q7, SensorEntity):