Skip to content
90 changes: 21 additions & 69 deletions homeassistant/components/climate/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@

import voluptuous as vol

from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
from homeassistant.helpers.entity_component import EntityComponent

from . import DOMAIN, ClimateEntity
from . import DOMAIN

INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"

Expand All @@ -23,82 +22,35 @@ class GetTemperatureIntent(intent.IntentHandler):

intent_type = INTENT_GET_TEMPERATURE
description = "Gets the current temperature of a climate device or entity"
slot_schema = {vol.Optional("area"): str, vol.Optional("name"): str}
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)

component: EntityComponent[ClimateEntity] = hass.data[DOMAIN]
entities: list[ClimateEntity] = list(component.entities)
climate_entity: ClimateEntity | None = None
climate_state: State | None = None
name: str | None = None
if "name" in slots:
name = slots["name"]["value"]

if not entities:
raise intent.IntentHandleError("No climate entities")
area: str | None = None
if "area" in slots:
area = slots["area"]["value"]

name_slot = slots.get("name", {})
entity_name: str | None = name_slot.get("value")
entity_text: str | None = name_slot.get("text")

area_slot = slots.get("area", {})
area_id = area_slot.get("value")

if area_id:
# Filter by area and optionally name
area_name = area_slot.get("text")

for maybe_climate in intent.async_match_states(
hass, name=entity_name, area_name=area_id, domains=[DOMAIN]
):
climate_state = maybe_climate
break

if climate_state is None:
raise intent.NoStatesMatchedError(
reason=intent.MatchFailedReason.AREA,
name=entity_text or entity_name,
area=area_name or area_id,
floor=None,
domains={DOMAIN},
device_classes=None,
)

climate_entity = component.get_entity(climate_state.entity_id)
elif entity_name:
# Filter by name
for maybe_climate in intent.async_match_states(
hass, name=entity_name, domains=[DOMAIN]
):
climate_state = maybe_climate
break

if climate_state is None:
raise intent.NoStatesMatchedError(
reason=intent.MatchFailedReason.NAME,
name=entity_name,
area=None,
floor=None,
domains={DOMAIN},
device_classes=None,
)

climate_entity = component.get_entity(climate_state.entity_id)
else:
# First entity
climate_entity = entities[0]
climate_state = hass.states.get(climate_entity.entity_id)

assert climate_entity is not None

if climate_state is None:
raise intent.IntentHandleError(f"No state for {climate_entity.name}")

assert climate_state is not None
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=[climate_state])
response.async_set_states(matched_states=match_result.states)
return response
45 changes: 23 additions & 22 deletions homeassistant/components/humidifier/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class HumidityHandler(intent.IntentHandler):
intent_type = INTENT_HUMIDITY
description = "Set desired humidity level"
slot_schema = {
vol.Required("name"): cv.string,
vol.Required("name"): intent.non_empty_string,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this translate to LLMs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It protects the user from them 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to prevent the LLM from sending in an empty string and targeting all entities.

vol.Required("humidity"): vol.All(vol.Coerce(int), vol.Range(0, 100)),
}
platforms = {DOMAIN}
Expand All @@ -44,18 +44,19 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse
"""Handle the hass intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
states = list(
intent.async_match_states(
hass,
name=slots["name"]["value"],
states=hass.states.async_all(DOMAIN),
)
)

if not states:
raise intent.IntentHandleError("No entities matched")
match_constraints = intent.MatchTargetsConstraints(
name=slots["name"]["value"],
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
)

state = states[0]
state = match_result.states[0]
service_data = {ATTR_ENTITY_ID: state.entity_id}

humidity = slots["humidity"]["value"]
Expand Down Expand Up @@ -89,7 +90,7 @@ class SetModeHandler(intent.IntentHandler):
intent_type = INTENT_MODE
description = "Set humidifier mode"
slot_schema = {
vol.Required("name"): cv.string,
vol.Required("name"): intent.non_empty_string,
vol.Required("mode"): cv.string,
}
platforms = {DOMAIN}
Expand All @@ -98,18 +99,18 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse
"""Handle the hass intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
states = list(
intent.async_match_states(
hass,
name=slots["name"]["value"],
states=hass.states.async_all(DOMAIN),
)
match_constraints = intent.MatchTargetsConstraints(
name=slots["name"]["value"],
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
)

if not states:
raise intent.IntentHandleError("No entities matched")

state = states[0]
state = match_result.states[0]
service_data = {ATTR_ENTITY_ID: state.entity_id}

intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes")
Expand Down
20 changes: 10 additions & 10 deletions homeassistant/components/todo/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent

from . import DOMAIN, TodoItem, TodoItemStatus, TodoListEntity
Expand All @@ -22,7 +21,7 @@ class ListAddItemIntent(intent.IntentHandler):

intent_type = INTENT_LIST_ADD_ITEM
description = "Add item to a todo list"
slot_schema = {"item": cv.string, "name": cv.string}
slot_schema = {"item": intent.non_empty_string, "name": intent.non_empty_string}
platforms = {DOMAIN}

async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
Expand All @@ -37,18 +36,19 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse
target_list: TodoListEntity | None = None

# Find matching list
for list_state in intent.async_match_states(
hass, name=list_name, domains=[DOMAIN]
):
target_list = component.get_entity(list_state.entity_id)
if target_list is not None:
break
match_constraints = intent.MatchTargetsConstraints(
name=list_name, 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
)

target_list = component.get_entity(match_result.states[0].entity_id)
if target_list is None:
raise intent.IntentHandleError(f"No to-do list: {list_name}")

assert target_list is not None

# Add to list
await target_list.async_create_todo_item(
TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION)
Expand Down
52 changes: 14 additions & 38 deletions homeassistant/components/weather/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@

from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import intent
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent

from . import DOMAIN, WeatherEntity
from . import DOMAIN

INTENT_GET_WEATHER = "HassGetWeather"

Expand All @@ -24,51 +22,29 @@ class GetWeatherIntent(intent.IntentHandler):

intent_type = INTENT_GET_WEATHER
description = "Gets the current weather"
slot_schema = {vol.Optional("name"): cv.string}
slot_schema = {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)

weather: WeatherEntity | None = None
weather_state: State | None = None
component: EntityComponent[WeatherEntity] = hass.data[DOMAIN]
entities = list(component.entities)

name: str | None = None
if "name" in slots:
# Named weather entity
weather_name = slots["name"]["value"]
name = slots["name"]["value"]

# Find matching weather entity
matching_states = intent.async_match_states(
hass, name=weather_name, domains=[DOMAIN]
match_constraints = intent.MatchTargetsConstraints(
name=name, 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
)
for maybe_weather_state in matching_states:
weather = component.get_entity(maybe_weather_state.entity_id)
if weather is not None:
weather_state = maybe_weather_state
break

if weather is None:
raise intent.IntentHandleError(
f"No weather entity named {weather_name}"
)
elif entities:
# First weather entity
weather = entities[0]
weather_name = weather.name
weather_state = hass.states.get(weather.entity_id)

if weather is None:
raise intent.IntentHandleError("No weather entity")

if weather_state is None:
raise intent.IntentHandleError(f"No state for weather: {weather.name}")

assert weather is not None
assert weather_state is not None
weather_state = match_result.states[0]

# Create response
response = intent_obj.create_response()
Expand All @@ -77,8 +53,8 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse
success_results=[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
name=weather_name,
id=weather.entity_id,
name=weather_state.name,
id=weather_state.entity_id,
)
]
)
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/helpers/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@ def async_match_states(
domains: Collection[str] | None = None,
device_classes: Collection[str] | None = None,
states: list[State] | None = None,
assistant: str | None = None,
) -> Iterable[State]:
"""Simplified interface to async_match_targets that returns states matching the constraints."""
result = async_match_targets(
Expand All @@ -722,6 +723,7 @@ def async_match_states(
floor_name=floor_name,
domains=domains,
device_classes=device_classes,
assistant=assistant,
),
states=states,
)
Expand Down
Loading