Add Full support for roborock Zeo washing/drying machines (#159575)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Yangqian Yan
2026-02-24 23:17:56 -08:00
committed by GitHub
co-authored by Norbert Rittel Joost Lekkerkerker
parent 644c74f311
commit 2f80720730
15 changed files with 2267 additions and 25 deletions
@@ -6,6 +6,7 @@ from collections.abc import Callable
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.components.binary_sensor import (
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 @@ BINARY_SENSOR_DESCRIPTIONS = [
]
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 @@ class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity
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)
+75 -2
View File
@@ -10,6 +10,7 @@ from typing import Any
from roborock.devices.traits.v1.consumeable import ConsumableAttribute
from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockZeoProtocol
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
@@ -18,8 +19,13 @@ from homeassistant.exceptions import HomeAssistantError
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,
RoborockWashingMachineUpdateCoordinator,
)
from .entity import RoborockCoordinatedEntityA01, RoborockEntity, RoborockEntityV1
_LOGGER = logging.getLogger(__name__)
@@ -65,6 +71,32 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [
]
@dataclass(frozen=True, kw_only=True)
class RoborockButtonDescriptionA01(ButtonEntityDescription):
"""Describes a Roborock A01 button entity."""
data_protocol: RoborockZeoProtocol
ZEO_BUTTON_DESCRIPTIONS = [
RoborockButtonDescriptionA01(
key="start",
data_protocol=RoborockZeoProtocol.START,
translation_key="start",
),
RoborockButtonDescriptionA01(
key="pause",
data_protocol=RoborockZeoProtocol.PAUSE,
translation_key="pause",
),
RoborockButtonDescriptionA01(
key="shutdown",
data_protocol=RoborockZeoProtocol.SHUTDOWN,
translation_key="shutdown",
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
@@ -98,6 +130,15 @@ async def async_setup_entry(
)
for routine in routines
),
(
RoborockButtonEntityA01(
coordinator,
description,
)
for coordinator in config_entry.runtime_data.a01
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
for description in ZEO_BUTTON_DESCRIPTIONS
),
)
)
@@ -160,3 +201,35 @@ class RoborockRoutineButtonEntity(RoborockEntity, ButtonEntity):
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:
await self.coordinator.api.set_value( # type: ignore[attr-defined]
self.entity_description.data_protocol,
1,
)
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="button_press_failed",
) from err
finally:
await self.coordinator.async_request_refresh()
@@ -432,6 +432,18 @@ class RoborockWashingMachineUpdateCoordinator(
RoborockZeoProtocol.COUNTDOWN,
RoborockZeoProtocol.WASHING_LEFT,
RoborockZeoProtocol.ERROR,
RoborockZeoProtocol.TIMES_AFTER_CLEAN,
RoborockZeoProtocol.DETERGENT_EMPTY,
RoborockZeoProtocol.SOFTENER_EMPTY,
RoborockZeoProtocol.DETERGENT_TYPE,
RoborockZeoProtocol.SOFTENER_TYPE,
RoborockZeoProtocol.MODE,
RoborockZeoProtocol.PROGRAM,
RoborockZeoProtocol.TEMP,
RoborockZeoProtocol.RINSE_TIMES,
RoborockZeoProtocol.SPIN_LEVEL,
RoborockZeoProtocol.DRYING_MODE,
RoborockZeoProtocol.SOUND_SET,
]
async def _async_update_data(
+161 -3
View File
@@ -3,21 +3,35 @@
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from typing import Any
from roborock import B01Props, CleanTypeMapping
from roborock.data import RoborockDockDustCollectionModeCode, WaterLevelMapping
from roborock.data import (
RoborockDockDustCollectionModeCode,
RoborockEnum,
WaterLevelMapping,
ZeoDetergentType,
ZeoDryingMode,
ZeoMode,
ZeoProgram,
ZeoRinse,
ZeoSoftenerType,
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_message import RoborockZeoProtocol
from roborock.roborock_typing import RoborockCommand
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
@@ -25,11 +39,18 @@ from .coordinator import (
RoborockB01Q7UpdateCoordinator,
RoborockConfigEntry,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
)
from .entity import (
RoborockCoordinatedEntityA01,
RoborockCoordinatedEntityB01Q7,
RoborockCoordinatedEntityV1,
)
from .entity import RoborockCoordinatedEntityB01Q7, RoborockCoordinatedEntityV1
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class RoborockSelectDescription(SelectEntityDescription):
@@ -65,6 +86,16 @@ 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
# Enum class for the select entity
enum_class: type[RoborockEnum]
B01_SELECT_DESCRIPTIONS: list[RoborockB01SelectDescription] = [
RoborockB01SelectDescription(
key="water_flow",
@@ -139,6 +170,66 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [
]
A01_SELECT_DESCRIPTIONS: list[RoborockSelectDescriptionA01] = [
RoborockSelectDescriptionA01(
key="program",
data_protocol=RoborockZeoProtocol.PROGRAM,
translation_key="program",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoProgram,
),
RoborockSelectDescriptionA01(
key="mode",
data_protocol=RoborockZeoProtocol.MODE,
translation_key="mode",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoMode,
),
RoborockSelectDescriptionA01(
key="temperature",
data_protocol=RoborockZeoProtocol.TEMP,
translation_key="temperature",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoTemperature,
),
RoborockSelectDescriptionA01(
key="drying_mode",
data_protocol=RoborockZeoProtocol.DRYING_MODE,
translation_key="drying_mode",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoDryingMode,
),
RoborockSelectDescriptionA01(
key="spin_level",
data_protocol=RoborockZeoProtocol.SPIN_LEVEL,
translation_key="spin_level",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoSpin,
),
RoborockSelectDescriptionA01(
key="rinse_times",
data_protocol=RoborockZeoProtocol.RINSE_TIMES,
translation_key="rinse_times",
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,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
@@ -169,6 +260,12 @@ async def async_setup_entry(
for description in B01_SELECT_DESCRIPTIONS
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
if description.data_protocol in coordinator.request_protocols
)
class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity):
@@ -308,3 +405,64 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
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 = list(entity_description.enum_class.keys())
async def async_select_option(self, option: str) -> None:
"""Set the option."""
# 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",
)
value = option_values[option]
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
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(
"current_value: %s for %s",
current_value,
self.entity_description.key,
)
return str(current_value)
+24 -2
View File
@@ -37,6 +37,8 @@ from .coordinator import (
RoborockConfigEntry,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
RoborockWashingMachineUpdateCoordinator,
RoborockWetDryVacUpdateCoordinator,
)
from .entity import (
RoborockCoordinatedEntityA01,
@@ -252,7 +254,7 @@ SENSOR_DESCRIPTIONS = [
),
]
A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
DYAD_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
RoborockSensorDescriptionA01(
key="status",
data_protocol=RoborockDyadDataProtocol.STATUS,
@@ -303,6 +305,9 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
translation_key="total_cleaning_time",
entity_category=EntityCategory.DIAGNOSTIC,
),
]
ZEO_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
RoborockSensorDescriptionA01(
key="state",
data_protocol=RoborockZeoProtocol.STATE,
@@ -335,6 +340,12 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
entity_category=EntityCategory.DIAGNOSTIC,
options=ZeoError.keys(),
),
RoborockSensorDescriptionA01(
key="times_after_clean",
data_protocol=RoborockZeoProtocol.TIMES_AFTER_CLEAN,
translation_key="times_after_clean",
entity_category=EntityCategory.DIAGNOSTIC,
),
]
Q7_B01_SENSOR_DESCRIPTIONS = [
@@ -418,7 +429,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(
+158 -8
View File
@@ -50,6 +50,13 @@
"clean_fluid_empty": {
"name": "Cleaning fluid"
},
"detergent_empty": {
"name": "Detergent",
"state": {
"off": "Available",
"on": "[%key:common::state::empty%]"
}
},
"dirty_box_full": {
"name": "Dirty water box"
},
@@ -62,6 +69,13 @@
"mop_drying_status": {
"name": "Mop drying"
},
"softener_empty": {
"name": "Softener",
"state": {
"off": "Available",
"on": "[%key:common::state::empty%]"
}
},
"water_box_attached": {
"name": "Water box attached"
},
@@ -70,6 +84,9 @@
}
},
"button": {
"pause": {
"name": "Pause"
},
"reset_air_filter_consumable": {
"name": "Reset air filter consumable"
},
@@ -81,6 +98,12 @@
},
"reset_side_brush_consumable": {
"name": "Reset side brush consumable"
},
"shutdown": {
"name": "Shutdown"
},
"start": {
"name": "Start"
}
},
"number": {
@@ -97,6 +120,25 @@
"vacuum": "Vacuum only"
}
},
"detergent_type": {
"name": "Detergent type",
"state": {
"empty": "[%key:common::state::empty%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]"
}
},
"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": {
@@ -106,6 +148,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": {
@@ -138,9 +193,90 @@
"standard": "Standard"
}
},
"program": {
"name": "Wash program",
"state": {
"air_refresh": "Air refresh",
"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",
"down_clean": "Down clean",
"exo_40_60": "Exo 40/60",
"gentle": "Gentle",
"intensive": "Intensive",
"new_clothes": "New clothes",
"night": "Night",
"panties": "Panties",
"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",
"twenty_c": "20°C",
"underwear": "Underwear",
"warming": "Warming",
"wool": "Wool"
}
},
"rinse_times": {
"name": "Rinse times",
"state": {
"high": "4",
"low": "2",
"max": "5",
"mid": "3",
"min": "1",
"none": "Default"
}
},
"selected_map": {
"name": "Selected map"
},
"softener_type": {
"name": "Softener type",
"state": {
"empty": "[%key:common::state::empty%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]"
}
},
"spin_level": {
"name": "Spin level",
"state": {
"high": "1000 RPM",
"max": "1400 RPM",
"mid": "800 RPM",
"none": "Default",
"very_high": "1200 RPM",
"very_low": "600 RPM"
}
},
"temperature": {
"name": "Water temperature",
"state": {
"30": "30°C",
"40": "40°C",
"60": "60°C",
"90": "90°C",
"auto": "[%key:common::state::auto%]",
"cold": "Cold"
}
},
"water_flow": {
"name": "Water flow",
"state": {
@@ -307,6 +443,9 @@
"strainer_time_left": {
"name": "Strainer time left"
},
"times_after_clean": {
"name": "Times after clean"
},
"total_cleaning_area": {
"name": "Total cleaning area"
},
@@ -375,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%]",
@@ -420,6 +559,9 @@
"off_peak_switch": {
"name": "Off-peak charging"
},
"sound_setting": {
"name": "Sound setting"
},
"status_indicator": {
"name": "Status indicator light"
}
@@ -464,6 +606,9 @@
}
},
"exceptions": {
"button_press_failed": {
"message": "Failed to press button"
},
"command_failed": {
"message": "Error while calling {command}"
},
@@ -491,6 +636,12 @@
"position_not_found": {
"message": "Robot position not found"
},
"segment_id_parse_error": {
"message": "Invalid segment ID format: {segment_id}"
},
"select_option_failed": {
"message": "Failed to set selected option"
},
"update_data_fail": {
"message": "Failed to update data"
},
@@ -504,7 +655,6 @@
"title": "Cloud API used"
}
},
"options": {
"step": {
"drawables": {
+85 -2
View File
@@ -10,6 +10,7 @@ from typing import Any
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,12 @@ from homeassistant.exceptions import HomeAssistantError
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 +72,30 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [
]
@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="sound_setting",
entity_category=EntityCategory.CONFIG,
),
]
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(
@@ -87,6 +110,17 @@ async def async_setup_entry(
]
)
# 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.request_protocols
)
class RoborockSwitch(RoborockEntityV1, SwitchEntity):
"""A class to let you turn functionality on Roborock devices on and off that does need a coordinator."""
@@ -137,3 +171,52 @@ class RoborockSwitch(RoborockEntityV1, SwitchEntity):
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( # type: ignore[attr-defined]
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( # type: ignore[attr-defined]
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)
+12
View File
@@ -111,6 +111,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
@@ -699,3 +699,103 @@
'state': 'off',
})
# ---
# name: test_binary_sensors[binary_sensor.zeo_one_detergent-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.zeo_one_detergent',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Detergent',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Detergent',
'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-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Zeo One Detergent',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.zeo_one_detergent',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[binary_sensor.zeo_one_softener-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.zeo_one_softener',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Softener',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Softener',
'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-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Zeo One Softener',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.zeo_one_softener',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.roborock_s7_2_reset_air_filter_consumable',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_2_reset_air_filter_consumable',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.roborock_s7_2_reset_main_brush_consumable',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_2_reset_main_brush_consumable',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_2_reset_sensor_consumable-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.roborock_s7_2_reset_sensor_consumable',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_2_reset_sensor_consumable',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.roborock_s7_2_reset_side_brush_consumable',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_2_reset_side_brush_consumable',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_2_sc1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_2_sc1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_2_sc2-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_2_sc2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.roborock_s7_maxv_reset_air_filter_consumable',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_maxv_reset_air_filter_consumable',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.roborock_s7_maxv_reset_main_brush_consumable',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_maxv_reset_main_brush_consumable',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_maxv_reset_sensor_consumable-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.roborock_s7_maxv_reset_sensor_consumable',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_maxv_reset_sensor_consumable',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.roborock_s7_maxv_reset_side_brush_consumable',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_maxv_reset_side_brush_consumable',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_maxv_sc1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_maxv_sc1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_maxv_sc2-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'button.roborock_s7_maxv_sc2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.zeo_one_pause-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.zeo_one_pause',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.zeo_one_pause',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.zeo_one_shutdown-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.zeo_one_shutdown',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.zeo_one_shutdown',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.zeo_one_start-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.zeo_one_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.zeo_one_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
@@ -3361,6 +3361,55 @@
'state': 'drying',
})
# ---
# name: test_sensors[sensor.zeo_one_times_after_clean-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.zeo_one_times_after_clean',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.zeo_one_times_after_clean',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5',
})
# ---
# name: test_sensors[sensor.zeo_one_washing_left-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -0,0 +1,442 @@
# serializer version: 1
# name: test_switches[switch.roborock_s7_2_do_not_disturb-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.roborock_s7_2_do_not_disturb',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.roborock_s7_2_do_not_disturb',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[switch.roborock_s7_2_dock_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.roborock_s7_2_dock_child_lock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.roborock_s7_2_dock_child_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.roborock_s7_2_dock_status_indicator_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.roborock_s7_2_dock_status_indicator_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[switch.roborock_s7_2_off_peak_charging-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.roborock_s7_2_off_peak_charging',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.roborock_s7_2_off_peak_charging',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[switch.roborock_s7_maxv_do_not_disturb-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.roborock_s7_maxv_do_not_disturb',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.roborock_s7_maxv_do_not_disturb',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[switch.roborock_s7_maxv_dock_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.roborock_s7_maxv_dock_child_lock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.roborock_s7_maxv_dock_child_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.roborock_s7_maxv_dock_status_indicator_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.roborock_s7_maxv_dock_status_indicator_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[switch.roborock_s7_maxv_off_peak_charging-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.roborock_s7_maxv_off_peak_charging',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.roborock_s7_maxv_off_peak_charging',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[switch.zeo_one_sound_setting-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.zeo_one_sound_setting',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.zeo_one_sound_setting',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+94 -1
View File
@@ -5,15 +5,17 @@ from unittest.mock import Mock
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"
+108 -3
View File
@@ -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,98 @@ 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
coordinator.data = {RoborockZeoProtocol.PROGRAM: None}
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()
+141 -1
View File
@@ -1,19 +1,24 @@
"""Test Roborock Switch platform."""
from collections.abc import Callable
from datetime import timedelta
from typing import Any
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 homeassistant.util import dt as dt_util
from .conftest import FakeDevice
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.fixture
@@ -22,6 +27,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 +131,127 @@ 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
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"