From eda0fd4ec46d40eaabdf7b24ef56890e593ec0f1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:26:17 +0200 Subject: [PATCH] Move vacuum service registration to services module (#183417) Co-authored-by: Claude Opus 5 --- homeassistant/components/vacuum/__init__.py | 158 ++----------------- homeassistant/components/vacuum/const.py | 13 ++ homeassistant/components/vacuum/services.py | 162 ++++++++++++++++++++ tests/components/vacuum/test_init.py | 3 +- 4 files changed, 193 insertions(+), 143 deletions(-) create mode 100644 homeassistant/components/vacuum/services.py diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index baa76de82e4c..dcce025d3d43 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -7,7 +7,6 @@ from functools import partial import logging from typing import Any, final, override -import probatio from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -18,25 +17,32 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - issue_registry as ir, - service as service_helper, -) +from homeassistant.core import HomeAssistant, callback +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.typing import ConfigType -from .const import ( +from .const import ( # noqa: F401 + ATTR_FAN_SPEED, + ATTR_PARAMS, DATA_COMPONENT, DOMAIN, + SERVICE_CLEAN_AREA, + SERVICE_CLEAN_SPOT, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SEND_COMMAND, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + SERVICE_STOP, VacuumActivity, VacuumEntityCapabilityAttribute, VacuumEntityFeature, VacuumEntityStateAttribute, ) +from .services import async_setup_services from .websocket import async_register_websocket_handlers _LOGGER = logging.getLogger(__name__) @@ -47,21 +53,10 @@ PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=20) ATTR_CLEANED_AREA = "cleaned_area" -ATTR_FAN_SPEED = "fan_speed" ATTR_FAN_SPEED_LIST = "fan_speed_list" -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" -SERVICE_SET_FAN_SPEED = "set_fan_speed" SERVICE_START_PAUSE = "start_pause" -SERVICE_START = "start" -SERVICE_PAUSE = "pause" -SERVICE_STOP = "stop" DEFAULT_NAME = "Vacuum cleaner robot" @@ -86,65 +81,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_websocket_handlers(hass) - component.async_register_entity_service( - SERVICE_START, - None, - "async_start", - [VacuumEntityFeature.START], - ) - component.async_register_entity_service( - SERVICE_PAUSE, - None, - "async_pause", - [VacuumEntityFeature.PAUSE], - ) - component.async_register_entity_service( - SERVICE_RETURN_TO_BASE, - None, - "async_return_to_base", - [VacuumEntityFeature.RETURN_HOME], - ) - component.async_register_entity_service( - SERVICE_CLEAN_SPOT, - None, - "async_clean_spot", - [VacuumEntityFeature.CLEAN_SPOT], - ) - component.async_register_batched_entity_service( - SERVICE_CLEAN_AREA, - { - probatio.Required("cleaning_area_id"): probatio.All(cv.ensure_list, [str]), - }, - StateVacuumEntity.async_internal_clean_area, - [VacuumEntityFeature.CLEAN_AREA], - ) - component.async_register_entity_service( - SERVICE_LOCATE, - None, - "async_locate", - [VacuumEntityFeature.LOCATE], - ) - component.async_register_entity_service( - SERVICE_STOP, - None, - "async_stop", - [VacuumEntityFeature.STOP], - ) - component.async_register_entity_service( - SERVICE_SET_FAN_SPEED, - {probatio.Required(ATTR_FAN_SPEED): cv.string}, - "async_set_fan_speed", - [VacuumEntityFeature.FAN_SPEED], - ) - component.async_register_entity_service( - SERVICE_SEND_COMMAND, - { - probatio.Required(ATTR_COMMAND): cv.string, - probatio.Optional(ATTR_PARAMS): probatio.Any(dict, cv.ensure_list), - }, - "async_send_command", - [VacuumEntityFeature.SEND_COMMAND], - ) + async_setup_services(hass) return True @@ -313,69 +250,6 @@ class StateVacuumEntity( return [Segment(**segment) for segment in last_seen_segments] - @final - @staticmethod - async def async_internal_clean_area( - entities: list[StateVacuumEntity], call: ServiceCall - ) -> None: - """Perform an area clean. - - Calls async_clean_segments for each entity. - """ - data = dict(call.data) - cleaning_area_id: list[str] = data.pop("cleaning_area_id") - - entity_data: list[tuple[StateVacuumEntity, dict[str, Any]]] = [] - handled_areas: set[str] = set() - for entity in entities: - if entity.registry_entry is None: - raise RuntimeError( - "Cannot perform area clean, registry entry is not set for" - f" {entity.entity_id}" - ) - - options: Mapping[str, Any] = entity.registry_entry.options.get(DOMAIN, {}) - area_mapping: dict[str, list[str]] | None = options.get("area_mapping") - - if area_mapping is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="area_mapping_not_configured", - translation_placeholders={"entity_id": entity.entity_id}, - ) - - # We use a dict to preserve the order of segments. - segment_ids: dict[str, None] = {} - for area_id in cleaning_area_id: - if (segments := area_mapping.get(area_id)) is None: - continue - handled_areas.add(area_id) - for segment_id in segments: - 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, - entity.entity_id, - ) - continue - - entity_data.append((entity, {"segment_ids": list(segment_ids), **data})) - - if entity_data: - await service_helper.async_handle_entity_calls( - "async_clean_segments", entity_data, context=call.context - ) - - unhandled_areas = set(cleaning_area_id) - handled_areas - if unhandled_areas: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="areas_not_mapped", - translation_placeholders={"areas": ", ".join(sorted(unhandled_areas))}, - ) - def clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: """Perform an area clean.""" raise NotImplementedError diff --git a/homeassistant/components/vacuum/const.py b/homeassistant/components/vacuum/const.py index 2d82ea937054..e9aa34094f42 100644 --- a/homeassistant/components/vacuum/const.py +++ b/homeassistant/components/vacuum/const.py @@ -11,6 +11,19 @@ if TYPE_CHECKING: DOMAIN: Final = "vacuum" +ATTR_FAN_SPEED: Final = "fan_speed" +ATTR_PARAMS: Final = "params" + +SERVICE_CLEAN_AREA: Final = "clean_area" +SERVICE_CLEAN_SPOT: Final = "clean_spot" +SERVICE_LOCATE: Final = "locate" +SERVICE_PAUSE: Final = "pause" +SERVICE_RETURN_TO_BASE: Final = "return_to_base" +SERVICE_SEND_COMMAND: Final = "send_command" +SERVICE_SET_FAN_SPEED: Final = "set_fan_speed" +SERVICE_START: Final = "start" +SERVICE_STOP: Final = "stop" + DATA_COMPONENT: HassKey[EntityComponent[StateVacuumEntity]] = HassKey(DOMAIN) diff --git a/homeassistant/components/vacuum/services.py b/homeassistant/components/vacuum/services.py new file mode 100644 index 000000000000..9a9ba94ed8e2 --- /dev/null +++ b/homeassistant/components/vacuum/services.py @@ -0,0 +1,162 @@ +"""Services for the vacuum integration.""" + +from collections.abc import Mapping +import logging +from typing import TYPE_CHECKING, Any + +import probatio + +from homeassistant.const import ATTR_COMMAND +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, service as service_helper + +from .const import ( + ATTR_FAN_SPEED, + ATTR_PARAMS, + DATA_COMPONENT, + DOMAIN, + SERVICE_CLEAN_AREA, + SERVICE_CLEAN_SPOT, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SEND_COMMAND, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + SERVICE_STOP, + VacuumEntityFeature, +) + +if TYPE_CHECKING: + from . import StateVacuumEntity + +_LOGGER = logging.getLogger(__name__) + + +async def _async_clean_area( + entities: list[StateVacuumEntity], call: ServiceCall +) -> None: + """Perform an area clean. + + Calls async_clean_segments for each entity. + """ + data = dict(call.data) + cleaning_area_id: list[str] = data.pop("cleaning_area_id") + + entity_data: list[tuple[StateVacuumEntity, dict[str, Any]]] = [] + handled_areas: set[str] = set() + for entity in entities: + if entity.registry_entry is None: + raise RuntimeError( + "Cannot perform area clean, registry entry is not set for" + f" {entity.entity_id}" + ) + + options: Mapping[str, Any] = entity.registry_entry.options.get(DOMAIN, {}) + area_mapping: dict[str, list[str]] | None = options.get("area_mapping") + + if area_mapping is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="area_mapping_not_configured", + translation_placeholders={"entity_id": entity.entity_id}, + ) + + # We use a dict to preserve the order of segments. + segment_ids: dict[str, None] = {} + for area_id in cleaning_area_id: + if (segments := area_mapping.get(area_id)) is None: + continue + handled_areas.add(area_id) + for segment_id in segments: + 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, + entity.entity_id, + ) + continue + + entity_data.append((entity, {"segment_ids": list(segment_ids), **data})) + + if entity_data: + await service_helper.async_handle_entity_calls( + "async_clean_segments", entity_data, context=call.context + ) + + unhandled_areas = set(cleaning_area_id) - handled_areas + if unhandled_areas: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="areas_not_mapped", + translation_placeholders={"areas": ", ".join(sorted(unhandled_areas))}, + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the vacuum services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_START, + None, + "async_start", + [VacuumEntityFeature.START], + ) + component.async_register_entity_service( + SERVICE_PAUSE, + None, + "async_pause", + [VacuumEntityFeature.PAUSE], + ) + component.async_register_entity_service( + SERVICE_RETURN_TO_BASE, + None, + "async_return_to_base", + [VacuumEntityFeature.RETURN_HOME], + ) + component.async_register_entity_service( + SERVICE_CLEAN_SPOT, + None, + "async_clean_spot", + [VacuumEntityFeature.CLEAN_SPOT], + ) + component.async_register_batched_entity_service( + SERVICE_CLEAN_AREA, + { + probatio.Required("cleaning_area_id"): probatio.All(cv.ensure_list, [str]), + }, + _async_clean_area, + [VacuumEntityFeature.CLEAN_AREA], + ) + component.async_register_entity_service( + SERVICE_LOCATE, + None, + "async_locate", + [VacuumEntityFeature.LOCATE], + ) + component.async_register_entity_service( + SERVICE_STOP, + None, + "async_stop", + [VacuumEntityFeature.STOP], + ) + component.async_register_entity_service( + SERVICE_SET_FAN_SPEED, + {probatio.Required(ATTR_FAN_SPEED): cv.string}, + "async_set_fan_speed", + [VacuumEntityFeature.FAN_SPEED], + ) + component.async_register_entity_service( + SERVICE_SEND_COMMAND, + { + probatio.Required(ATTR_COMMAND): cv.string, + probatio.Optional(ATTR_PARAMS): probatio.Any(dict, cv.ensure_list), + }, + "async_send_command", + [VacuumEntityFeature.SEND_COMMAND], + ) diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 5f15536053fc..fd450529221f 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -20,6 +20,7 @@ from homeassistant.components.vacuum import ( VacuumActivity, VacuumEntityFeature, ) +from homeassistant.components.vacuum.services import _async_clean_area from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -449,7 +450,7 @@ async def test_clean_area_no_registry_entry(hass: HomeAssistant) -> None: RuntimeError, match="Cannot perform area clean, registry entry is not set", ): - await StateVacuumEntity.async_internal_clean_area([mock_vacuum], call) + await _async_clean_area([mock_vacuum], call) with pytest.raises( RuntimeError,