[climate] Store custom mode vectors on Climate entity to eliminate heap allocation (#15206)

This commit is contained in:
J. Nick Koston
2026-04-08 12:25:29 -10:00
committed by GitHub
parent d4cce142c5
commit faa05031a7
16 changed files with 404 additions and 63 deletions

View File

@@ -0,0 +1,4 @@
"""Legacy climate component — tests deprecated ClimateTraits setters backward compat.
Remove this entire directory in 2026.11.0 when the deprecated setters are removed.
"""

View File

@@ -0,0 +1,16 @@
"""Legacy climate platform that uses deprecated ClimateTraits setters."""
import esphome.codegen as cg
from esphome.components import climate
import esphome.config_validation as cv
from esphome.types import ConfigType
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: ConfigType) -> None:
var = await climate.new_climate(config)
await cg.register_component(var, config)

View File

@@ -0,0 +1,55 @@
#pragma once
#include "esphome/components/climate/climate.h"
#include "esphome/core/component.h"
namespace esphome::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();
}
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.has_custom_fan_mode()) {
this->set_custom_fan_mode_(call.get_custom_fan_mode());
}
if (call.has_custom_preset()) {
this->set_custom_preset_(call.get_custom_preset());
}
this->publish_state();
}
};
} // namespace esphome::legacy_climate_test

View File

@@ -0,0 +1,18 @@
esphome:
name: legacy-climate-compat
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

View File

@@ -0,0 +1,96 @@
"""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.
Remove this entire test file and the legacy_climate_component external component
in 2026.11.0 when the deprecated ClimateTraits setters are removed.
"""
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, _ = 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"