Add clean area intent for vacuum (#165182)

This commit is contained in:
Artur Pragacz
2026-03-10 16:24:18 +01:00
committed by GitHub
parent 0d9c458705
commit 1677a9bfa6
2 changed files with 333 additions and 12 deletions
+169 -3
View File
@@ -1,12 +1,25 @@
"""Intents for the vacuum integration."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
import logging
from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, VacuumEntityFeature
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar, config_validation as cv, intent
from . import (
DOMAIN,
SERVICE_CLEAN_AREA,
SERVICE_RETURN_TO_BASE,
SERVICE_START,
VacuumEntityFeature,
)
_LOGGER = logging.getLogger(__name__)
INTENT_VACUUM_START = "HassVacuumStart"
INTENT_VACUUM_RETURN_TO_BASE = "HassVacuumReturnToBase"
INTENT_VACUUM_CLEAN_AREA = "HassVacuumCleanArea"
async def async_setup_intents(hass: HomeAssistant) -> None:
@@ -35,3 +48,156 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
required_features=VacuumEntityFeature.RETURN_HOME,
),
)
intent.async_register(hass, CleanAreaIntentHandler())
class CleanAreaIntentHandler(intent.IntentHandler):
"""Intent handler for cleaning a specific area with a vacuum.
The area slot is used as a service parameter (cleaning_area_id),
not for entity matching.
"""
intent_type = INTENT_VACUUM_CLEAN_AREA
platforms = {DOMAIN}
description = "Tells a vacuum to clean a specific area"
@property
def slot_schema(self) -> dict:
"""Return a slot schema."""
return {
vol.Required("area"): cv.string,
vol.Optional("name"): cv.string,
vol.Optional("preferred_area_id"): cv.string,
vol.Optional("preferred_floor_id"): cv.string,
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
# Resolve the area name to an area ID
area_name = slots["area"]["value"]
area_reg = ar.async_get(hass)
matched_areas = list(intent.find_areas(area_name, area_reg))
if not matched_areas:
raise intent.MatchFailedError(
result=intent.MatchTargetsResult(
is_match=False,
no_match_reason=intent.MatchFailedReason.INVALID_AREA,
no_match_name=area_name,
),
constraints=intent.MatchTargetsConstraints(
area_name=area_name,
),
)
# Use preferred area/floor from conversation context to disambiguate
preferred_area_id = slots.get("preferred_area_id", {}).get("value")
preferred_floor_id = slots.get("preferred_floor_id", {}).get("value")
if len(matched_areas) > 1 and preferred_area_id is not None:
filtered = [a for a in matched_areas if a.id == preferred_area_id]
if filtered:
matched_areas = filtered
if len(matched_areas) > 1 and preferred_floor_id is not None:
filtered = [a for a in matched_areas if a.floor_id == preferred_floor_id]
if filtered:
matched_areas = filtered
# Match vacuum entity by name
name_slot = slots.get("name", {})
entity_name: str | None = name_slot.get("value")
match_constraints = intent.MatchTargetsConstraints(
name=entity_name,
domains={DOMAIN},
features=VacuumEntityFeature.CLEAN_AREA,
assistant=intent_obj.assistant,
)
# Use the resolved cleaning area and its floor as preferences
# for entity disambiguation
target_area = matched_areas[0]
match_preferences = intent.MatchTargetsPreferences(
area_id=target_area.id,
floor_id=target_area.floor_id,
)
match_result = intent.async_match_targets(
hass, match_constraints, match_preferences
)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result,
constraints=match_constraints,
preferences=match_preferences,
)
# Update intent slots to include any transformations done by the schemas
intent_obj.slots = slots
return await self._async_handle_service(intent_obj, match_result, matched_areas)
async def _async_handle_service(
self,
intent_obj: intent.Intent,
match_result: intent.MatchTargetsResult,
matched_areas: list[ar.AreaEntry],
) -> intent.IntentResponse:
"""Call clean_area for all matched areas."""
hass = intent_obj.hass
states = match_result.states
entity_ids = [state.entity_id for state in states]
area_ids = [area.id for area in matched_areas]
try:
await hass.services.async_call(
DOMAIN,
SERVICE_CLEAN_AREA,
{
"entity_id": entity_ids,
"cleaning_area_id": area_ids,
},
context=intent_obj.context,
blocking=True,
)
except Exception:
_LOGGER.exception(
"Failed to call %s for areas: %s with vacuums: %s",
SERVICE_CLEAN_AREA,
area_ids,
entity_ids,
)
raise intent.IntentHandleError(
f"Failed to call {SERVICE_CLEAN_AREA} for areas: {area_ids}"
f" with vacuums: {entity_ids}"
) from None
success_results: list[intent.IntentResponseTarget] = [
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.AREA,
name=area.name,
id=area.id,
)
for area in matched_areas
]
success_results.extend(
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
name=state.name,
id=state.entity_id,
)
for state in states
)
response = intent_obj.create_response()
response.async_set_results(success_results)
# Update all states
states = [hass.states.get(state.entity_id) or state for state in states]
response.async_set_states(states)
return response
+164 -9
View File
@@ -1,7 +1,12 @@
"""The tests for the vacuum platform."""
from unittest.mock import patch
import pytest
from homeassistant.components.vacuum import (
DOMAIN,
SERVICE_CLEAN_AREA,
SERVICE_RETURN_TO_BASE,
SERVICE_START,
VacuumEntityFeature,
@@ -9,13 +14,13 @@ from homeassistant.components.vacuum import (
)
from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_IDLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from homeassistant.helpers import area_registry as ar, intent
from tests.common import async_mock_service
async def test_start_vacuum_intent(hass: HomeAssistant) -> None:
"""Test HassTurnOn intent for vacuums."""
async def test_start(hass: HomeAssistant) -> None:
"""Test HassVacuumStart intent."""
await vacuum_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_vacuum"
@@ -40,8 +45,8 @@ async def test_start_vacuum_intent(hass: HomeAssistant) -> None:
assert call.data == {"entity_id": entity_id}
async def test_start_vacuum_without_name(hass: HomeAssistant) -> None:
"""Test starting a vacuum without specifying the name."""
async def test_start_without_name(hass: HomeAssistant) -> None:
"""Test HassVacuumStart intent without specifying the name."""
await vacuum_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_vacuum"
@@ -63,8 +68,8 @@ async def test_start_vacuum_without_name(hass: HomeAssistant) -> None:
assert call.data == {"entity_id": entity_id}
async def test_stop_vacuum_intent(hass: HomeAssistant) -> None:
"""Test HassTurnOff intent for vacuums."""
async def test_return_to_base(hass: HomeAssistant) -> None:
"""Test HassVacuumReturnToBase intent."""
await vacuum_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_vacuum"
@@ -91,8 +96,8 @@ async def test_stop_vacuum_intent(hass: HomeAssistant) -> None:
assert call.data == {"entity_id": entity_id}
async def test_stop_vacuum_without_name(hass: HomeAssistant) -> None:
"""Test stopping a vacuum without specifying the name."""
async def test_return_to_base_without_name(hass: HomeAssistant) -> None:
"""Test HassVacuumReturnToBase intent without specifying the name."""
await vacuum_intent.async_setup_intents(hass)
entity_id = f"{DOMAIN}.test_vacuum"
@@ -114,3 +119,153 @@ async def test_stop_vacuum_without_name(hass: HomeAssistant) -> None:
assert call.domain == DOMAIN
assert call.service == SERVICE_RETURN_TO_BASE
assert call.data == {"entity_id": entity_id}
async def test_clean_area(hass: HomeAssistant) -> None:
"""Test HassVacuumCleanArea intent."""
await vacuum_intent.async_setup_intents(hass)
area_reg = ar.async_get(hass)
kitchen = area_reg.async_create("Kitchen")
vacuum_1 = f"{DOMAIN}.vacuum_1"
vacuum_2 = f"{DOMAIN}.vacuum_2"
for entity_id in (vacuum_1, vacuum_2):
hass.states.async_set(
entity_id,
STATE_IDLE,
{ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.CLEAN_AREA},
)
calls = async_mock_service(hass, DOMAIN, SERVICE_CLEAN_AREA)
# Without name: all vacuums receive the service call
response = await intent.async_handle(
hass,
"test",
vacuum_intent.INTENT_VACUUM_CLEAN_AREA,
{"area": {"value": "Kitchen"}},
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
assert set(calls[0].data["entity_id"]) == {vacuum_1, vacuum_2}
assert calls[0].data["cleaning_area_id"] == [kitchen.id]
assert len(response.success_results) == 3
assert response.success_results[0].type == intent.IntentResponseTargetType.AREA
assert response.success_results[0].id == kitchen.id
assert all(
t.type == intent.IntentResponseTargetType.ENTITY
for t in response.success_results[1:]
)
assert {t.id for t in response.success_results[1:]} == {vacuum_1, vacuum_2}
# With name: only the named vacuum receives the call
calls.clear()
response = await intent.async_handle(
hass,
"test",
vacuum_intent.INTENT_VACUUM_CLEAN_AREA,
{"name": {"value": "vacuum 1"}, "area": {"value": "Kitchen"}},
)
await hass.async_block_till_done()
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1
assert calls[0].data == {
"entity_id": [vacuum_1],
"cleaning_area_id": [kitchen.id],
}
async def test_clean_area_no_matching_vacuum(hass: HomeAssistant) -> None:
"""Test HassVacuumCleanArea intent with no matching vacuum."""
await vacuum_intent.async_setup_intents(hass)
area_reg = ar.async_get(hass)
area_reg.async_create("Kitchen")
# No vacuums at all
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
vacuum_intent.INTENT_VACUUM_CLEAN_AREA,
{"area": {"value": "Kitchen"}},
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN
# Vacuum without CLEAN_AREA feature
hass.states.async_set(
f"{DOMAIN}.test_vacuum",
STATE_IDLE,
{ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.START},
)
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
vacuum_intent.INTENT_VACUUM_CLEAN_AREA,
{"area": {"value": "Kitchen"}},
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.FEATURE
async def test_clean_area_invalid_area(hass: HomeAssistant) -> None:
"""Test HassVacuumCleanArea intent with an invalid area."""
await vacuum_intent.async_setup_intents(hass)
hass.states.async_set(
f"{DOMAIN}.test_vacuum",
STATE_IDLE,
{ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.CLEAN_AREA},
)
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
vacuum_intent.INTENT_VACUUM_CLEAN_AREA,
{"area": {"value": "Nonexistent room"}},
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.INVALID_AREA
assert err.value.result.no_match_name == "Nonexistent room"
async def test_clean_area_service_failure(hass: HomeAssistant) -> None:
"""Test HassVacuumCleanArea intent when the service call fails."""
await vacuum_intent.async_setup_intents(hass)
area_reg = ar.async_get(hass)
area_reg.async_create("Kitchen")
entity_id = f"{DOMAIN}.test_vacuum"
hass.states.async_set(
entity_id,
STATE_IDLE,
{ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.CLEAN_AREA},
)
kitchen = area_reg.async_get_area_by_name("Kitchen")
assert kitchen is not None
with (
patch(
"homeassistant.core.ServiceRegistry.async_call",
side_effect=RuntimeError("Service failed"),
),
pytest.raises(intent.IntentHandleError) as err,
):
await intent.async_handle(
hass,
"test",
vacuum_intent.INTENT_VACUUM_CLEAN_AREA,
{"area": {"value": "Kitchen"}},
)
assert str(err.value) == (
f"Failed to call {SERVICE_CLEAN_AREA} for areas: ['{kitchen.id}']"
f" with vacuums: ['{entity_id}']"
)