diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index a928d208f3..cf592956b0 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_COOL_DEADBAND, CONF_COOL_MODE, CONF_COOL_OVERRUN, + CONF_CUSTOM_FAN_MODES, CONF_DEFAULT_MODE, CONF_DEFAULT_TARGET_TEMPERATURE_HIGH, CONF_DEFAULT_TARGET_TEMPERATURE_LOW, @@ -658,6 +659,7 @@ CONFIG_SCHEMA = cv.All( } ), cv.Optional(CONF_PRESET): cv.ensure_list(PRESET_CONFIG_SCHEMA), + cv.Optional(CONF_CUSTOM_FAN_MODES): cv.ensure_list(cv.string_strict), cv.Optional(CONF_ON_BOOT_RESTORE_FROM): validate_on_boot_restore_from, cv.Optional(CONF_PRESET_CHANGE): automation.validate_automation( single=True @@ -1008,3 +1010,21 @@ async def to_code(config): await automation.build_automation( var.get_preset_change_trigger(), [], config[CONF_PRESET_CHANGE] ) + + # Collect custom preset names from preset map (non-standard preset names only) + custom_preset_names = [ + preset_config[CONF_NAME] + for preset_config in config.get(CONF_PRESET, []) + if preset_config[CONF_NAME].upper() not in climate.CLIMATE_PRESETS + ] + if custom_preset_names: + cg.add(var.set_custom_presets(custom_preset_names)) + + # Collect custom fan modes (filter out standard enum fan modes) + custom_fan_modes = [ + mode + for mode in config.get(CONF_CUSTOM_FAN_MODES, []) + if mode.upper() not in climate.CLIMATE_FAN_MODES + ] + if custom_fan_modes: + cg.add(var.set_custom_fan_modes(custom_fan_modes)) diff --git a/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml b/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml new file mode 100644 index 0000000000..f006bb4352 --- /dev/null +++ b/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml @@ -0,0 +1,44 @@ +esphome: + name: climate-custom-modes-test +host: +api: +logger: + +sensor: + - platform: template + id: thermostat_sensor + lambda: "return 22.0;" + +climate: + - platform: thermostat + id: test_thermostat + name: Test Thermostat Custom Modes + sensor: thermostat_sensor + preset: + - name: Away + default_target_temperature_low: 16°C + default_target_temperature_high: 20°C + - name: Eco Plus + default_target_temperature_low: 18°C + default_target_temperature_high: 22°C + - name: Super Saver + default_target_temperature_low: 20°C + default_target_temperature_high: 24°C + - name: Vacation Mode + default_target_temperature_low: 15°C + default_target_temperature_high: 18°C + custom_fan_modes: + - "Turbo" + - "Silent" + - "Sleep Mode" + idle_action: + - logger.log: idle_action + cool_action: + - logger.log: cool_action + heat_action: + - logger.log: heat_action + min_cooling_off_time: 10s + min_cooling_run_time: 10s + min_heating_off_time: 10s + min_heating_run_time: 10s + min_idle_time: 10s diff --git a/tests/integration/test_climate_custom_modes.py b/tests/integration/test_climate_custom_modes.py new file mode 100644 index 0000000000..d88b682ccd --- /dev/null +++ b/tests/integration/test_climate_custom_modes.py @@ -0,0 +1,51 @@ +"""Integration test for climate custom fan modes and presets.""" + +from __future__ import annotations + +from aioesphomeapi import ClimateInfo, ClimatePreset +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_climate_custom_fan_modes_and_presets( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that custom fan modes and presets are properly exposed via API.""" + async with run_compiled(yaml_config), api_client_connected() as client: + # Get entities and services + entities, services = await client.list_entities_services() + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, "Expected exactly 1 climate entity" + + test_climate = climate_infos[0] + + # Verify custom fan modes are exposed + custom_fan_modes = test_climate.supported_custom_fan_modes + assert len(custom_fan_modes) == 3, ( + f"Expected 3 custom fan modes, got {len(custom_fan_modes)}" + ) + assert "Turbo" in custom_fan_modes, "Expected 'Turbo' in custom fan modes" + assert "Silent" in custom_fan_modes, "Expected 'Silent' in custom fan modes" + assert "Sleep Mode" in custom_fan_modes, ( + "Expected 'Sleep Mode' in custom fan modes" + ) + + # Verify enum presets are exposed (from preset: config map) + assert ClimatePreset.AWAY in test_climate.supported_presets, ( + "Expected AWAY in enum presets" + ) + + # Verify custom string presets are exposed (non-standard preset names from preset map) + custom_presets = test_climate.supported_custom_presets + assert len(custom_presets) == 3, ( + f"Expected 3 custom presets, got {len(custom_presets)}: {custom_presets}" + ) + assert "Eco Plus" in custom_presets, "Expected 'Eco Plus' in custom presets" + assert "Comfort" in custom_presets, "Expected 'Comfort' in custom presets" + assert "Vacation Mode" in custom_presets, ( + "Expected 'Vacation Mode' in custom presets" + )