From 93ecba49bd35ee65d12596d3d7f28863a63dd8fe Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sat, 5 Sep 2026 03:41:19 -0500 Subject: [PATCH] Add HassClimateSetFanMode intent for climate entities (#181310) Co-authored-by: Claude Opus 5 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/climate/__init__.py | 1 + homeassistant/components/climate/const.py | 1 + homeassistant/components/climate/intent.py | 128 +++++++- tests/components/climate/test_intent.py | 317 +++++++++++++++++++ 4 files changed, 445 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index dd7baf07ce69..68e4f16182e9 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -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, diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index e1fa5d3953ef..c14577c05b38 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -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" diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 01f5c15050c8..81d5957e2db6 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -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 diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 4bb346d84cf7..eb646ee08837 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -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, + )