Fix Roborock dock cleaning brush entity support (#180709)

This commit is contained in:
Allen Porter
2026-08-30 08:07:01 +02:00
committed by GitHub
parent 58076228ec
commit d7208d2b17
6 changed files with 192 additions and 231 deletions
+18 -8
View File
@@ -10,9 +10,10 @@ from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockZeoProtocol
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -94,7 +95,7 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [
entity_registry_enabled_default=False,
is_dock_entity=True,
is_supported=lambda coordinator: (
coordinator.properties_api.wash_towel_mode is not None
coordinator.properties_api.device_features.dock_features.is_cleaning_brush_supported
),
),
]
@@ -141,6 +142,7 @@ async def async_setup_entry(
) -> None:
"""Set up Roborock button platform."""
coordinators = config_entry.runtime_data
entity_registry = er.async_get(hass)
@callback
def async_add_coordinator_entities(
@@ -149,11 +151,18 @@ async def async_setup_entry(
"""Add entities for a specific coordinator."""
entities: list[ButtonEntity] = []
if isinstance(coordinator, RoborockDataUpdateCoordinator):
entities.extend(
RoborockButtonEntity(coordinator, description)
for description in CONSUMABLE_BUTTON_DESCRIPTIONS
if description.is_supported(coordinator)
)
for description in CONSUMABLE_BUTTON_DESCRIPTIONS:
unique_id = f"{description.key}_{coordinator.duid_slug}"
if description.is_supported(coordinator):
entities.append(
RoborockButtonEntity(unique_id, coordinator, description)
)
elif entity_id := entity_registry.async_get_entity_id(
Platform.BUTTON,
DOMAIN,
unique_id,
):
entity_registry.async_remove(entity_id)
async def async_add_routine_buttons() -> None:
try:
@@ -213,6 +222,7 @@ class RoborockButtonEntity(RoborockEntityV1, ButtonEntity):
def __init__(
self,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator,
entity_description: RoborockButtonDescription,
) -> None:
@@ -223,7 +233,7 @@ class RoborockButtonEntity(RoborockEntityV1, ButtonEntity):
else coordinator.device_info
)
super().__init__(
f"{entity_description.key}_{coordinator.duid_slug}",
unique_id,
device_info,
api=coordinator.properties_api.command,
)
+27 -8
View File
@@ -30,12 +30,20 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
Platform,
UnitOfArea,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .coordinator import (
RoborockB01Q7UpdateCoordinator,
RoborockB01Q10UpdateCoordinator,
@@ -139,7 +147,9 @@ SENSOR_DESCRIPTIONS = [
value_fn=lambda data: data.consumable.cleaning_brush_time_left,
entity_category=EntityCategory.DIAGNOSTIC,
is_dock_entity=True,
support_fn=lambda api: api.wash_towel_mode is not None,
support_fn=lambda api: (
api.device_features.dock_features.is_cleaning_brush_supported
),
),
RoborockSensorDescription(
native_unit_of_measurement=UnitOfTime.HOURS,
@@ -541,6 +551,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Roborock vacuum sensors."""
coordinators = config_entry.runtime_data
entity_registry = er.async_get(hass)
@callback
def async_add_coordinator_entities(
@@ -549,11 +560,18 @@ async def async_setup_entry(
"""Add entities for a specific coordinator."""
entities: list[RoborockEntity] = []
if isinstance(coordinator, RoborockDataUpdateCoordinator):
entities.extend(
RoborockSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS
if description.support_fn(coordinator.properties_api)
)
for description in SENSOR_DESCRIPTIONS:
unique_id = f"{description.key}_{coordinator.duid_slug}"
if description.support_fn(coordinator.properties_api):
entities.append(
RoborockSensorEntity(unique_id, coordinator, description)
)
elif entity_id := entity_registry.async_get_entity_id(
Platform.SENSOR,
DOMAIN,
unique_id,
):
entity_registry.async_remove(entity_id)
entities.append(RoborockCurrentRoom(coordinator))
elif isinstance(coordinator, RoborockWetDryVacUpdateCoordinator):
entities.extend(
@@ -598,13 +616,14 @@ class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity):
def __init__(
self,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator,
description: RoborockSensorDescription,
) -> None:
"""Initialize the entity."""
self.entity_description = description
super().__init__(
f"{description.key}_{coordinator.duid_slug}",
unique_id,
coordinator,
is_dock_entity=description.is_dock_entity,
)
@@ -49,56 +49,6 @@
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_2_dock_reset_cleaning_brush_consumable-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'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_dock_reset_cleaning_brush_consumable',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Reset cleaning brush consumable',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Reset cleaning brush consumable',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'reset_dock_cleaning_brush_consumable',
'unique_id': 'reset_dock_cleaning_brush_consumable_device_2',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[button.roborock_s7_2_dock_reset_cleaning_brush_consumable-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Roborock S7 2 Dock Reset cleaning brush consumable',
}),
'context': <ANY>,
'entity_id': 'button.roborock_s7_2_dock_reset_cleaning_brush_consumable',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_2_dock_reset_strainer_consumable-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -449,56 +399,6 @@
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'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_dock_reset_cleaning_brush_consumable',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Reset cleaning brush consumable',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Reset cleaning brush consumable',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'reset_dock_cleaning_brush_consumable',
'unique_id': 'reset_dock_cleaning_brush_consumable_abc123',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Roborock S7 MaxV Dock Reset cleaning brush consumable',
}),
'context': <ANY>,
'entity_id': 'button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_maxv_dock_reset_strainer_consumable-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -1875,61 +1875,6 @@
'state': 'ok',
})
# ---
# name: test_sensors[sensor.roborock_s7_2_dock_maintenance_brush_time_left-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'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.roborock_s7_2_dock_maintenance_brush_time_left',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Maintenance brush time left',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Maintenance brush time left',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cleaning_brush_time_left',
'unique_id': 'cleaning_brush_time_left_device_2',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
})
# ---
# name: test_sensors[sensor.roborock_s7_2_dock_maintenance_brush_time_left-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Roborock S7 2 Dock Maintenance brush time left',
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.HOURS: 'h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.roborock_s7_2_dock_maintenance_brush_time_left',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '235',
})
# ---
# name: test_sensors[sensor.roborock_s7_2_dock_mop_drying_remaining_time-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -3191,61 +3136,6 @@
'state': 'ok',
})
# ---
# name: test_sensors[sensor.roborock_s7_maxv_dock_maintenance_brush_time_left-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'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.roborock_s7_maxv_dock_maintenance_brush_time_left',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Maintenance brush time left',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Maintenance brush time left',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cleaning_brush_time_left',
'unique_id': 'cleaning_brush_time_left_abc123',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
})
# ---
# name: test_sensors[sensor.roborock_s7_maxv_dock_maintenance_brush_time_left-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Roborock S7 MaxV Dock Maintenance brush time left',
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.HOURS: 'h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.roborock_s7_maxv_dock_maintenance_brush_time_left',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '235',
})
# ---
# name: test_sensors[sensor.roborock_s7_maxv_dock_mop_drying_remaining_time-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+81 -5
View File
@@ -4,6 +4,8 @@ from unittest.mock import Mock
import pytest
from roborock import RoborockException
from roborock.data.v1 import RoborockDockTypeCode
from roborock.device_features import RoborockDockFeatures
from roborock.devices.traits.v1.consumeable import ConsumableAttribute
from roborock.exceptions import RoborockTimeout
from syrupy.assertion import SnapshotAssertion
@@ -45,8 +47,11 @@ async def test_buttons(
@pytest.fixture
def non_wash_n_fill_dock(fake_vacuum: FakeDevice) -> None:
"""Disable wash towel mode to indicate this device has no wash functions."""
"""Disable wash towel mode and cleaning brush to indicate this device has no wash functions."""
fake_vacuum.v1_properties.wash_towel_mode = None
fake_vacuum.v1_properties.device_features.dock_features = (
RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o1_dock)
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@@ -109,10 +114,6 @@ def consumeables_trait_fixture(fake_vacuum: FakeDevice) -> Mock:
"button.roborock_s7_maxv_dock_reset_strainer_consumable",
ConsumableAttribute.STRAINER_WORK_TIME,
),
(
"button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable",
ConsumableAttribute.CLEANING_BRUSH_WORK_TIME,
),
],
)
@pytest.mark.freeze_time("2023-10-30 08:50:00")
@@ -382,4 +383,79 @@ async def test_press_q10_empty_dustbin_button_failure(
)
fake_q10_vacuum.b01_q10_properties.vacuum.empty_dustbin.assert_called_once()
async def test_dock_cleaning_brush_button_not_created_and_cleaned_up(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_roborock_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
) -> None:
"""Test cleaning brush button is not created and removed if it was in the registry."""
fake_vacuum.v1_properties.device_features.dock_features = (
RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.pearl_dock)
)
entity_registry.async_get_or_create(
domain=Platform.BUTTON,
platform=DOMAIN,
unique_id="reset_dock_cleaning_brush_consumable_abc123",
config_entry=mock_roborock_entry,
)
assert (
entity_registry.async_get_entity_id(
Platform.BUTTON, DOMAIN, "reset_dock_cleaning_brush_consumable_abc123"
)
is not None
)
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
await hass.async_block_till_done()
# Cleaning brush button must be removed from the entity registry
assert (
entity_registry.async_get_entity_id(
Platform.BUTTON, DOMAIN, "reset_dock_cleaning_brush_consumable_abc123"
)
is None
)
assert (
hass.states.get("button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable")
is None
)
# Washable dock strainer button must still exist
assert (
entity_registry.async_get_entity_id(
Platform.BUTTON, DOMAIN, "reset_dock_strainer_consumable_abc123"
)
is not None
)
@pytest.mark.freeze_time("2023-10-30 08:50:00")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_dock_cleaning_brush_button_press(
hass: HomeAssistant,
bypass_api_client_fixture: None,
mock_roborock_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
consumeables_trait: Mock,
) -> None:
"""Test pressing the cleaning brush button on a dock that supports it."""
fake_vacuum.v1_properties.device_features.dock_features = (
RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o3_plus_dock)
)
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
await hass.async_block_till_done()
entity_id = "button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable"
assert hass.states.get(entity_id).state == "unknown"
await hass.services.async_call(
"button",
SERVICE_PRESS,
blocking=True,
target={"entity_id": entity_id},
)
consumeables_trait.reset_consumable.assert_called_once_with(
ConsumableAttribute.CLEANING_BRUSH_WORK_TIME
)
assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00"
+66
View File
@@ -3,9 +3,12 @@
from typing import Any
import pytest
from roborock.data.v1 import RoborockDockTypeCode
from roborock.device_features import RoborockDockFeatures
from roborock.exceptions import RoborockException
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.roborock.const import DOMAIN
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -90,3 +93,66 @@ async def test_sensors_coordinator_state(
state = hass.states.get("sensor.roborock_q10_s5_battery")
assert state is not None
assert state.state == expected_state
async def test_dock_cleaning_brush_sensor_not_created_and_cleaned_up(
hass: HomeAssistant,
bypass_api_client_fixture: None,
entity_registry: er.EntityRegistry,
mock_roborock_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
) -> None:
"""Test cleaning brush sensor is not created and removed if it was in the registry."""
fake_vacuum.v1_properties.device_features.dock_features = (
RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.pearl_dock)
)
entity_registry.async_get_or_create(
domain=Platform.SENSOR,
platform=DOMAIN,
unique_id="cleaning_brush_time_left_abc123",
config_entry=mock_roborock_entry,
)
assert (
entity_registry.async_get_entity_id(
Platform.SENSOR, DOMAIN, "cleaning_brush_time_left_abc123"
)
is not None
)
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
await hass.async_block_till_done()
# Cleaning brush sensor must be removed from the entity registry
assert (
entity_registry.async_get_entity_id(
Platform.SENSOR, DOMAIN, "cleaning_brush_time_left_abc123"
)
is None
)
assert (
hass.states.get("sensor.roborock_s7_maxv_dock_maintenance_brush_time_left")
is None
)
# Washable dock strainer sensor must still exist
assert (
hass.states.get("sensor.roborock_s7_maxv_dock_strainer_time_left") is not None
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_dock_cleaning_brush_sensor_created_when_supported(
hass: HomeAssistant,
bypass_api_client_fixture: None,
mock_roborock_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
) -> None:
"""Test cleaning brush sensor is created on a dock that supports it."""
fake_vacuum.v1_properties.device_features.dock_features = (
RoborockDockFeatures.from_dock_type(RoborockDockTypeCode.o3_plus_dock)
)
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("sensor.roborock_s7_maxv_dock_maintenance_brush_time_left")
assert state is not None
assert state.state == "235"