mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Add clean area intent for vacuum (#165182)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}']"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user