Check area temperature sensors in get temperature intent (#139221)

* Check area temperature sensors in get temperature intent

* Fix candidate check

* Add new code back in

* Remove cruft from climate
This commit is contained in:
Michael Hansen 2025-02-28 12:02:30 -06:00 committed by GitHub
parent e9bb4625d8
commit 49c27ae7bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 247 additions and 21 deletions

View File

@ -2,13 +2,14 @@
from __future__ import annotations
from collections.abc import Collection
import logging
from typing import Any, Protocol
from aiohttp import web
import voluptuous as vol
from homeassistant.components import http
from homeassistant.components import http, sensor
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import (
ATTR_POSITION,
@ -40,7 +41,12 @@ from homeassistant.const import (
SERVICE_TURN_ON,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State
from homeassistant.helpers import config_validation as cv, integration_platform, intent
from homeassistant.helpers import (
area_registry as ar,
config_validation as cv,
integration_platform,
intent,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
@ -454,6 +460,9 @@ class GetTemperatureIntent(intent.IntentHandler):
slot_schema = {
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 = {CLIMATE_DOMAIN}
@ -470,13 +479,71 @@ class GetTemperatureIntent(intent.IntentHandler):
if "area" in slots:
area = slots["area"]["value"]
floor_name: str | None = None
if "floor" in slots:
floor_name = slots["floor"]["value"]
match_preferences = intent.MatchTargetsPreferences(
area_id=slots.get("preferred_area_id", {}).get("value"),
floor_id=slots.get("preferred_floor_id", {}).get("value"),
)
if (not name) and (area or match_preferences.area_id):
# Look for temperature sensors assigned to an area
area_registry = ar.async_get(hass)
area_temperature_ids: dict[str, str] = {}
# Keep candidates that are registered as area temperature sensors
def area_candidate_filter(
candidate: intent.MatchTargetsCandidate,
possible_area_ids: Collection[str],
) -> bool:
for area_id in possible_area_ids:
temperature_id = area_temperature_ids.get(area_id)
if (temperature_id is None) and (
area_entry := area_registry.async_get_area(area_id)
):
temperature_id = area_entry.temperature_entity_id or ""
area_temperature_ids[area_id] = temperature_id
if candidate.state.entity_id == temperature_id:
return True
return False
match_constraints = intent.MatchTargetsConstraints(
area_name=area,
floor_name=floor_name,
domains=[sensor.DOMAIN],
device_classes=[sensor.SensorDeviceClass.TEMPERATURE],
assistant=intent_obj.assistant,
single_target=True,
)
match_result = intent.async_match_targets(
hass,
match_constraints,
match_preferences,
area_candidate_filter=area_candidate_filter,
)
if match_result.is_match:
# Found temperature sensor
response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER
response.async_set_states(matched_states=match_result.states)
return response
# Look for climate devices
match_constraints = intent.MatchTargetsConstraints(
name=name,
area_name=area,
floor_name=floor_name,
domains=[CLIMATE_DOMAIN],
assistant=intent_obj.assistant,
single_target=True,
)
match_result = intent.async_match_targets(
hass, match_constraints, match_preferences
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints

View File

@ -507,12 +507,22 @@ def _add_areas(
candidate.area = areas.async_get_area(candidate.device.area_id)
def _default_area_candidate_filter(
candidate: MatchTargetsCandidate, possible_area_ids: Collection[str]
) -> bool:
"""Keep candidates in the possible areas."""
return (candidate.area is not None) and (candidate.area.id in possible_area_ids)
@callback
def async_match_targets( # noqa: C901
hass: HomeAssistant,
constraints: MatchTargetsConstraints,
preferences: MatchTargetsPreferences | None = None,
states: list[State] | None = None,
area_candidate_filter: Callable[
[MatchTargetsCandidate, Collection[str]], bool
] = _default_area_candidate_filter,
) -> MatchTargetsResult:
"""Match entities based on constraints in order to handle an intent."""
preferences = preferences or MatchTargetsPreferences()
@ -623,9 +633,7 @@ def async_match_targets( # noqa: C901
}
candidates = [
c
for c in candidates
if (c.area is not None) and (c.area.id in possible_area_ids)
c for c in candidates if area_candidate_filter(c, possible_area_ids)
]
if not candidates:
return MatchTargetsResult(
@ -649,9 +657,7 @@ def async_match_targets( # noqa: C901
# May be constrained by floors above
possible_area_ids.intersection_update(matching_area_ids)
candidates = [
c
for c in candidates
if (c.area is not None) and (c.area.id in possible_area_ids)
c for c in candidates if area_candidate_filter(c, possible_area_ids)
]
if not candidates:
return MatchTargetsResult(
@ -701,7 +707,7 @@ def async_match_targets( # noqa: C901
group_candidates = [
c
for c in group_candidates
if (c.area is not None) and (c.area.id == preferences.area_id)
if area_candidate_filter(c, {preferences.area_id})
]
if len(group_candidates) < 2:
# Disambiguated by area
@ -747,7 +753,7 @@ def async_match_targets( # noqa: C901
if preferences.area_id:
# Filter by area
filtered_candidates = [
c for c in candidates if c.area and (c.area.id == preferences.area_id)
c for c in candidates if area_candidate_filter(c, {preferences.area_id})
]
if (len(filtered_candidates) > 1) and preferences.floor_id:

View File

@ -14,10 +14,16 @@ from homeassistant.components.climate import (
HVACMode,
)
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import Platform, UnitOfTemperature
from homeassistant.const import ATTR_DEVICE_CLASS, 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 AddConfigEntryEntitiesCallback
from homeassistant.setup import async_setup_component
@ -131,6 +137,7 @@ async def test_get_temperature(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
floor_registry: fr.FloorRegistry,
) -> None:
"""Test HassClimateGetTemperature intent."""
assert await async_setup_component(hass, "homeassistant", {})
@ -157,29 +164,133 @@ async def test_get_temperature(
# Add climate entities to different areas:
# climate_1 => living room
# climate_2 => bedroom
# nothing in office
# nothing in bathroom
# nothing in office yet
# nothing in attic yet
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")
attic_area = area_registry.async_create(name="Attic")
bathroom_area = area_registry.async_create(name="Bathroom")
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(
# Put areas on different floors:
# first floor => living room and office
# 2nd floor => bedroom
# 3rd floor => attic
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
)
bathroom_area = area_registry.async_update(
bathroom_area.id, floor_id=second_floor.floor_id
)
third_floor = floor_registry.async_create("Third floor")
attic_area = area_registry.async_update(
attic_area.id, floor_id=third_floor.floor_id
)
# Add temperature sensors to each area that should *not* be selected
for area in (living_room_area, office_area, bedroom_area, attic_area):
wrong_temperature_entry = entity_registry.async_get_or_create(
"sensor", "test", f"wrong_temperature_{area.id}"
)
hass.states.async_set(
wrong_temperature_entry.entity_id,
"10.0",
{
ATTR_TEMPERATURE: "Temperature",
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
},
)
entity_registry.async_update_entity(
wrong_temperature_entry.entity_id, area_id=area.id
)
# Create temperature sensor and assign them to the office/attic
office_temperature_id = "sensor.office_temperature"
attic_temperature_id = "sensor.attic_temperature"
hass.states.async_set(
office_temperature_id,
"15.5",
{
ATTR_TEMPERATURE: "Temperature",
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
},
)
office_area = area_registry.async_update(
office_area.id, temperature_entity_id=office_temperature_id
)
hass.states.async_set(
attic_temperature_id,
"18.1",
{
ATTR_TEMPERATURE: "Temperature",
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
},
)
attic_area = area_registry.async_update(
attic_area.id, temperature_entity_id=attic_temperature_id
)
# Multiple climate entities match (error)
with pytest.raises(intent.MatchFailedError) as error:
await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{},
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.MULTIPLE_TARGETS
)
# Select by area (office_temperature)
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"area": {"value": office_area.name}},
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
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == office_temperature_id
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 10.0
assert state.state == "15.5"
# Select by preferred area (attic_temperature)
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"preferred_area_id": {"value": attic_area.id}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == attic_temperature_id
state = response.matched_states[0]
assert state.state == "18.1"
# Select by area (climate_2)
response = await intent.async_handle(
@ -215,7 +326,7 @@ async def test_get_temperature(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"area": {"value": office_area.name}},
{"area": {"value": bathroom_area.name}},
assistant=conversation.DOMAIN,
)
@ -224,7 +335,7 @@ async def test_get_temperature(
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.area_name == bathroom_area.name
assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN})
assert constraints.device_classes is None
@ -262,6 +373,48 @@ async def test_get_temperature(
assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN})
assert constraints.device_classes is None
# Select by floor (climate_1)
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"floor": {"value": first_floor.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_1.entity_id
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 10.0
# Select by preferred area (climate_2)
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"preferred_area_id": {"value": bedroom_area.id}},
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 preferred floor (climate_1)
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"preferred_floor_id": {"value": first_floor.floor_id}},
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
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 10.0
async def test_get_temperature_no_entities(
hass: HomeAssistant,