diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py new file mode 100644 index 0000000000..a495fb81ad --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py @@ -0,0 +1 @@ +"""Legacy climate component — tests deprecated ClimateTraits setters backward compat.""" diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py new file mode 100644 index 0000000000..08ef4f542a --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py @@ -0,0 +1,15 @@ +"""Legacy climate platform that uses deprecated ClimateTraits setters.""" + +import esphome.codegen as cg +from esphome.components import climate +import esphome.config_validation as cv + +legacy_climate_ns = cg.esphome_ns.namespace("legacy_climate_test") +LegacyClimate = legacy_climate_ns.class_("LegacyClimate", climate.Climate, cg.Component) + +CONFIG_SCHEMA = climate.climate_schema(LegacyClimate).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = await climate.new_climate(config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h new file mode 100644 index 0000000000..b2fea53dd6 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/components/climate/climate.h" +#include "esphome/core/component.h" + +namespace esphome { +namespace legacy_climate_test { + +/// Test climate that uses the DEPRECATED ClimateTraits setters for custom modes. +/// This validates backward compatibility for external components that haven't migrated. +class LegacyClimate : public climate::Climate, public Component { + public: + void setup() override { + this->mode = climate::CLIMATE_MODE_OFF; + this->target_temperature = 22.0f; + this->current_temperature = 20.0f; + this->publish_state(); + } + + float get_setup_priority() const override { return setup_priority::LATE; } + + protected: + climate::ClimateTraits traits() override { + auto traits = climate::ClimateTraits(); + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_COOL}); + traits.set_visual_min_temperature(16.0f); + traits.set_visual_max_temperature(30.0f); + traits.set_visual_temperature_step(0.5f); + + // DEPRECATED API: setting custom modes directly on ClimateTraits. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + traits.set_supported_custom_fan_modes({"Turbo", "Silent", "Auto"}); + traits.set_supported_custom_presets({"Eco Mode", "Night Mode"}); +#pragma GCC diagnostic pop + + return traits; + } + + void control(const climate::ClimateCall &call) override { + if (call.get_mode().has_value()) { + this->mode = *call.get_mode(); + } + if (call.get_target_temperature().has_value()) { + this->target_temperature = *call.get_target_temperature(); + } + if (call.get_custom_fan_mode() != nullptr) { + this->set_custom_fan_mode_(call.get_custom_fan_mode()); + } + if (call.get_custom_preset() != nullptr) { + this->set_custom_preset_(call.get_custom_preset()); + } + this->publish_state(); + } +}; + +} // namespace legacy_climate_test +} // namespace esphome diff --git a/tests/integration/fixtures/legacy_climate_compat.yaml b/tests/integration/fixtures/legacy_climate_compat.yaml new file mode 100644 index 0000000000..13f88aab02 --- /dev/null +++ b/tests/integration/fixtures/legacy_climate_compat.yaml @@ -0,0 +1,21 @@ +esphome: + name: legacy-climate-compat + platformio_options: + build_flags: + - "-DUSE_HOST" + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [legacy_climate_component] + +climate: + - platform: legacy_climate_component + name: "Legacy Climate" + id: legacy_climate diff --git a/tests/integration/test_legacy_climate_compat.py b/tests/integration/test_legacy_climate_compat.py new file mode 100644 index 0000000000..430778134d --- /dev/null +++ b/tests/integration/test_legacy_climate_compat.py @@ -0,0 +1,93 @@ +"""Integration test for backward compatibility of deprecated ClimateTraits setters. + +Verifies that external components using the old traits.set_supported_custom_fan_modes() +and traits.set_supported_custom_presets() API still work correctly during the +deprecation period (removed in 2026.11.0). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import aioesphomeapi +from aioesphomeapi import ClimateInfo +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_legacy_climate_compat( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that deprecated ClimateTraits custom mode setters still work end-to-end.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, services = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, ( + f"Expected 1 climate entity, got {len(climate_infos)}" + ) + + test_climate = climate_infos[0] + + # Verify custom fan modes set via deprecated ClimateTraits setter are exposed + assert set(test_climate.supported_custom_fan_modes) == { + "Turbo", + "Silent", + "Auto", + }, ( + f"Expected custom fan modes {{Turbo, Silent, Auto}}, " + f"got {test_climate.supported_custom_fan_modes}" + ) + + # Verify custom presets set via deprecated ClimateTraits setter are exposed + assert set(test_climate.supported_custom_presets) == { + "Eco Mode", + "Night Mode", + }, ( + f"Expected custom presets {{Eco Mode, Night Mode}}, " + f"got {test_climate.supported_custom_presets}" + ) + + # Set up state tracking with InitialStateHelper + turbo_future: asyncio.Future[aioesphomeapi.ClimateState] = loop.create_future() + + def on_state(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, aioesphomeapi.ClimateState) + and state.custom_fan_mode == "Turbo" + and not turbo_future.done() + ): + turbo_future.set_result(state) + + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Verify we can set a custom fan mode via API (tests find_custom_fan_mode_ compat path) + client.climate_command(test_climate.key, custom_fan_mode="Turbo") + + try: + turbo_state = await asyncio.wait_for(turbo_future, timeout=5.0) + except TimeoutError: + pytest.fail("Custom fan mode 'Turbo' not received within 5 seconds") + + assert turbo_state.custom_fan_mode == "Turbo"