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, 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,

View File

@ -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"

View File

@ -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

View File

@ -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