Add HassClimateSetTemperature (#136484)

* Add HassClimateSetTemperature

* Use single target constraint
This commit is contained in:
Michael Hansen 2025-01-27 14:18:31 -06:00 committed by GitHub
parent 58b4556a1d
commit b633a0424a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 345 additions and 3 deletions

View File

@ -69,6 +69,7 @@ from .const import ( # noqa: F401
FAN_TOP,
HVAC_MODES,
INTENT_GET_TEMPERATURE,
INTENT_SET_TEMPERATURE,
PRESET_ACTIVITY,
PRESET_AWAY,
PRESET_BOOST,

View File

@ -127,6 +127,7 @@ DEFAULT_MAX_HUMIDITY = 99
DOMAIN = "climate"
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
SERVICE_SET_AUX_HEAT = "set_aux_heat"
SERVICE_SET_FAN_MODE = "set_fan_mode"

View File

@ -4,15 +4,24 @@ from __future__ import annotations
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID
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:
"""Set up the climate intents."""
intent.async_register(hass, GetTemperatureIntent())
intent.async_register(hass, SetTemperatureIntent())
class GetTemperatureIntent(intent.IntentHandler):
@ -52,3 +61,84 @@ class GetTemperatureIntent(intent.IntentHandler):
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."""
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

View File

@ -1,13 +1,16 @@
"""Test climate 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,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
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.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 import (
area_registry as ar,
entity_registry as er,
floor_registry as fr,
intent,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.setup import async_setup_component
@ -107,6 +115,20 @@ class MockClimateEntity(ClimateEntity):
_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(
@ -436,3 +458,231 @@ async def test_not_exposed(
assistant=conversation.DOMAIN,
)
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