Move vacuum service registration to services module (#183417)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
epenet
2026-09-28 11:26:17 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 86569eaf8c
commit eda0fd4ec4
4 changed files with 193 additions and 143 deletions
+16 -142
View File
@@ -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
+13
View File
@@ -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)
+162
View File
@@ -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],
)
+2 -1
View File
@@ -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,