mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Add HassClimateSetTemperature (#136484)
* Add HassClimateSetTemperature * Use single target constraint
This commit is contained in:
parent
58b4556a1d
commit
b633a0424a
@ -69,6 +69,7 @@ from .const import ( # noqa: F401
|
|||||||
FAN_TOP,
|
FAN_TOP,
|
||||||
HVAC_MODES,
|
HVAC_MODES,
|
||||||
INTENT_GET_TEMPERATURE,
|
INTENT_GET_TEMPERATURE,
|
||||||
|
INTENT_SET_TEMPERATURE,
|
||||||
PRESET_ACTIVITY,
|
PRESET_ACTIVITY,
|
||||||
PRESET_AWAY,
|
PRESET_AWAY,
|
||||||
PRESET_BOOST,
|
PRESET_BOOST,
|
||||||
|
@ -127,6 +127,7 @@ DEFAULT_MAX_HUMIDITY = 99
|
|||||||
DOMAIN = "climate"
|
DOMAIN = "climate"
|
||||||
|
|
||||||
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
|
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
|
||||||
|
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
|
||||||
|
|
||||||
SERVICE_SET_AUX_HEAT = "set_aux_heat"
|
SERVICE_SET_AUX_HEAT = "set_aux_heat"
|
||||||
SERVICE_SET_FAN_MODE = "set_fan_mode"
|
SERVICE_SET_FAN_MODE = "set_fan_mode"
|
||||||
|
@ -4,15 +4,24 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import intent
|
from homeassistant.helpers import config_validation as cv, intent
|
||||||
|
|
||||||
from . import DOMAIN, INTENT_GET_TEMPERATURE
|
from . import (
|
||||||
|
ATTR_TEMPERATURE,
|
||||||
|
DOMAIN,
|
||||||
|
INTENT_GET_TEMPERATURE,
|
||||||
|
INTENT_SET_TEMPERATURE,
|
||||||
|
SERVICE_SET_TEMPERATURE,
|
||||||
|
ClimateEntityFeature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_intents(hass: HomeAssistant) -> None:
|
async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||||
"""Set up the climate intents."""
|
"""Set up the climate intents."""
|
||||||
intent.async_register(hass, GetTemperatureIntent())
|
intent.async_register(hass, GetTemperatureIntent())
|
||||||
|
intent.async_register(hass, SetTemperatureIntent())
|
||||||
|
|
||||||
|
|
||||||
class GetTemperatureIntent(intent.IntentHandler):
|
class GetTemperatureIntent(intent.IntentHandler):
|
||||||
@ -52,3 +61,84 @@ class GetTemperatureIntent(intent.IntentHandler):
|
|||||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||||
response.async_set_states(matched_states=match_result.states)
|
response.async_set_states(matched_states=match_result.states)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class SetTemperatureIntent(intent.IntentHandler):
|
||||||
|
"""Handle SetTemperature intents."""
|
||||||
|
|
||||||
|
intent_type = INTENT_SET_TEMPERATURE
|
||||||
|
description = "Sets the target temperature of a climate device or entity"
|
||||||
|
slot_schema = {
|
||||||
|
vol.Required("temperature"): vol.Coerce(float),
|
||||||
|
vol.Optional("area"): intent.non_empty_string,
|
||||||
|
vol.Optional("name"): intent.non_empty_string,
|
||||||
|
vol.Optional("floor"): intent.non_empty_string,
|
||||||
|
vol.Optional("preferred_area_id"): cv.string,
|
||||||
|
vol.Optional("preferred_floor_id"): cv.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)
|
||||||
|
|
||||||
|
temperature: float = slots["temperature"]["value"]
|
||||||
|
|
||||||
|
name: str | None = None
|
||||||
|
if "name" in slots:
|
||||||
|
name = slots["name"]["value"]
|
||||||
|
|
||||||
|
area_name: str | None = None
|
||||||
|
if "area" in slots:
|
||||||
|
area_name = slots["area"]["value"]
|
||||||
|
|
||||||
|
floor_name: str | None = None
|
||||||
|
if "floor" in slots:
|
||||||
|
floor_name = slots["floor"]["value"]
|
||||||
|
|
||||||
|
match_constraints = intent.MatchTargetsConstraints(
|
||||||
|
name=name,
|
||||||
|
area_name=area_name,
|
||||||
|
floor_name=floor_name,
|
||||||
|
domains=[DOMAIN],
|
||||||
|
assistant=intent_obj.assistant,
|
||||||
|
features=ClimateEntityFeature.TARGET_TEMPERATURE,
|
||||||
|
single_target=True,
|
||||||
|
)
|
||||||
|
match_preferences = intent.MatchTargetsPreferences(
|
||||||
|
area_id=slots.get("preferred_area_id", {}).get("value"),
|
||||||
|
floor_id=slots.get("preferred_floor_id", {}).get("value"),
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
assert match_result.states
|
||||||
|
climate_state = match_result.states[0]
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SET_TEMPERATURE,
|
||||||
|
service_data={ATTR_TEMPERATURE: temperature},
|
||||||
|
target={ATTR_ENTITY_ID: climate_state.entity_id},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = intent_obj.create_response()
|
||||||
|
response.response_type = intent.IntentResponseType.ACTION_DONE
|
||||||
|
response.async_set_results(
|
||||||
|
success_results=[
|
||||||
|
intent.IntentResponseTarget(
|
||||||
|
type=intent.IntentResponseTargetType.ENTITY,
|
||||||
|
name=climate_state.name,
|
||||||
|
id=climate_state.entity_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
response.async_set_states(matched_states=[climate_state])
|
||||||
|
return response
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
"""Test climate intents."""
|
"""Test climate intents."""
|
||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components import conversation
|
from homeassistant.components import conversation
|
||||||
from homeassistant.components.climate import (
|
from homeassistant.components.climate import (
|
||||||
|
ATTR_TEMPERATURE,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ClimateEntity,
|
ClimateEntity,
|
||||||
|
ClimateEntityFeature,
|
||||||
HVACMode,
|
HVACMode,
|
||||||
intent as climate_intent,
|
intent as climate_intent,
|
||||||
)
|
)
|
||||||
@ -15,7 +18,12 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose
|
|||||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||||
from homeassistant.const import Platform, UnitOfTemperature
|
from homeassistant.const import Platform, UnitOfTemperature
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import area_registry as ar, entity_registry as er, intent
|
from homeassistant.helpers import (
|
||||||
|
area_registry as ar,
|
||||||
|
entity_registry as er,
|
||||||
|
floor_registry as fr,
|
||||||
|
intent,
|
||||||
|
)
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
@ -107,6 +115,20 @@ class MockClimateEntity(ClimateEntity):
|
|||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
_attr_hvac_mode = HVACMode.OFF
|
_attr_hvac_mode = HVACMode.OFF
|
||||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
|
_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(
|
async def test_get_temperature(
|
||||||
@ -436,3 +458,231 @@ async def test_not_exposed(
|
|||||||
assistant=conversation.DOMAIN,
|
assistant=conversation.DOMAIN,
|
||||||
)
|
)
|
||||||
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
|
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_temperature(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
area_registry: ar.AreaRegistry,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
floor_registry: fr.FloorRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Test HassClimateSetTemperature 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
|
||||||
|
climate_1._attr_target_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
|
||||||
|
climate_2._attr_target_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)
|
||||||
|
|
||||||
|
# Put areas on different floors:
|
||||||
|
# first floor => living room and office
|
||||||
|
# upstairs => bedroom
|
||||||
|
floor_registry = fr.async_get(hass)
|
||||||
|
first_floor = floor_registry.async_create("First floor")
|
||||||
|
living_room_area = area_registry.async_update(
|
||||||
|
living_room_area.id, floor_id=first_floor.floor_id
|
||||||
|
)
|
||||||
|
office_area = area_registry.async_update(
|
||||||
|
office_area.id, floor_id=first_floor.floor_id
|
||||||
|
)
|
||||||
|
|
||||||
|
second_floor = floor_registry.async_create("Second floor")
|
||||||
|
bedroom_area = area_registry.async_update(
|
||||||
|
bedroom_area.id, floor_id=second_floor.floor_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cannot target multiple climate devices
|
||||||
|
with pytest.raises(intent.MatchFailedError) as err:
|
||||||
|
await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
climate_intent.INTENT_SET_TEMPERATURE,
|
||||||
|
{"temperature": {"value": 20}},
|
||||||
|
assistant=conversation.DOMAIN,
|
||||||
|
)
|
||||||
|
assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS
|
||||||
|
|
||||||
|
# Select by area explicitly (climate_2)
|
||||||
|
response = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
climate_intent.INTENT_SET_TEMPERATURE,
|
||||||
|
{"area": {"value": bedroom_area.name}, "temperature": {"value": 20.1}},
|
||||||
|
assistant=conversation.DOMAIN,
|
||||||
|
)
|
||||||
|
assert response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
assert len(response.matched_states) == 1
|
||||||
|
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||||
|
state = hass.states.get(climate_2.entity_id)
|
||||||
|
assert state.attributes[ATTR_TEMPERATURE] == 20.1
|
||||||
|
|
||||||
|
# Select by area implicitly (climate_2)
|
||||||
|
response = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
climate_intent.INTENT_SET_TEMPERATURE,
|
||||||
|
{
|
||||||
|
"preferred_area_id": {"value": bedroom_area.id},
|
||||||
|
"temperature": {"value": 20.2},
|
||||||
|
},
|
||||||
|
assistant=conversation.DOMAIN,
|
||||||
|
)
|
||||||
|
assert response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
assert response.matched_states
|
||||||
|
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||||
|
state = hass.states.get(climate_2.entity_id)
|
||||||
|
assert state.attributes[ATTR_TEMPERATURE] == 20.2
|
||||||
|
|
||||||
|
# Select by floor explicitly (climate_2)
|
||||||
|
response = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
climate_intent.INTENT_SET_TEMPERATURE,
|
||||||
|
{"floor": {"value": second_floor.name}, "temperature": {"value": 20.3}},
|
||||||
|
assistant=conversation.DOMAIN,
|
||||||
|
)
|
||||||
|
assert response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
assert response.matched_states
|
||||||
|
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||||
|
state = hass.states.get(climate_2.entity_id)
|
||||||
|
assert state.attributes[ATTR_TEMPERATURE] == 20.3
|
||||||
|
|
||||||
|
# Select by floor implicitly (climate_2)
|
||||||
|
response = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
climate_intent.INTENT_SET_TEMPERATURE,
|
||||||
|
{
|
||||||
|
"preferred_floor_id": {"value": second_floor.floor_id},
|
||||||
|
"temperature": {"value": 20.4},
|
||||||
|
},
|
||||||
|
assistant=conversation.DOMAIN,
|
||||||
|
)
|
||||||
|
assert response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
assert response.matched_states
|
||||||
|
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||||
|
state = hass.states.get(climate_2.entity_id)
|
||||||
|
assert state.attributes[ATTR_TEMPERATURE] == 20.4
|
||||||
|
|
||||||
|
# Select by name (climate_2)
|
||||||
|
response = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
climate_intent.INTENT_SET_TEMPERATURE,
|
||||||
|
{"name": {"value": "Climate 2"}, "temperature": {"value": 20.5}},
|
||||||
|
assistant=conversation.DOMAIN,
|
||||||
|
)
|
||||||
|
assert response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
assert len(response.matched_states) == 1
|
||||||
|
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||||
|
state = hass.states.get(climate_2.entity_id)
|
||||||
|
assert state.attributes[ATTR_TEMPERATURE] == 20.5
|
||||||
|
|
||||||
|
# Check area with no climate entities (explicit)
|
||||||
|
with pytest.raises(intent.MatchFailedError) as error:
|
||||||
|
response = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
climate_intent.INTENT_SET_TEMPERATURE,
|
||||||
|
{"area": {"value": office_area.name}, "temperature": {"value": 20.6}},
|
||||||
|
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
|
||||||
|
|
||||||
|
# Implicit area with no climate entities will fail with multiple targets
|
||||||
|
with pytest.raises(intent.MatchFailedError) as err:
|
||||||
|
await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
climate_intent.INTENT_SET_TEMPERATURE,
|
||||||
|
{
|
||||||
|
"preferred_area_id": {"value": office_area.id},
|
||||||
|
"temperature": {"value": 20.7},
|
||||||
|
},
|
||||||
|
assistant=conversation.DOMAIN,
|
||||||
|
)
|
||||||
|
assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_temperature_no_entities(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> None:
|
||||||
|
"""Test HassClimateSetTemperature 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_SET_TEMPERATURE,
|
||||||
|
{"temperature": {"value": 20}},
|
||||||
|
assistant=conversation.DOMAIN,
|
||||||
|
)
|
||||||
|
assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_temperature_not_supported(hass: HomeAssistant) -> None:
|
||||||
|
"""Test HassClimateSetTemperature intent when climate entity doesn't support required feature."""
|
||||||
|
assert await async_setup_component(hass, "homeassistant", {})
|
||||||
|
await climate_intent.async_setup_intents(hass)
|
||||||
|
|
||||||
|
climate_1 = MockClimateEntityNoSetTemperature()
|
||||||
|
climate_1._attr_name = "Climate 1"
|
||||||
|
climate_1._attr_unique_id = "1234"
|
||||||
|
climate_1._attr_current_temperature = 10.0
|
||||||
|
climate_1._attr_target_temperature = 10.0
|
||||||
|
|
||||||
|
await create_mock_platform(hass, [climate_1])
|
||||||
|
|
||||||
|
with pytest.raises(intent.MatchFailedError) as error:
|
||||||
|
await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
climate_intent.INTENT_SET_TEMPERATURE,
|
||||||
|
{"temperature": {"value": 20.0}},
|
||||||
|
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.FEATURE
|
||||||
|
Loading…
x
Reference in New Issue
Block a user