diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index ba00bcaedb9..28bfea66be2 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -7,6 +7,7 @@ from typing import Any from homeassistant.components.vacuum import ( ATTR_CLEANED_AREA, + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, @@ -14,8 +15,11 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import event +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import DOMAIN + SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF SUPPORT_BASIC_SERVICES = ( @@ -45,9 +49,17 @@ SUPPORT_ALL_SERVICES = ( | VacuumEntityFeature.LOCATE | VacuumEntityFeature.MAP | VacuumEntityFeature.CLEAN_SPOT + | VacuumEntityFeature.CLEAN_AREA ) FAN_SPEEDS = ["min", "medium", "high", "max"] +DEMO_SEGMENTS = [ + Segment(id="living_room", name="Living room"), + Segment(id="kitchen", name="Kitchen"), + Segment(id="bedroom_1", name="Master bedroom", group="Bedrooms"), + Segment(id="bedroom_2", name="Guest bedroom", group="Bedrooms"), + Segment(id="bathroom", name="Bathroom"), +] DEMO_VACUUM_COMPLETE = "Demo vacuum 0 ground floor" DEMO_VACUUM_MOST = "Demo vacuum 1 first floor" DEMO_VACUUM_BASIC = "Demo vacuum 2 second floor" @@ -63,11 +75,11 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - StateDemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), - StateDemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), - StateDemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), - StateDemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), - StateDemoVacuum(DEMO_VACUUM_NONE, VacuumEntityFeature(0)), + StateDemoVacuum("vacuum_1", DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), + StateDemoVacuum("vacuum_2", DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), + StateDemoVacuum("vacuum_3", DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), + StateDemoVacuum("vacuum_4", DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), + StateDemoVacuum("vacuum_5", DEMO_VACUUM_NONE, VacuumEntityFeature(0)), ] ) @@ -75,13 +87,21 @@ async def async_setup_entry( class StateDemoVacuum(StateVacuumEntity): """Representation of a demo vacuum supporting states.""" + _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False _attr_translation_key = "model_s" - def __init__(self, name: str, supported_features: VacuumEntityFeature) -> None: + def __init__( + self, unique_id: str, name: str, supported_features: VacuumEntityFeature + ) -> None: """Initialize the vacuum.""" - self._attr_name = name + self._attr_unique_id = unique_id self._attr_supported_features = supported_features + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=name, + ) self._attr_activity = VacuumActivity.DOCKED self._fan_speed = FAN_SPEEDS[1] self._cleaned_area: float = 0 @@ -163,6 +183,16 @@ class StateDemoVacuum(StateVacuumEntity): self._attr_activity = VacuumActivity.IDLE self.async_write_ha_state() + async def async_get_segments(self) -> list[Segment]: + """Get the list of segments.""" + return DEMO_SEGMENTS + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Clean the specified segments.""" + self._attr_activity = VacuumActivity.CLEANING + self._cleaned_area += len(segment_ids) * 0.7 + self.async_write_ha_state() + def __set_state_to_dock(self, _: datetime) -> None: self._attr_activity = VacuumActivity.DOCKED self.schedule_update_ha_state() diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 2e68cf3938c..288f40727d0 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping +from dataclasses import dataclass from datetime import timedelta from functools import partial import logging @@ -21,7 +23,7 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API STATE_ON, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform @@ -31,6 +33,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from .const import DATA_COMPONENT, DOMAIN, VacuumActivity, VacuumEntityFeature +from .websocket import async_register_websocket_handlers _LOGGER = logging.getLogger(__name__) @@ -47,6 +50,7 @@ ATTR_PARAMS = "params" ATTR_STATUS = "status" SERVICE_CLEAN_SPOT = "clean_spot" +SERVICE_CLEAN_AREA = "clean_area" SERVICE_LOCATE = "locate" SERVICE_RETURN_TO_BASE = "return_to_base" SERVICE_SEND_COMMAND = "send_command" @@ -58,6 +62,8 @@ SERVICE_STOP = "stop" DEFAULT_NAME = "Vacuum cleaner robot" +ISSUE_SEGMENTS_CHANGED = "segments_changed" + _BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",) @@ -78,6 +84,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) + async_register_websocket_handlers(hass) + component.async_register_entity_service( SERVICE_START, None, @@ -102,6 +110,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_clean_spot", [VacuumEntityFeature.CLEAN_SPOT], ) + component.async_register_entity_service( + SERVICE_CLEAN_AREA, + { + vol.Required("cleaning_area_id"): vol.All(cv.ensure_list, [str]), + }, + "async_internal_clean_area", + [VacuumEntityFeature.CLEAN_AREA], + ) component.async_register_entity_service( SERVICE_LOCATE, None, @@ -368,6 +384,112 @@ class StateVacuumEntity( """ await self.hass.async_add_executor_job(partial(self.clean_spot, **kwargs)) + async def async_get_segments(self) -> list[Segment]: + """Get the segments that can be cleaned. + + Returns a list of segments containing their ids and names. + """ + raise NotImplementedError + + @final + @property + def last_seen_segments(self) -> list[Segment] | None: + """Return segments as seen by the user, when last mapping the areas. + + Returns None if no mapping has been saved yet. + This can be used by integrations to detect changes in segments reported + by the vacuum and create a repair issue. + """ + if self.registry_entry is None: + raise RuntimeError( + "Cannot access last_seen_segments, registry entry is not set for" + f" {self.entity_id}" + ) + + options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) + last_seen_segments = options.get("last_seen_segments") + + if last_seen_segments is None: + return None + + return [Segment(**segment) for segment in last_seen_segments] + + @final + async def async_internal_clean_area( + self, cleaning_area_id: list[str], **kwargs: Any + ) -> None: + """Perform an area clean. + + Calls async_clean_segments. + """ + if self.registry_entry is None: + raise RuntimeError( + "Cannot perform area clean, registry entry is not set for" + f" {self.entity_id}" + ) + + options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) + area_mapping: dict[str, list[str]] = options.get("area_mapping", {}) + + # We use a dict to preserve the order of segments. + segment_ids: dict[str, None] = {} + for area_id in cleaning_area_id: + for segment_id in area_mapping.get(area_id, []): + segment_ids[segment_id] = None + + if not segment_ids: + _LOGGER.debug( + "No segments found for cleaning_area_id %s on vacuum %s", + cleaning_area_id, + self.entity_id, + ) + return + + await self.async_clean_segments(list(segment_ids), **kwargs) + + def clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean.""" + raise NotImplementedError + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean.""" + await self.hass.async_add_executor_job( + partial(self.clean_segments, segment_ids, **kwargs) + ) + + @callback + def async_create_segments_issue(self) -> None: + """Create a repair issue when vacuum segments have changed. + + Integrations should call this method when the vacuum reports + different segments than what was previously mapped to areas. + + The issue is not fixable via the standard repair flow. The frontend + will handle the fix by showing the segment mapping dialog. + """ + if self.registry_entry is None: + raise RuntimeError( + "Cannot create segments issue, registry entry is not set for" + f" {self.entity_id}" + ) + + issue_id = f"{ISSUE_SEGMENTS_CHANGED}_{self.registry_entry.id}" + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id, + data={ + "entry_id": self.registry_entry.id, + "entity_id": self.entity_id, + }, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_SEGMENTS_CHANGED, + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) + def locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" raise NotImplementedError @@ -436,3 +558,12 @@ class StateVacuumEntity( This method must be run in the event loop. """ await self.hass.async_add_executor_job(self.pause) + + +@dataclass(slots=True) +class Segment: + """Represents a cleanable segment reported by a vacuum.""" + + id: str + name: str + group: str | None = None diff --git a/homeassistant/components/vacuum/const.py b/homeassistant/components/vacuum/const.py index a6e8703a1b0..919eb1df566 100644 --- a/homeassistant/components/vacuum/const.py +++ b/homeassistant/components/vacuum/const.py @@ -44,3 +44,4 @@ class VacuumEntityFeature(IntFlag): MAP = 2048 STATE = 4096 # Must be set by vacuum platforms derived from StateVacuumEntity START = 8192 + CLEAN_AREA = 16384 diff --git a/homeassistant/components/vacuum/icons.json b/homeassistant/components/vacuum/icons.json index 7cc83f647dd..dabca1057ac 100644 --- a/homeassistant/components/vacuum/icons.json +++ b/homeassistant/components/vacuum/icons.json @@ -22,6 +22,9 @@ } }, "services": { + "clean_area": { + "service": "mdi:target-variant" + }, "clean_spot": { "service": "mdi:target-variant" }, diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index 25f3822bd35..2f14a5bd3c6 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -69,6 +69,19 @@ clean_spot: entity: domain: vacuum +clean_area: + target: + entity: + domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.CLEAN_AREA + fields: + cleaning_area_id: + required: true + selector: + area: + multiple: true + send_command: target: entity: diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 8e980aedb54..604abd04937 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -89,6 +89,12 @@ } } }, + "issues": { + "segments_changed": { + "description": "", + "title": "Vacuum segments have changed for {entity_id}" + } + }, "selector": { "condition_behavior": { "options": { @@ -105,6 +111,16 @@ } }, "services": { + "clean_area": { + "description": "Tells the vacuum cleaner to clean an area.", + "fields": { + "cleaning_area_id": { + "description": "Areas to clean.", + "name": "Areas" + } + }, + "name": "Clean area" + }, "clean_spot": { "description": "Tells the vacuum cleaner to do a spot clean-up.", "name": "Clean spot" diff --git a/homeassistant/components/vacuum/websocket.py b/homeassistant/components/vacuum/websocket.py new file mode 100644 index 00000000000..7be4187bc13 --- /dev/null +++ b/homeassistant/components/vacuum/websocket.py @@ -0,0 +1,51 @@ +"""Websocket commands for the Vacuum integration.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv + +from .const import DATA_COMPONENT, VacuumEntityFeature + + +@callback +def async_register_websocket_handlers(hass: HomeAssistant) -> None: + """Register websocket commands.""" + websocket_api.async_register_command(hass, handle_get_segments) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "vacuum/get_segments", + vol.Required("entity_id"): cv.strict_entity_id, + } +) +@websocket_api.async_response +async def handle_get_segments( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get segments for a vacuum.""" + entity_id = msg["entity_id"] + entity = hass.data[DATA_COMPONENT].get_entity(entity_id) + if entity is None: + connection.send_error(msg["id"], ERR_NOT_FOUND, f"Entity {entity_id} not found") + return + + if VacuumEntityFeature.CLEAN_AREA not in entity.supported_features: + connection.send_error( + msg["id"], ERR_NOT_SUPPORTED, f"Entity {entity_id} not supported" + ) + return + + segments = await entity.async_get_segments() + + connection.send_result(msg["id"], {"segments": segments}) diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index a497bd964ec..d3858a91c7c 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -70,7 +70,7 @@ async def setup_demo_vacuum(hass: HomeAssistant, vacuum_only: None): async def test_supported_features(hass: HomeAssistant) -> None: """Test vacuum supported features.""" state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 16316 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 32700 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS assert state.state == VacuumActivity.DOCKED diff --git a/tests/components/vacuum/__init__.py b/tests/components/vacuum/__init__.py index 7e27af46bac..ae4cdc30b17 100644 --- a/tests/components/vacuum/__init__.py +++ b/tests/components/vacuum/__init__.py @@ -3,6 +3,7 @@ from typing import Any from homeassistant.components.vacuum import ( + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, @@ -79,3 +80,48 @@ async def help_async_unload_entry( return await hass.config_entries.async_unload_platforms( config_entry, [Platform.VACUUM] ) + + +SEGMENTS = [ + Segment(id="seg_1", name="Kitchen"), + Segment(id="seg_2", name="Living Room"), + Segment(id="seg_3", name="Bedroom"), + Segment(id="seg_4", name="Bedroom", group="Upstairs"), + Segment(id="seg_5", name="Bathroom", group="Upstairs"), +] + + +class MockVacuumWithCleanArea(MockEntity, StateVacuumEntity): + """Mock vacuum with clean_area support.""" + + _attr_supported_features = ( + VacuumEntityFeature.STATE + | VacuumEntityFeature.START + | VacuumEntityFeature.CLEAN_AREA + ) + + def __init__( + self, + segments: list[Segment] | None = None, + unique_id: str = "mock_vacuum_unique_id", + **values: Any, + ) -> None: + """Initialize a mock vacuum entity.""" + super().__init__(**values) + self._attr_unique_id = unique_id + self._attr_activity = VacuumActivity.DOCKED + self.segments = segments if segments is not None else SEGMENTS + self.clean_segments_calls: list[tuple[list[str], dict[str, Any]]] = [] + + def start(self) -> None: + """Start cleaning.""" + self._attr_activity = VacuumActivity.CLEANING + + async def async_get_segments(self) -> list[Segment]: + """Get the segments that can be cleaned.""" + return self.segments + + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean.""" + self.clean_segments_calls.append((segment_ids, kwargs)) + self._attr_activity = VacuumActivity.CLEANING diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 1607264d822..549802d6e79 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import asdict import logging from typing import Any @@ -9,6 +10,7 @@ import pytest from homeassistant.components.vacuum import ( DOMAIN, + SERVICE_CLEAN_AREA, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -22,12 +24,19 @@ from homeassistant.components.vacuum import ( VacuumEntityFeature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir -from . import MockVacuum, help_async_setup_entry_init, help_async_unload_entry +from . import ( + MockVacuum, + MockVacuumWithCleanArea, + help_async_setup_entry_init, + help_async_unload_entry, +) from .common import async_start from tests.common import ( MockConfigEntry, + MockEntity, MockModule, mock_integration, setup_test_component_platform, @@ -206,6 +215,252 @@ async def test_send_command(hass: HomeAssistant, config_flow_fixture: None) -> N assert "test" in strings +@pytest.mark.usefixtures("config_flow_fixture") +@pytest.mark.parametrize( + ("area_mapping", "targeted_areas", "targeted_segments"), + [ + ( + {"area_1": ["seg_1"], "area_2": ["seg_2", "seg_3"]}, + ["area_1", "area_2"], + ["seg_1", "seg_2", "seg_3"], + ), + ( + {"area_1": ["seg_1", "seg_2"], "area_2": ["seg_2", "seg_3"]}, + ["area_1", "area_2"], + ["seg_1", "seg_2", "seg_3"], + ), + ], +) +async def test_clean_area_service( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_mapping: dict[str, list[str]], + targeted_areas: list[str], + targeted_segments: list[str], +) -> None: + """Test clean_area service calls async_clean_segments with correct segments.""" + mock_vacuum = MockVacuumWithCleanArea(name="Testing", entity_id="vacuum.testing") + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_registry.async_update_entity_options( + mock_vacuum.entity_id, + DOMAIN, + { + "area_mapping": area_mapping, + "last_seen_segments": [asdict(segment) for segment in mock_vacuum.segments], + }, + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAN_AREA, + {"entity_id": mock_vacuum.entity_id, "cleaning_area_id": targeted_areas}, + blocking=True, + ) + + assert len(mock_vacuum.clean_segments_calls) == 1 + assert mock_vacuum.clean_segments_calls[0][0] == targeted_segments + + +@pytest.mark.usefixtures("config_flow_fixture") +@pytest.mark.parametrize( + ("area_mapping", "targeted_areas"), + [ + ({}, ["area_1"]), + ({"area_1": ["seg_1"]}, ["area_2"]), + ], +) +async def test_clean_area_no_segments( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_mapping: dict[str, list[str]], + targeted_areas: list[str], +) -> None: + """Test clean_area does nothing when no segments to clean.""" + mock_vacuum = MockVacuumWithCleanArea(name="Testing", entity_id="vacuum.testing") + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAN_AREA, + {"entity_id": mock_vacuum.entity_id, "cleaning_area_id": targeted_areas}, + blocking=True, + ) + + entity_registry.async_update_entity_options( + mock_vacuum.entity_id, + DOMAIN, + { + "area_mapping": area_mapping, + "last_seen_segments": [asdict(segment) for segment in mock_vacuum.segments], + }, + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAN_AREA, + {"entity_id": mock_vacuum.entity_id, "cleaning_area_id": targeted_areas}, + blocking=True, + ) + + assert len(mock_vacuum.clean_segments_calls) == 0 + + +@pytest.mark.usefixtures("config_flow_fixture") +async def test_clean_area_methods_not_implemented(hass: HomeAssistant) -> None: + """Test async_get_segments and async_clean_segments raise NotImplementedError.""" + + class MockVacuumNoImpl(MockEntity, StateVacuumEntity): + """Mock vacuum without implementations.""" + + _attr_supported_features = ( + VacuumEntityFeature.STATE | VacuumEntityFeature.CLEAN_AREA + ) + _attr_activity = VacuumActivity.DOCKED + + mock_vacuum = MockVacuumNoImpl(name="Testing", entity_id="vacuum.testing") + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(NotImplementedError): + await mock_vacuum.async_get_segments() + + with pytest.raises(NotImplementedError): + await mock_vacuum.async_clean_segments(["seg_1"]) + + +async def test_clean_area_no_registry_entry() -> None: + """Test error handling when registry entry is not set.""" + mock_vacuum = MockVacuumWithCleanArea(name="Testing", entity_id="vacuum.testing") + + with pytest.raises( + RuntimeError, + match="Cannot access last_seen_segments, registry entry is not set", + ): + mock_vacuum.last_seen_segments # noqa: B018 + + with pytest.raises( + RuntimeError, + match="Cannot perform area clean, registry entry is not set", + ): + await mock_vacuum.async_internal_clean_area(["area_1"]) + + with pytest.raises( + RuntimeError, + match="Cannot create segments issue, registry entry is not set", + ): + mock_vacuum.async_create_segments_issue() + + +@pytest.mark.usefixtures("config_flow_fixture") +async def test_last_seen_segments( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test last_seen_segments property.""" + mock_vacuum = MockVacuumWithCleanArea(name="Testing", entity_id="vacuum.testing") + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_vacuum.last_seen_segments is None + + entity_registry.async_update_entity_options( + mock_vacuum.entity_id, + DOMAIN, + { + "area_mapping": {}, + "last_seen_segments": [asdict(segment) for segment in mock_vacuum.segments], + }, + ) + + assert mock_vacuum.last_seen_segments == mock_vacuum.segments + + +@pytest.mark.usefixtures("config_flow_fixture") +async def test_last_seen_segments_and_issue_creation( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test last_seen_segments property and segments issue creation.""" + mock_vacuum = MockVacuumWithCleanArea(name="Testing", entity_id="vacuum.testing") + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get(mock_vacuum.entity_id) + mock_vacuum.async_create_segments_issue() + + issue_id = f"segments_changed_{entity_entry.id}" + issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_key == "segments_changed" + + @pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 3)]) async def test_vacuum_log_deprecated_battery_using_properties( hass: HomeAssistant, diff --git a/tests/components/vacuum/test_websocket.py b/tests/components/vacuum/test_websocket.py new file mode 100644 index 00000000000..19ba3366169 --- /dev/null +++ b/tests/components/vacuum/test_websocket.py @@ -0,0 +1,125 @@ +"""Tests for vacuum websocket API.""" + +from __future__ import annotations + +from dataclasses import asdict + +import pytest + +from homeassistant.components.vacuum import ( + DOMAIN, + Segment, + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + MockVacuumWithCleanArea, + help_async_setup_entry_init, + help_async_unload_entry, +) + +from tests.common import ( + MockConfigEntry, + MockEntity, + MockModule, + mock_integration, + setup_test_component_platform, +) +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("config_flow_fixture") +async def test_get_segments( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test vacuum/get_segments websocket command.""" + segments = [ + Segment(id="seg_1", name="Kitchen"), + Segment(id="seg_2", name="Living Room"), + Segment(id="seg_3", name="Bedroom", group="Upstairs"), + ] + entity = MockVacuumWithCleanArea( + name="Testing", + entity_id="vacuum.testing", + segments=segments, + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": entity.entity_id} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"segments": [asdict(seg) for seg in segments]} + + +async def test_get_segments_entity_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test vacuum/get_segments with unknown entity.""" + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": "vacuum.unknown"} + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + +@pytest.mark.usefixtures("config_flow_fixture") +async def test_get_segments_not_supported( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test vacuum/get_segments with entity not supporting CLEAN_AREA.""" + + class MockVacuumNoCleanArea(MockEntity, StateVacuumEntity): + _attr_supported_features = VacuumEntityFeature.STATE | VacuumEntityFeature.START + _attr_activity = VacuumActivity.DOCKED + + entity = MockVacuumNoCleanArea(name="Testing", entity_id="vacuum.testing") + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": entity.entity_id} + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_SUPPORTED