[fan] Store preset mode vector on Fan entity to eliminate heap allocation (#15209)

This commit is contained in:
J. Nick Koston
2026-04-08 12:25:37 -10:00
committed by GitHub
parent faa05031a7
commit 8e02d0a20e
15 changed files with 297 additions and 32 deletions
+8 -1
View File
@@ -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;
}
+47 -8
View File
@@ -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<const char *> EMPTY_PRESET_MODES; // NOLINT
const std::vector<const char *> &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;
}
}
}
}
+24
View File
@@ -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<const char *> preset_modes) {
this->ensure_preset_modes_().assign(preset_modes.begin(), preset_modes.end());
}
void set_supported_preset_modes(const std::vector<const char *> &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<void()> state_callback_{};
ESPPreferenceObject rtc_;
FanRestoreMode restore_mode_;
private:
/// Lazy-allocate preset modes vector (never freed — entity lives forever).
std::vector<const char *> &ensure_preset_modes_() {
if (!this->supported_preset_modes_) {
this->supported_preset_modes_ = new std::vector<const char *>(); // NOLINT
}
return *this->supported_preset_modes_;
}
std::vector<const char *> *supported_preset_modes_{nullptr};
const char *preset_mode_{nullptr};
};
+38 -11
View File
@@ -3,12 +3,17 @@
#include <cstring>
#include <vector>
#include <initializer_list>
#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<const char *> &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<const char *> &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<const char *> 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<const char *> &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<const char *> &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<std::string> &preset_modes) = delete;
void set_supported_preset_modes(std::initializer_list<std::string> 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<const char *> *preset_modes) {
this->preset_modes_ = preset_modes;
}
bool oscillation_{false};
bool speed_{false};
bool direction_{false};
int speed_count_{};
std::vector<const char *> preset_modes_{};
const std::vector<const char *> *preset_modes_{nullptr};
// Compat: owned storage for deprecated setters. Copies copy the vector (copies include this vector).
// Remove in 2026.11.0.
std::vector<const char *> compat_preset_modes_;
};
} // namespace fan
@@ -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()) {
+5 -3
View File
@@ -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<const char *> presets) { preset_modes_ = presets; }
void set_preset_modes(std::initializer_list<const char *> 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<const char *> preset_modes_{};
void control(const fan::FanCall &call) override;
void write_state_();
@@ -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()) {
+5 -3
View File
@@ -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<const char *> presets) { this->preset_modes_ = presets; }
fan::FanTraits get_traits() override { return this->traits_; }
void set_preset_modes(std::initializer_list<const char *> 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<const char *> preset_modes_{};
};
} // namespace speed
@@ -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()) {
@@ -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<const char *> presets) { this->preset_modes_ = presets; }
fan::FanTraits get_traits() override { return this->traits_; }
void set_preset_modes(std::initializer_list<const char *> 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<const char *> preset_modes_{};
};
} // namespace esphome::template_
@@ -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.
"""
@@ -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)
@@ -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
@@ -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
@@ -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"