mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 01:08:12 +00:00
Move climate intent to homeassistant integration (#139371)
* Move climate intent to homeassistant integration * Move get temperature intent to intent integration * Clean up old test
This commit is contained in:
parent
83c0351338
commit
0891669aee
@ -68,7 +68,6 @@ from .const import ( # noqa: F401
|
||||
FAN_ON,
|
||||
FAN_TOP,
|
||||
HVAC_MODES,
|
||||
INTENT_GET_TEMPERATURE,
|
||||
INTENT_SET_TEMPERATURE,
|
||||
PRESET_ACTIVITY,
|
||||
PRESET_AWAY,
|
||||
|
@ -126,7 +126,6 @@ DEFAULT_MAX_HUMIDITY = 99
|
||||
|
||||
DOMAIN = "climate"
|
||||
|
||||
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
|
||||
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
|
||||
|
||||
SERVICE_SET_AUX_HEAT = "set_aux_heat"
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Intents for the client integration."""
|
||||
"""Intents for the climate integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv, intent
|
||||
from . import (
|
||||
ATTR_TEMPERATURE,
|
||||
DOMAIN,
|
||||
INTENT_GET_TEMPERATURE,
|
||||
INTENT_SET_TEMPERATURE,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
ClimateEntityFeature,
|
||||
@ -20,49 +19,9 @@ from . import (
|
||||
|
||||
async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
"""Set up the climate intents."""
|
||||
intent.async_register(hass, GetTemperatureIntent())
|
||||
intent.async_register(hass, SetTemperatureIntent())
|
||||
|
||||
|
||||
class GetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle GetTemperature intents."""
|
||||
|
||||
intent_type = INTENT_GET_TEMPERATURE
|
||||
description = "Gets the current temperature of a climate device or entity"
|
||||
slot_schema = {
|
||||
vol.Optional("area"): intent.non_empty_string,
|
||||
vol.Optional("name"): intent.non_empty_string,
|
||||
}
|
||||
platforms = {DOMAIN}
|
||||
|
||||
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)
|
||||
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
|
||||
area: str | None = None
|
||||
if "area" in slots:
|
||||
area = slots["area"]["value"]
|
||||
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||
response.async_set_states(matched_states=match_result.states)
|
||||
return response
|
||||
|
||||
|
||||
class SetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle SetTemperature intents."""
|
||||
|
||||
|
@ -9,6 +9,7 @@ from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import http
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
@ -140,6 +141,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
intent.async_register(hass, GetCurrentDateIntentHandler())
|
||||
intent.async_register(hass, GetCurrentTimeIntentHandler())
|
||||
intent.async_register(hass, RespondIntentHandler())
|
||||
intent.async_register(hass, GetTemperatureIntent())
|
||||
|
||||
return True
|
||||
|
||||
@ -444,6 +446,48 @@ class RespondIntentHandler(intent.IntentHandler):
|
||||
return response
|
||||
|
||||
|
||||
class GetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle GetTemperature intents."""
|
||||
|
||||
intent_type = intent.INTENT_GET_TEMPERATURE
|
||||
description = "Gets the current temperature of a climate device or entity"
|
||||
slot_schema = {
|
||||
vol.Optional("area"): intent.non_empty_string,
|
||||
vol.Optional("name"): intent.non_empty_string,
|
||||
}
|
||||
platforms = {CLIMATE_DOMAIN}
|
||||
|
||||
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)
|
||||
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
|
||||
area: str | None = None
|
||||
if "area" in slots:
|
||||
area = slots["area"]["value"]
|
||||
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=name,
|
||||
area_name=area,
|
||||
domains=[CLIMATE_DOMAIN],
|
||||
assistant=intent_obj.assistant,
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||
response.async_set_states(matched_states=match_result.states)
|
||||
return response
|
||||
|
||||
|
||||
async def _async_process_intent(
|
||||
hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol
|
||||
) -> None:
|
||||
|
@ -59,6 +59,7 @@ INTENT_GET_CURRENT_DATE = "HassGetCurrentDate"
|
||||
INTENT_GET_CURRENT_TIME = "HassGetCurrentTime"
|
||||
INTENT_RESPOND = "HassRespond"
|
||||
INTENT_BROADCAST = "HassBroadcast"
|
||||
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
|
||||
|
||||
SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
@ -19,7 +19,6 @@ from homeassistant.components.calendar import (
|
||||
DOMAIN as CALENDAR_DOMAIN,
|
||||
SERVICE_GET_EVENTS,
|
||||
)
|
||||
from homeassistant.components.climate import INTENT_GET_TEMPERATURE
|
||||
from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER
|
||||
from homeassistant.components.homeassistant import async_should_expose
|
||||
from homeassistant.components.intent import async_device_supports_timers
|
||||
@ -285,7 +284,7 @@ class AssistAPI(API):
|
||||
"""API exposing Assist API to LLMs."""
|
||||
|
||||
IGNORE_INTENTS = {
|
||||
INTENT_GET_TEMPERATURE,
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
INTENT_GET_WEATHER,
|
||||
INTENT_OPEN_COVER, # deprecated
|
||||
INTENT_CLOSE_COVER, # deprecated
|
||||
@ -530,9 +529,11 @@ def _get_exposed_entities(
|
||||
info["areas"] = ", ".join(area_names)
|
||||
|
||||
if attributes := {
|
||||
attr_name: str(attr_value)
|
||||
if isinstance(attr_value, (Enum, Decimal, int))
|
||||
else attr_value
|
||||
attr_name: (
|
||||
str(attr_value)
|
||||
if isinstance(attr_value, (Enum, Decimal, int))
|
||||
else attr_value
|
||||
)
|
||||
for attr_name, attr_value in state.attributes.items()
|
||||
if attr_name in interesting_attributes
|
||||
}:
|
||||
|
@ -14,7 +14,6 @@ from homeassistant.components.climate import (
|
||||
HVACMode,
|
||||
intent as climate_intent,
|
||||
)
|
||||
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||
from homeassistant.const import Platform, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
@ -131,335 +130,6 @@ class MockClimateEntityNoSetTemperature(ClimateEntity):
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
|
||||
|
||||
async def test_get_temperature(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test HassClimateGetTemperature intent."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
await climate_intent.async_setup_intents(hass)
|
||||
|
||||
climate_1 = MockClimateEntity()
|
||||
climate_1._attr_name = "Climate 1"
|
||||
climate_1._attr_unique_id = "1234"
|
||||
climate_1._attr_current_temperature = 10.0
|
||||
entity_registry.async_get_or_create(
|
||||
DOMAIN, "test", "1234", suggested_object_id="climate_1"
|
||||
)
|
||||
|
||||
climate_2 = MockClimateEntity()
|
||||
climate_2._attr_name = "Climate 2"
|
||||
climate_2._attr_unique_id = "5678"
|
||||
climate_2._attr_current_temperature = 22.0
|
||||
entity_registry.async_get_or_create(
|
||||
DOMAIN, "test", "5678", suggested_object_id="climate_2"
|
||||
)
|
||||
|
||||
await create_mock_platform(hass, [climate_1, climate_2])
|
||||
|
||||
# Add climate entities to different areas:
|
||||
# climate_1 => living room
|
||||
# climate_2 => bedroom
|
||||
# nothing in office
|
||||
living_room_area = area_registry.async_create(name="Living Room")
|
||||
bedroom_area = area_registry.async_create(name="Bedroom")
|
||||
office_area = area_registry.async_create(name="Office")
|
||||
|
||||
entity_registry.async_update_entity(
|
||||
climate_1.entity_id, area_id=living_room_area.id
|
||||
)
|
||||
entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id)
|
||||
|
||||
# First climate entity will be selected (no area)
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert response.matched_states
|
||||
assert response.matched_states[0].entity_id == climate_1.entity_id
|
||||
state = response.matched_states[0]
|
||||
assert state.attributes["current_temperature"] == 10.0
|
||||
|
||||
# Select by area (climate_2)
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": bedroom_area.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
state = response.matched_states[0]
|
||||
assert state.attributes["current_temperature"] == 22.0
|
||||
|
||||
# Select by name (climate_2)
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": "Climate 2"}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
state = response.matched_states[0]
|
||||
assert state.attributes["current_temperature"] == 22.0
|
||||
|
||||
# Check area with no climate entities
|
||||
with pytest.raises(intent.MatchFailedError) as error:
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": office_area.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
|
||||
# Exception should contain details of what we tried to match
|
||||
assert isinstance(error.value, intent.MatchFailedError)
|
||||
assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA
|
||||
constraints = error.value.constraints
|
||||
assert constraints.name is None
|
||||
assert constraints.area_name == office_area.name
|
||||
assert constraints.domains and (set(constraints.domains) == {DOMAIN})
|
||||
assert constraints.device_classes is None
|
||||
|
||||
# Check wrong name
|
||||
with pytest.raises(intent.MatchFailedError) as error:
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": "Does not exist"}},
|
||||
)
|
||||
|
||||
assert isinstance(error.value, intent.MatchFailedError)
|
||||
assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME
|
||||
constraints = error.value.constraints
|
||||
assert constraints.name == "Does not exist"
|
||||
assert constraints.area_name is None
|
||||
assert constraints.domains and (set(constraints.domains) == {DOMAIN})
|
||||
assert constraints.device_classes is None
|
||||
|
||||
# Check wrong name with area
|
||||
with pytest.raises(intent.MatchFailedError) as error:
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}},
|
||||
)
|
||||
|
||||
assert isinstance(error.value, intent.MatchFailedError)
|
||||
assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA
|
||||
constraints = error.value.constraints
|
||||
assert constraints.name == "Climate 1"
|
||||
assert constraints.area_name == bedroom_area.name
|
||||
assert constraints.domains and (set(constraints.domains) == {DOMAIN})
|
||||
assert constraints.device_classes is None
|
||||
|
||||
|
||||
async def test_get_temperature_no_entities(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test HassClimateGetTemperature intent with no climate entities."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
await climate_intent.async_setup_intents(hass)
|
||||
|
||||
await create_mock_platform(hass, [])
|
||||
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN
|
||||
|
||||
|
||||
async def test_not_exposed(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test HassClimateGetTemperature intent when entities aren't exposed."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
await climate_intent.async_setup_intents(hass)
|
||||
|
||||
climate_1 = MockClimateEntity()
|
||||
climate_1._attr_name = "Climate 1"
|
||||
climate_1._attr_unique_id = "1234"
|
||||
climate_1._attr_current_temperature = 10.0
|
||||
entity_registry.async_get_or_create(
|
||||
DOMAIN, "test", "1234", suggested_object_id="climate_1"
|
||||
)
|
||||
|
||||
climate_2 = MockClimateEntity()
|
||||
climate_2._attr_name = "Climate 2"
|
||||
climate_2._attr_unique_id = "5678"
|
||||
climate_2._attr_current_temperature = 22.0
|
||||
entity_registry.async_get_or_create(
|
||||
DOMAIN, "test", "5678", suggested_object_id="climate_2"
|
||||
)
|
||||
|
||||
await create_mock_platform(hass, [climate_1, climate_2])
|
||||
|
||||
# Add climate entities to same area
|
||||
living_room_area = area_registry.async_create(name="Living Room")
|
||||
bedroom_area = area_registry.async_create(name="Bedroom")
|
||||
entity_registry.async_update_entity(
|
||||
climate_1.entity_id, area_id=living_room_area.id
|
||||
)
|
||||
entity_registry.async_update_entity(
|
||||
climate_2.entity_id, area_id=living_room_area.id
|
||||
)
|
||||
|
||||
# Should fail with empty name
|
||||
with pytest.raises(intent.InvalidSlotInfo):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": ""}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
|
||||
# Should fail with empty area
|
||||
with pytest.raises(intent.InvalidSlotInfo):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": ""}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
|
||||
# Expose second, hide first
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False)
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True)
|
||||
|
||||
# Second climate entity is exposed
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
|
||||
# Using the area should work
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": living_room_area.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
|
||||
# Using the name of the exposed entity should work
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": climate_2.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
|
||||
# Using the name of the *unexposed* entity should fail
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": climate_1.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
|
||||
|
||||
# Expose first, hide second
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True)
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False)
|
||||
|
||||
# Second climate entity is exposed
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_1.entity_id
|
||||
|
||||
# Wrong area name
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": bedroom_area.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA
|
||||
|
||||
# Neither are exposed
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False)
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False)
|
||||
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
|
||||
|
||||
# Should fail with area
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": living_room_area.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
|
||||
|
||||
# Should fail with both names
|
||||
for name in (climate_1.name, climate_2.name):
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
|
||||
|
||||
|
||||
async def test_set_temperature(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
|
456
tests/components/intent/test_temperature.py
Normal file
456
tests/components/intent/test_temperature.py
Normal file
@ -0,0 +1,456 @@
|
||||
"""Test temperature intents."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_TEMPERATURE,
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||
from homeassistant.const import Platform, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import area_registry as ar, entity_registry as er, intent
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
MockModule,
|
||||
MockPlatform,
|
||||
mock_config_flow,
|
||||
mock_integration,
|
||||
mock_platform,
|
||||
)
|
||||
|
||||
TEST_DOMAIN = "test"
|
||||
|
||||
|
||||
class MockFlow(ConfigFlow):
|
||||
"""Test flow."""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def config_flow_fixture(hass: HomeAssistant) -> Generator[None]:
|
||||
"""Mock config flow."""
|
||||
mock_platform(hass, f"{TEST_DOMAIN}.config_flow")
|
||||
|
||||
with mock_config_flow(TEST_DOMAIN, MockFlow):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_setup_integration(hass: HomeAssistant) -> None:
|
||||
"""Fixture to set up a mock integration."""
|
||||
|
||||
async def async_setup_entry_init(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up test config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
config_entry, [CLIMATE_DOMAIN]
|
||||
)
|
||||
return True
|
||||
|
||||
async def async_unload_entry_init(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
) -> bool:
|
||||
await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO])
|
||||
return True
|
||||
|
||||
mock_platform(hass, f"{TEST_DOMAIN}.config_flow")
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
TEST_DOMAIN,
|
||||
async_setup_entry=async_setup_entry_init,
|
||||
async_unload_entry=async_unload_entry_init,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def create_mock_platform(
|
||||
hass: HomeAssistant,
|
||||
entities: list[ClimateEntity],
|
||||
) -> MockConfigEntry:
|
||||
"""Create a todo platform with the specified entities."""
|
||||
|
||||
async def async_setup_entry_platform(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up test event platform via config entry."""
|
||||
async_add_entities(entities)
|
||||
|
||||
mock_platform(
|
||||
hass,
|
||||
f"{TEST_DOMAIN}.{CLIMATE_DOMAIN}",
|
||||
MockPlatform(async_setup_entry=async_setup_entry_platform),
|
||||
)
|
||||
|
||||
config_entry = MockConfigEntry(domain=TEST_DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return config_entry
|
||||
|
||||
|
||||
class MockClimateEntity(ClimateEntity):
|
||||
"""Mock Climate device to use in tests."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_hvac_mode = HVACMode.OFF
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the thermostat temperature."""
|
||||
value = kwargs[ATTR_TEMPERATURE]
|
||||
self._attr_target_temperature = value
|
||||
|
||||
|
||||
class MockClimateEntityNoSetTemperature(ClimateEntity):
|
||||
"""Mock Climate device to use in tests."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_hvac_mode = HVACMode.OFF
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
|
||||
|
||||
async def test_get_temperature(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test HassClimateGetTemperature intent."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "intent", {})
|
||||
|
||||
climate_1 = MockClimateEntity()
|
||||
climate_1._attr_name = "Climate 1"
|
||||
climate_1._attr_unique_id = "1234"
|
||||
climate_1._attr_current_temperature = 10.0
|
||||
entity_registry.async_get_or_create(
|
||||
CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1"
|
||||
)
|
||||
|
||||
climate_2 = MockClimateEntity()
|
||||
climate_2._attr_name = "Climate 2"
|
||||
climate_2._attr_unique_id = "5678"
|
||||
climate_2._attr_current_temperature = 22.0
|
||||
entity_registry.async_get_or_create(
|
||||
CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2"
|
||||
)
|
||||
|
||||
await create_mock_platform(hass, [climate_1, climate_2])
|
||||
|
||||
# Add climate entities to different areas:
|
||||
# climate_1 => living room
|
||||
# climate_2 => bedroom
|
||||
# nothing in office
|
||||
living_room_area = area_registry.async_create(name="Living Room")
|
||||
bedroom_area = area_registry.async_create(name="Bedroom")
|
||||
office_area = area_registry.async_create(name="Office")
|
||||
|
||||
entity_registry.async_update_entity(
|
||||
climate_1.entity_id, area_id=living_room_area.id
|
||||
)
|
||||
entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id)
|
||||
|
||||
# First climate entity will be selected (no area)
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert response.matched_states
|
||||
assert response.matched_states[0].entity_id == climate_1.entity_id
|
||||
state = response.matched_states[0]
|
||||
assert state.attributes["current_temperature"] == 10.0
|
||||
|
||||
# Select by area (climate_2)
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": bedroom_area.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
state = response.matched_states[0]
|
||||
assert state.attributes["current_temperature"] == 22.0
|
||||
|
||||
# Select by name (climate_2)
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": "Climate 2"}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
state = response.matched_states[0]
|
||||
assert state.attributes["current_temperature"] == 22.0
|
||||
|
||||
# Check area with no climate entities
|
||||
with pytest.raises(intent.MatchFailedError) as error:
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": office_area.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
|
||||
# Exception should contain details of what we tried to match
|
||||
assert isinstance(error.value, intent.MatchFailedError)
|
||||
assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA
|
||||
constraints = error.value.constraints
|
||||
assert constraints.name is None
|
||||
assert constraints.area_name == office_area.name
|
||||
assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN})
|
||||
assert constraints.device_classes is None
|
||||
|
||||
# Check wrong name
|
||||
with pytest.raises(intent.MatchFailedError) as error:
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": "Does not exist"}},
|
||||
)
|
||||
|
||||
assert isinstance(error.value, intent.MatchFailedError)
|
||||
assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME
|
||||
constraints = error.value.constraints
|
||||
assert constraints.name == "Does not exist"
|
||||
assert constraints.area_name is None
|
||||
assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN})
|
||||
assert constraints.device_classes is None
|
||||
|
||||
# Check wrong name with area
|
||||
with pytest.raises(intent.MatchFailedError) as error:
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}},
|
||||
)
|
||||
|
||||
assert isinstance(error.value, intent.MatchFailedError)
|
||||
assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA
|
||||
constraints = error.value.constraints
|
||||
assert constraints.name == "Climate 1"
|
||||
assert constraints.area_name == bedroom_area.name
|
||||
assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN})
|
||||
assert constraints.device_classes is None
|
||||
|
||||
|
||||
async def test_get_temperature_no_entities(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test HassClimateGetTemperature intent with no climate entities."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "intent", {})
|
||||
|
||||
await create_mock_platform(hass, [])
|
||||
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN
|
||||
|
||||
|
||||
async def test_not_exposed(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test HassClimateGetTemperature intent when entities aren't exposed."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "intent", {})
|
||||
|
||||
climate_1 = MockClimateEntity()
|
||||
climate_1._attr_name = "Climate 1"
|
||||
climate_1._attr_unique_id = "1234"
|
||||
climate_1._attr_current_temperature = 10.0
|
||||
entity_registry.async_get_or_create(
|
||||
CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1"
|
||||
)
|
||||
|
||||
climate_2 = MockClimateEntity()
|
||||
climate_2._attr_name = "Climate 2"
|
||||
climate_2._attr_unique_id = "5678"
|
||||
climate_2._attr_current_temperature = 22.0
|
||||
entity_registry.async_get_or_create(
|
||||
CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2"
|
||||
)
|
||||
|
||||
await create_mock_platform(hass, [climate_1, climate_2])
|
||||
|
||||
# Add climate entities to same area
|
||||
living_room_area = area_registry.async_create(name="Living Room")
|
||||
bedroom_area = area_registry.async_create(name="Bedroom")
|
||||
entity_registry.async_update_entity(
|
||||
climate_1.entity_id, area_id=living_room_area.id
|
||||
)
|
||||
entity_registry.async_update_entity(
|
||||
climate_2.entity_id, area_id=living_room_area.id
|
||||
)
|
||||
|
||||
# Should fail with empty name
|
||||
with pytest.raises(intent.InvalidSlotInfo):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": ""}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
|
||||
# Should fail with empty area
|
||||
with pytest.raises(intent.InvalidSlotInfo):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": ""}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
|
||||
# Expose second, hide first
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False)
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True)
|
||||
|
||||
# Second climate entity is exposed
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
|
||||
# Using the area should work
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": living_room_area.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
|
||||
# Using the name of the exposed entity should work
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": climate_2.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
|
||||
# Using the name of the *unexposed* entity should fail
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": climate_1.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
|
||||
|
||||
# Expose first, hide second
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True)
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False)
|
||||
|
||||
# Second climate entity is exposed
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_1.entity_id
|
||||
|
||||
# Wrong area name
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": bedroom_area.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA
|
||||
|
||||
# Neither are exposed
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False)
|
||||
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False)
|
||||
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
|
||||
|
||||
# Should fail with area
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": living_room_area.name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
|
||||
|
||||
# Should fail with both names
|
||||
for name in (climate_1.name, climate_2.name):
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_GET_TEMPERATURE,
|
||||
{"name": {"value": name}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
|
Loading…
x
Reference in New Issue
Block a user