diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index f8952962af43..be069dec2c6f 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -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, ) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index bd81c9e1f73e..987c88b691dd 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -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, ) diff --git a/tests/components/roborock/snapshots/test_button.ambr b/tests/components/roborock/snapshots/test_button.ambr index bd667e378e78..5f7d0f9b4caa 100644 --- a/tests/components/roborock/snapshots/test_button.ambr +++ b/tests/components/roborock/snapshots/test_button.ambr @@ -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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.roborock_s7_2_dock_reset_cleaning_brush_consumable', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - '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({ - : 'Roborock S7 2 Dock Reset cleaning brush consumable', - }), - 'context': , - 'entity_id': 'button.roborock_s7_2_dock_reset_cleaning_brush_consumable', - 'last_changed': , - 'last_reported': , - 'last_updated': , - '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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - '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({ - : 'Roborock S7 MaxV Dock Reset cleaning brush consumable', - }), - 'context': , - 'entity_id': 'button.roborock_s7_maxv_dock_reset_cleaning_brush_consumable', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[button.roborock_s7_maxv_dock_reset_strainer_consumable-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index 97d2feae035c..3baf08a1a3e0 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.roborock_s7_2_dock_maintenance_brush_time_left', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Maintenance brush time left', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - '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': , - }) -# --- -# name: test_sensors[sensor.roborock_s7_2_dock_maintenance_brush_time_left-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'duration', - : 'Roborock S7 2 Dock Maintenance brush time left', - : , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_2_dock_maintenance_brush_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - '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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.roborock_s7_maxv_dock_maintenance_brush_time_left', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Maintenance brush time left', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - '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': , - }) -# --- -# name: test_sensors[sensor.roborock_s7_maxv_dock_maintenance_brush_time_left-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'duration', - : 'Roborock S7 MaxV Dock Maintenance brush time left', - : , - }), - 'context': , - 'entity_id': 'sensor.roborock_s7_maxv_dock_maintenance_brush_time_left', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '235', - }) -# --- # name: test_sensors[sensor.roborock_s7_maxv_dock_mop_drying_remaining_time-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 16611d785c0c..1780f2570b5a 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -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" diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index fa2b9702bef4..d6ba272d2e52 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -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"