mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
Add HassClimateSetFanMode intent for climate entities (#181310)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5
Copilot Autofix powered by AI
parent
842647d911
commit
93ecba49bd
@@ -63,6 +63,7 @@ from .const import ( # noqa: F401
|
||||
FAN_ON,
|
||||
FAN_TOP,
|
||||
HVAC_MODES,
|
||||
INTENT_SET_FAN_MODE,
|
||||
INTENT_SET_TEMPERATURE,
|
||||
PRESET_ACTIVITY,
|
||||
PRESET_AWAY,
|
||||
|
||||
@@ -129,6 +129,7 @@ DEFAULT_MAX_HUMIDITY = 99
|
||||
|
||||
DOMAIN: Final = "climate"
|
||||
|
||||
INTENT_SET_FAN_MODE = "HassClimateSetFanMode"
|
||||
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
|
||||
|
||||
SERVICE_SET_FAN_MODE = "set_fan_mode"
|
||||
|
||||
@@ -5,21 +5,30 @@ from typing import override
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, intent
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import config_validation as cv, intent, translation
|
||||
|
||||
from . import (
|
||||
ATTR_FAN_MODE,
|
||||
ATTR_FAN_MODES,
|
||||
ATTR_TEMPERATURE,
|
||||
DOMAIN,
|
||||
INTENT_SET_FAN_MODE,
|
||||
INTENT_SET_TEMPERATURE,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
ClimateEntityFeature,
|
||||
)
|
||||
|
||||
FAN_MODE_TRANSLATION_PREFIX = (
|
||||
f"component.{DOMAIN}.entity_component._.state_attributes.{ATTR_FAN_MODE}.state."
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
"""Set up the climate intents."""
|
||||
intent.async_register(hass, SetTemperatureIntent())
|
||||
intent.async_register(hass, SetFanModeIntent())
|
||||
|
||||
|
||||
class SetTemperatureIntent(intent.IntentHandler):
|
||||
@@ -101,3 +110,118 @@ class SetTemperatureIntent(intent.IntentHandler):
|
||||
)
|
||||
response.async_set_states(matched_states=[climate_state])
|
||||
return response
|
||||
|
||||
|
||||
class SetFanModeIntent(intent.IntentHandler):
|
||||
"""Handle SetFanMode intents."""
|
||||
|
||||
intent_type = INTENT_SET_FAN_MODE
|
||||
description = "Sets the fan mode of a climate device or entity"
|
||||
slot_schema = {
|
||||
vol.Required("fan_mode"): intent.non_empty_string,
|
||||
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}
|
||||
|
||||
@override
|
||||
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)
|
||||
|
||||
requested_fan_mode: str = slots["fan_mode"]["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.FAN_MODE,
|
||||
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]
|
||||
|
||||
fan_mode = await _async_resolve_fan_mode(
|
||||
hass, intent_obj.language, climate_state, requested_fan_mode
|
||||
)
|
||||
if fan_mode is None:
|
||||
raise intent.IntentHandleError(
|
||||
f"Fan mode {requested_fan_mode} is not supported by "
|
||||
f"{climate_state.name}"
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
service_data={ATTR_FAN_MODE: fan_mode},
|
||||
target={ATTR_ENTITY_ID: climate_state.entity_id},
|
||||
blocking=True,
|
||||
context=intent_obj.context,
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
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
|
||||
|
||||
|
||||
async def _async_resolve_fan_mode(
|
||||
hass: HomeAssistant, language: str, climate_state: State, requested: str
|
||||
) -> str | None:
|
||||
"""Return a matching fan mode using translations if necessary."""
|
||||
available: list[str] = climate_state.attributes.get(ATTR_FAN_MODES) or []
|
||||
if requested in available:
|
||||
return requested
|
||||
|
||||
by_casefold = {mode.casefold(): mode for mode in available}
|
||||
if (fan_mode := by_casefold.get(requested.casefold())) is not None:
|
||||
return fan_mode
|
||||
|
||||
translations = await translation.async_get_translations(
|
||||
hass, language, "entity_component", {DOMAIN}
|
||||
)
|
||||
for key, localized in translations.items():
|
||||
if key.startswith(FAN_MODE_TRANSLATION_PREFIX) and (
|
||||
localized.casefold() == requested.casefold()
|
||||
):
|
||||
return by_casefold.get(key.removeprefix(FAN_MODE_TRANSLATION_PREFIX))
|
||||
|
||||
return None
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_FAN_MODE,
|
||||
ATTR_TEMPERATURE,
|
||||
DOMAIN,
|
||||
ClimateEntity,
|
||||
@@ -132,6 +134,71 @@ class MockClimateEntityNoSetTemperature(ClimateEntity):
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
|
||||
|
||||
class MockClimateEntityWithFanMode(ClimateEntity):
|
||||
"""Mock Climate device with fan mode support to use in tests."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_hvac_mode = HVACMode.OFF
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
|
||||
_attr_supported_features = ClimateEntityFeature.FAN_MODE
|
||||
# Mixed casing and a vendor-specific mode, as real integrations report.
|
||||
_attr_fan_modes = ["auto", "Low", "Turbo"]
|
||||
_attr_fan_mode = "auto"
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set the fan mode."""
|
||||
self._attr_fan_mode = fan_mode
|
||||
|
||||
|
||||
class MockClimateEntityNoFanModes(MockClimateEntityWithFanMode):
|
||||
"""Mock Climate device claiming fan mode support without reporting modes."""
|
||||
|
||||
_attr_fan_modes = None
|
||||
_attr_fan_mode = None
|
||||
|
||||
|
||||
async def setup_fan_mode_entities(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
floor_registry: fr.FloorRegistry,
|
||||
) -> tuple[ClimateEntity, ClimateEntity]:
|
||||
"""Set up two fan mode capable entities in separate areas and floors.
|
||||
|
||||
climate_1 => Living Room => First floor
|
||||
climate_2 => Bedroom => Second floor
|
||||
"""
|
||||
climate_1 = MockClimateEntityWithFanMode()
|
||||
climate_1._attr_name = "Climate 1"
|
||||
climate_1._attr_unique_id = "1234"
|
||||
entity_registry.async_get_or_create(
|
||||
DOMAIN, "test", "1234", suggested_object_id="climate_1"
|
||||
)
|
||||
|
||||
climate_2 = MockClimateEntityWithFanMode()
|
||||
climate_2._attr_name = "Climate 2"
|
||||
climate_2._attr_unique_id = "5678"
|
||||
entity_registry.async_get_or_create(
|
||||
DOMAIN, "test", "5678", suggested_object_id="climate_2"
|
||||
)
|
||||
|
||||
await create_mock_platform(hass, [climate_1, climate_2])
|
||||
|
||||
living_room_area = area_registry.async_create(name="Living Room")
|
||||
bedroom_area = area_registry.async_create(name="Bedroom")
|
||||
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_floor = floor_registry.async_create("First floor")
|
||||
second_floor = floor_registry.async_create("Second floor")
|
||||
area_registry.async_update(living_room_area.id, floor_id=first_floor.floor_id)
|
||||
area_registry.async_update(bedroom_area.id, floor_id=second_floor.floor_id)
|
||||
|
||||
return climate_1, climate_2
|
||||
|
||||
|
||||
async def test_set_temperature(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
@@ -357,3 +424,253 @@ async def test_set_temperature_not_supported(hass: HomeAssistant) -> None:
|
||||
# Exception should contain details of what we tried to match
|
||||
assert isinstance(error.value, intent.MatchFailedError)
|
||||
assert error.value.result.no_match_reason is intent.MatchFailedReason.FEATURE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("requested_fan_mode", "expected_fan_mode"),
|
||||
[
|
||||
pytest.param("auto", "auto", id="exact"),
|
||||
pytest.param("LOW", "Low", id="differing_case"),
|
||||
pytest.param("Turbo", "Turbo", id="vendor_specific"),
|
||||
],
|
||||
)
|
||||
async def test_set_fan_mode(
|
||||
hass: HomeAssistant,
|
||||
requested_fan_mode: str,
|
||||
expected_fan_mode: str,
|
||||
) -> None:
|
||||
"""Test HassClimateSetFanMode intent resolves against the entity's fan modes."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
await climate_intent.async_setup_intents(hass)
|
||||
|
||||
climate_1 = MockClimateEntityWithFanMode()
|
||||
climate_1._attr_name = "Climate 1"
|
||||
climate_1._attr_unique_id = "1234"
|
||||
|
||||
await create_mock_platform(hass, [climate_1])
|
||||
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_SET_FAN_MODE,
|
||||
{"fan_mode": {"value": requested_fan_mode}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_1.entity_id
|
||||
|
||||
state = hass.states.get(climate_1.entity_id)
|
||||
assert state.attributes[ATTR_FAN_MODE] == expected_fan_mode
|
||||
|
||||
|
||||
async def test_set_fan_mode_localized(hass: HomeAssistant) -> None:
|
||||
"""Test HassClimateSetFanMode intent with a localized fan mode name."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
await climate_intent.async_setup_intents(hass)
|
||||
|
||||
climate_1 = MockClimateEntityWithFanMode()
|
||||
climate_1._attr_name = "Climate 1"
|
||||
climate_1._attr_unique_id = "1234"
|
||||
|
||||
await create_mock_platform(hass, [climate_1])
|
||||
|
||||
# Only English translations are generated for tests, so stub the German ones.
|
||||
with patch(
|
||||
"homeassistant.components.climate.intent.translation.async_get_translations",
|
||||
return_value={
|
||||
f"{climate_intent.FAN_MODE_TRANSLATION_PREFIX}auto": "Automatisch",
|
||||
f"{climate_intent.FAN_MODE_TRANSLATION_PREFIX}low": "Niedrig",
|
||||
},
|
||||
):
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_SET_FAN_MODE,
|
||||
{"fan_mode": {"value": "niedrig"}},
|
||||
language="de",
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
|
||||
assert response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
state = hass.states.get(climate_1.entity_id)
|
||||
assert state.attributes[ATTR_FAN_MODE] == "Low"
|
||||
|
||||
|
||||
async def test_set_fan_mode_unsupported_mode(hass: HomeAssistant) -> None:
|
||||
"""Test HassClimateSetFanMode intent with a mode the entity does not have."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
await climate_intent.async_setup_intents(hass)
|
||||
|
||||
climate_1 = MockClimateEntityWithFanMode()
|
||||
climate_1._attr_name = "Climate 1"
|
||||
climate_1._attr_unique_id = "1234"
|
||||
|
||||
await create_mock_platform(hass, [climate_1])
|
||||
|
||||
with pytest.raises(intent.IntentHandleError):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_SET_FAN_MODE,
|
||||
{"fan_mode": {"value": "diffuse"}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
|
||||
# Mode was not affected by failed intent
|
||||
state = hass.states.get(climate_1.entity_id)
|
||||
assert state.attributes[ATTR_FAN_MODE] == "auto"
|
||||
|
||||
|
||||
async def test_set_fan_mode_not_supported(hass: HomeAssistant) -> None:
|
||||
"""Test HassClimateSetFanMode intent on an entity without fan mode support."""
|
||||
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"
|
||||
|
||||
await create_mock_platform(hass, [climate_1])
|
||||
|
||||
with pytest.raises(intent.MatchFailedError) as error:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_SET_FAN_MODE,
|
||||
{"fan_mode": {"value": "auto"}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
|
||||
assert error.value.result.no_match_reason is intent.MatchFailedReason.FEATURE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target_slots",
|
||||
[
|
||||
pytest.param({"name": {"value": "Climate 2"}}, id="name"),
|
||||
pytest.param({"area": {"value": "Bedroom"}}, id="area"),
|
||||
pytest.param({"floor": {"value": "Second floor"}}, id="floor"),
|
||||
],
|
||||
)
|
||||
async def test_set_fan_mode_targeting(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
floor_registry: fr.FloorRegistry,
|
||||
target_slots: dict[str, dict[str, str]],
|
||||
) -> None:
|
||||
"""Test HassClimateSetFanMode intent targeting by name, area and floor."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
await climate_intent.async_setup_intents(hass)
|
||||
|
||||
climate_1, climate_2 = await setup_fan_mode_entities(
|
||||
hass, area_registry, entity_registry, floor_registry
|
||||
)
|
||||
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_SET_FAN_MODE,
|
||||
{"fan_mode": {"value": "Low"}} | target_slots,
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
assert len(response.matched_states) == 1
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
|
||||
assert hass.states.get(climate_2.entity_id).attributes[ATTR_FAN_MODE] == "Low"
|
||||
# The entity in the other area/floor is untouched
|
||||
assert hass.states.get(climate_1.entity_id).attributes[ATTR_FAN_MODE] == "auto"
|
||||
|
||||
|
||||
async def test_set_fan_mode_preferred_area_and_floor(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
floor_registry: fr.FloorRegistry,
|
||||
) -> None:
|
||||
"""Test HassClimateSetFanMode intent with an implicit area and floor."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
await climate_intent.async_setup_intents(hass)
|
||||
|
||||
climate_1, climate_2 = await setup_fan_mode_entities(
|
||||
hass, area_registry, entity_registry, floor_registry
|
||||
)
|
||||
bedroom_area = area_registry.async_get_area_by_name("Bedroom")
|
||||
second_floor = floor_registry.async_get_floor_by_name("Second floor")
|
||||
|
||||
# Cannot target multiple climate devices without a preference
|
||||
with pytest.raises(intent.MatchFailedError) as err:
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_SET_FAN_MODE,
|
||||
{"fan_mode": {"value": "Low"}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert err.value.result.no_match_reason is intent.MatchFailedReason.MULTIPLE_TARGETS
|
||||
|
||||
# Select by area implicitly (climate_2)
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_SET_FAN_MODE,
|
||||
{
|
||||
"fan_mode": {"value": "Low"},
|
||||
"preferred_area_id": {"value": bedroom_area.id},
|
||||
},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
assert hass.states.get(climate_2.entity_id).attributes[ATTR_FAN_MODE] == "Low"
|
||||
|
||||
# Select by floor implicitly (climate_2)
|
||||
response = await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_SET_FAN_MODE,
|
||||
{
|
||||
"fan_mode": {"value": "Turbo"},
|
||||
"preferred_floor_id": {"value": second_floor.floor_id},
|
||||
},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
assert response.matched_states[0].entity_id == climate_2.entity_id
|
||||
assert hass.states.get(climate_2.entity_id).attributes[ATTR_FAN_MODE] == "Turbo"
|
||||
|
||||
assert hass.states.get(climate_1.entity_id).attributes[ATTR_FAN_MODE] == "auto"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_class", "requested_fan_mode"),
|
||||
[
|
||||
# Matches no fan mode and no translated fan mode name
|
||||
pytest.param(MockClimateEntityWithFanMode, "hyperdrive", id="unknown_mode"),
|
||||
# Entity claims fan mode support but reports no modes at all
|
||||
pytest.param(MockClimateEntityNoFanModes, "auto", id="no_fan_modes"),
|
||||
],
|
||||
)
|
||||
async def test_set_fan_mode_unresolvable(
|
||||
hass: HomeAssistant,
|
||||
entity_class: type[ClimateEntity],
|
||||
requested_fan_mode: str,
|
||||
) -> None:
|
||||
"""Test HassClimateSetFanMode intent when the mode cannot be resolved."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
await climate_intent.async_setup_intents(hass)
|
||||
|
||||
climate_1 = entity_class()
|
||||
climate_1._attr_name = "Climate 1"
|
||||
climate_1._attr_unique_id = "1234"
|
||||
|
||||
await create_mock_platform(hass, [climate_1])
|
||||
|
||||
with pytest.raises(intent.IntentHandleError):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_SET_FAN_MODE,
|
||||
{"fan_mode": {"value": requested_fan_mode}},
|
||||
assistant=conversation.DOMAIN,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user