From 8e02d0a20e0c46ecc578b2d5d405f406cb599068 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 12:25:37 -1000 Subject: [PATCH] [fan] Store preset mode vector on Fan entity to eliminate heap allocation (#15209) --- esphome/components/copy/fan/copy_fan.cpp | 9 +- esphome/components/fan/fan.cpp | 55 +++++++++++-- esphome/components/fan/fan.h | 24 ++++++ esphome/components/fan/fan_traits.h | 49 ++++++++--- .../components/hbridge/fan/hbridge_fan.cpp | 1 - esphome/components/hbridge/fan/hbridge_fan.h | 8 +- esphome/components/speed/fan/speed_fan.cpp | 1 - esphome/components/speed/fan/speed_fan.h | 8 +- .../components/template/fan/template_fan.cpp | 1 - .../components/template/fan/template_fan.h | 8 +- .../legacy_fan_component/__init__.py | 4 + .../legacy_fan_component/fan/__init__.py | 16 ++++ .../legacy_fan_component/fan/legacy_fan.h | 45 ++++++++++ .../fixtures/legacy_fan_compat.yaml | 18 ++++ tests/integration/test_legacy_fan_compat.py | 82 +++++++++++++++++++ 15 files changed, 297 insertions(+), 32 deletions(-) create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h create mode 100644 tests/integration/fixtures/legacy_fan_compat.yaml create mode 100644 tests/integration/test_legacy_fan_compat.py diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index 14c600d71f..bdaa35c467 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -7,6 +7,12 @@ namespace copy { static const char *const TAG = "copy.fan"; void CopyFan::setup() { + // Copy preset modes once from source fan — stored on Fan base class + auto source_traits = source_->get_traits(); + if (source_traits.supports_preset_modes()) { + this->set_supported_preset_modes(source_traits.supported_preset_modes()); + } + source_->add_on_state_callback([this]() { this->copy_state_from_source_(); this->publish_state(); @@ -39,7 +45,8 @@ fan::FanTraits CopyFan::get_traits() { traits.set_speed(base.supports_speed()); traits.set_supported_speed_count(base.supported_speed_count()); traits.set_direction(base.supports_direction()); - traits.set_supported_preset_modes(base.supported_preset_modes()); + // Preset modes are set once in setup() and wired via wire_preset_modes_() + this->wire_preset_modes_(traits); return traits; } diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index dc7a75018c..9301e0cea4 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -9,6 +9,22 @@ namespace fan { static const char *const TAG = "fan"; +// Compat: shared empty vector for getter when no preset modes are set. +// Remove in 2026.11.0 when deprecated FanTraits setters are removed +// and getter can return const vector * instead of const vector &. +static const std::vector EMPTY_PRESET_MODES; // NOLINT + +const std::vector &FanTraits::supported_preset_modes() const { + if (this->preset_modes_) { + return *this->preset_modes_; + } + // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0 (change return to const vector *). + if (!this->compat_preset_modes_.empty()) { + return this->compat_preset_modes_; + } + return EMPTY_PRESET_MODES; +} + // Fan direction strings indexed by FanDirection enum (0-1): FORWARD, REVERSE, plus UNKNOWN PROGMEM_STRING_TABLE(FanDirectionStrings, "FORWARD", "REVERSE", "UNKNOWN"); @@ -148,6 +164,18 @@ const char *Fan::find_preset_mode_(const char *preset_mode) { } const char *Fan::find_preset_mode_(const char *preset_mode, size_t len) { + if (preset_mode == nullptr || len == 0) { + return nullptr; + } + if (this->supported_preset_modes_) { + for (const char *mode : *this->supported_preset_modes_) { + if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { + return mode; + } + } + return nullptr; + } + // Fallback for deprecated path: external components may set modes on FanTraits directly return this->get_traits().find_preset_mode(preset_mode, len); } @@ -261,8 +289,6 @@ void Fan::save_state_() { return; } - auto traits = this->get_traits(); - FanRestoreState state{}; state.state = this->state; state.oscillating = this->oscillating; @@ -271,12 +297,25 @@ void Fan::save_state_() { state.preset_mode = FanRestoreState::NO_PRESET; if (this->has_preset_mode()) { - const auto &preset_modes = traits.supported_preset_modes(); - // Find index of current preset mode (pointer comparison is safe since preset is from traits) - for (size_t i = 0; i < preset_modes.size(); i++) { - if (preset_modes[i] == this->preset_mode_) { - state.preset_mode = i; - break; + if (this->supported_preset_modes_) { + // New path: search Fan-owned vector directly + for (size_t i = 0; i < this->supported_preset_modes_->size(); i++) { + if ((*this->supported_preset_modes_)[i] == this->preset_mode_) { + state.preset_mode = i; + break; + } + } + } else { + // Compat: fall back to traits for deprecated path. Remove in 2026.11.0. + // Pointer comparison works because preset_mode_ and the compat vector both + // hold pointers to string literals in .rodata (stable addresses). + auto traits = this->get_traits(); + const auto &preset_modes = traits.supported_preset_modes(); + for (size_t i = 0; i < preset_modes.size(); i++) { + if (preset_modes[i] == this->preset_mode_) { + state.preset_mode = i; + break; + } } } } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index e7b3681e32..d5763edf2f 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -130,6 +130,14 @@ class Fan : public EntityBase { virtual FanTraits get_traits() = 0; + /// Set the supported preset modes (stored on Fan, referenced by FanTraits via pointer). + void set_supported_preset_modes(std::initializer_list preset_modes) { + this->ensure_preset_modes_().assign(preset_modes.begin(), preset_modes.end()); + } + void set_supported_preset_modes(const std::vector &preset_modes) { + this->ensure_preset_modes_() = preset_modes; + } + /// Set the restore mode of this fan. void set_restore_mode(FanRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } @@ -167,11 +175,27 @@ class Fan : public EntityBase { const char *find_preset_mode_(const char *preset_mode); const char *find_preset_mode_(const char *preset_mode, size_t len); + /// Wire the Fan-owned preset modes pointer into the given traits object. + void wire_preset_modes_(FanTraits &traits) { + if (this->supported_preset_modes_) { + traits.set_supported_preset_modes_(this->supported_preset_modes_); + } + } + LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; private: + /// Lazy-allocate preset modes vector (never freed — entity lives forever). + std::vector &ensure_preset_modes_() { + if (!this->supported_preset_modes_) { + this->supported_preset_modes_ = new std::vector(); // NOLINT + } + return *this->supported_preset_modes_; + } + + std::vector *supported_preset_modes_{nullptr}; const char *preset_mode_{nullptr}; }; diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index c0c5f34c50..a2b2633af1 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -3,12 +3,17 @@ #include #include #include +#include "esphome/core/helpers.h" namespace esphome { namespace fan { +class Fan; // Forward declaration + class FanTraits { + friend class Fan; // Allow Fan to access protected pointer setter + public: FanTraits() = default; FanTraits(bool oscillation, bool speed, bool direction, int speed_count) @@ -30,42 +35,64 @@ class FanTraits { bool supports_direction() const { return this->direction_; } /// Set whether this fan supports changing direction void set_direction(bool direction) { this->direction_ = direction; } - /// Return the preset modes supported by the fan. - const std::vector &supported_preset_modes() const { return this->preset_modes_; } - /// Set the preset modes supported by the fan (from initializer list). + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. + const std::vector &supported_preset_modes() const; + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(std::initializer_list preset_modes) { - this->preset_modes_ = preset_modes; + // Compat: store in owned vector. Copies copy the vector (deprecated path still copies this vector). + this->compat_preset_modes_ = preset_modes; + } + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_preset_modes(const std::vector &preset_modes) { + this->compat_preset_modes_ = preset_modes; } - /// Set the preset modes supported by the fan (from vector). - void set_supported_preset_modes(const std::vector &preset_modes) { this->preset_modes_ = preset_modes; } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_supported_preset_modes(const std::vector &preset_modes) = delete; void set_supported_preset_modes(std::initializer_list preset_modes) = delete; /// Return if preset modes are supported - bool supports_preset_modes() const { return !this->preset_modes_.empty(); } + bool supports_preset_modes() const { + // Same precedence as supported_preset_modes() getter + if (this->preset_modes_) { + return !this->preset_modes_->empty(); + } + return !this->compat_preset_modes_.empty(); + } /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. const char *find_preset_mode(const char *preset_mode) const { return this->find_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); } const char *find_preset_mode(const char *preset_mode, size_t len) const { - if (preset_mode == nullptr || len == 0) + if (preset_mode == nullptr || len == 0) { return nullptr; - for (const char *mode : this->preset_modes_) { + } + // Check pointer-based storage (new path) then compat owned vector (deprecated path) + const auto &modes = this->preset_modes_ ? *this->preset_modes_ : this->compat_preset_modes_; + for (const char *mode : modes) { if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { - return mode; // Return pointer from traits + return mode; } } return nullptr; } protected: + /// Set the preset modes pointer (only Fan::wire_preset_modes_() should call this). + void set_supported_preset_modes_(const std::vector *preset_modes) { + this->preset_modes_ = preset_modes; + } + bool oscillation_{false}; bool speed_{false}; bool direction_{false}; int speed_count_{}; - std::vector preset_modes_{}; + const std::vector *preset_modes_{nullptr}; + // Compat: owned storage for deprecated setters. Copies copy the vector (copies include this vector). + // Remove in 2026.11.0. + std::vector compat_preset_modes_; }; } // namespace fan diff --git a/esphome/components/hbridge/fan/hbridge_fan.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index 89c162eebf..d548128b99 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -30,7 +30,6 @@ fan::FanCall HBridgeFan::brake() { void HBridgeFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, true, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index ec1e8ada0e..997f66ae48 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -20,11 +20,14 @@ class HBridgeFan : public Component, public fan::Fan { void set_pin_a(output::FloatOutput *pin_a) { pin_a_ = pin_a; } void set_pin_b(output::FloatOutput *pin_b) { pin_b_ = pin_b; } void set_enable_pin(output::FloatOutput *enable) { enable_ = enable; } - void set_preset_modes(std::initializer_list presets) { preset_modes_ = presets; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } void setup() override; void dump_config() override; - fan::FanTraits get_traits() override { return this->traits_; } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } fan::FanCall brake(); @@ -36,7 +39,6 @@ class HBridgeFan : public Component, public fan::Fan { int speed_count_{}; DecayMode decay_mode_{DECAY_MODE_SLOW}; fan::FanTraits traits_; - std::vector preset_modes_{}; void control(const fan::FanCall &call) override; void write_state_(); diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index d45237c467..eaa8a55858 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -9,7 +9,6 @@ static const char *const TAG = "speed.fan"; void SpeedFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, this->direction_ != nullptr, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index e9a389e0f3..db96039a13 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -16,8 +16,11 @@ class SpeedFan : public Component, public fan::Fan { void set_output(output::FloatOutput *output) { this->output_ = output; } void set_oscillating(output::BinaryOutput *oscillating) { this->oscillating_ = oscillating; } void set_direction(output::BinaryOutput *direction) { this->direction_ = direction; } - void set_preset_modes(std::initializer_list presets) { this->preset_modes_ = presets; } - fan::FanTraits get_traits() override { return this->traits_; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } protected: void control(const fan::FanCall &call) override; @@ -28,7 +31,6 @@ class SpeedFan : public Component, public fan::Fan { output::BinaryOutput *direction_{nullptr}; int speed_count_{}; fan::FanTraits traits_; - std::vector preset_modes_{}; }; } // namespace speed diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index 46a5cba9bb..431be84654 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -9,7 +9,6 @@ void TemplateFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->has_oscillating_, this->speed_count_ > 0, this->has_direction_, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/template/fan/template_fan.h b/esphome/components/template/fan/template_fan.h index b7e1d4ab5a..5ab6ae8c65 100644 --- a/esphome/components/template/fan/template_fan.h +++ b/esphome/components/template/fan/template_fan.h @@ -13,8 +13,11 @@ class TemplateFan final : public Component, public fan::Fan { void set_has_direction(bool has_direction) { this->has_direction_ = has_direction; } void set_has_oscillating(bool has_oscillating) { this->has_oscillating_ = has_oscillating; } void set_speed_count(int count) { this->speed_count_ = count; } - void set_preset_modes(std::initializer_list presets) { this->preset_modes_ = presets; } - fan::FanTraits get_traits() override { return this->traits_; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } protected: void control(const fan::FanCall &call) override; @@ -23,7 +26,6 @@ class TemplateFan final : public Component, public fan::Fan { bool has_direction_{false}; int speed_count_{0}; fan::FanTraits traits_; - std::vector preset_modes_{}; }; } // namespace esphome::template_ diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py new file mode 100644 index 0000000000..714be181fe --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py @@ -0,0 +1,4 @@ +"""Legacy fan component — tests deprecated FanTraits setters backward compat. + +Remove this entire directory in 2026.11.0 when the deprecated setters are removed. +""" diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py new file mode 100644 index 0000000000..985d97d081 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py @@ -0,0 +1,16 @@ +"""Legacy fan platform that uses deprecated FanTraits setters.""" + +import esphome.codegen as cg +from esphome.components import fan +import esphome.config_validation as cv +from esphome.types import ConfigType + +legacy_fan_ns = cg.esphome_ns.namespace("legacy_fan_test") +LegacyFan = legacy_fan_ns.class_("LegacyFan", fan.Fan, cg.Component) + +CONFIG_SCHEMA = fan.fan_schema(LegacyFan).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = await fan.new_fan(config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h new file mode 100644 index 0000000000..ac378b59c5 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h @@ -0,0 +1,45 @@ +#pragma once + +#include "esphome/components/fan/fan.h" +#include "esphome/core/component.h" + +namespace esphome::legacy_fan_test { + +/// Test fan that uses the DEPRECATED FanTraits setters for preset modes. +/// This validates backward compatibility for external components that haven't migrated. +class LegacyFan : public fan::Fan, public Component { + public: + void setup() override { + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(*this); + } + this->publish_state(); + } + + fan::FanTraits get_traits() override { + auto traits = fan::FanTraits(false, true, false, 3); + + // DEPRECATED API: setting preset modes directly on FanTraits. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + traits.set_supported_preset_modes({"Turbo", "Silent", "Eco"}); +#pragma GCC diagnostic pop + + return traits; + } + + protected: + void control(const fan::FanCall &call) override { + if (call.get_state().has_value()) { + this->state = *call.get_state(); + } + if (call.get_speed().has_value()) { + this->speed = *call.get_speed(); + } + this->apply_preset_mode_(call); + this->publish_state(); + } +}; + +} // namespace esphome::legacy_fan_test diff --git a/tests/integration/fixtures/legacy_fan_compat.yaml b/tests/integration/fixtures/legacy_fan_compat.yaml new file mode 100644 index 0000000000..256fd4e4c1 --- /dev/null +++ b/tests/integration/fixtures/legacy_fan_compat.yaml @@ -0,0 +1,18 @@ +esphome: + name: legacy-fan-compat + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [legacy_fan_component] + +fan: + - platform: legacy_fan_component + name: "Legacy Fan" + id: legacy_fan diff --git a/tests/integration/test_legacy_fan_compat.py b/tests/integration/test_legacy_fan_compat.py new file mode 100644 index 0000000000..5ee41772f3 --- /dev/null +++ b/tests/integration/test_legacy_fan_compat.py @@ -0,0 +1,82 @@ +"""Integration test for backward compatibility of deprecated FanTraits setters. + +Verifies that external components using the old traits.set_supported_preset_modes() +API still work correctly during the deprecation period. + +Remove this entire test file and the legacy_fan_component external component +in 2026.11.0 when the deprecated FanTraits setters are removed. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import FanInfo, FanState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_legacy_fan_compat( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that deprecated FanTraits preset 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 + ) + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + + fan_infos = [e for e in entities if isinstance(e, FanInfo)] + assert len(fan_infos) == 1, f"Expected 1 fan entity, got {len(fan_infos)}" + + test_fan = fan_infos[0] + + # Verify preset modes set via deprecated FanTraits setter are exposed + assert set(test_fan.supported_preset_modes) == { + "Turbo", + "Silent", + "Eco", + }, ( + f"Expected preset modes {{Turbo, Silent, Eco}}, " + f"got {test_fan.supported_preset_modes}" + ) + + # Verify speed support + assert test_fan.supports_speed is True + assert test_fan.supported_speed_count == 3 + + # Subscribe and wait for initial states + states: dict[int, FanState] = {} + state_event = asyncio.Event() + + def on_state(state: FanState) -> None: + if isinstance(state, FanState): + states[state.key] = state + state_event.set() + + client.subscribe_states(on_state) + + # Wait for initial state + await asyncio.wait_for(state_event.wait(), timeout=5.0) + + # Turn on fan with preset mode (tests find_preset_mode_ compat path) + state_event.clear() + client.fan_command( + key=test_fan.key, + state=True, + preset_mode="Turbo", + ) + await asyncio.wait_for(state_event.wait(), timeout=5.0) + + fan_state = states[test_fan.key] + assert fan_state.state is True + assert fan_state.preset_mode == "Turbo"