Compare commits

...
Author SHA1 Message Date
J. Nick Koston c0c7180f87 Merge remote-tracking branch 'origin/dev' into climate-custom-modes-const-vector
# Conflicts:
#	esphome/components/midea/air_conditioner.cpp
2026-10-13 17:04:15 -10:00
J. Nick Koston 78c927ec0f Merge remote-tracking branch 'origin/dev' into climate-custom-modes-const-vector
# Conflicts:
#	esphome/components/midea/ac_adapter.cpp
#	esphome/components/midea/ac_adapter.h
2026-10-13 15:06:36 -10:00
J. Nick Koston d54037e67a Merge remote-tracking branch 'origin/dev' into climate-custom-modes-const-vector
# Conflicts:
#	tests/integration/integration_test_durations.json
2026-10-12 11:29:06 -10:00
J. Nick Koston f0ee63ac73 Declare the midea log tag with ESPHOME_LOG_TAG so it stays in flash on ESP8266 2026-10-11 08:37:34 -10:00
J. Nick Koston f358358c1b Merge remote-tracking branch 'origin/dev' into HEAD 2026-10-11 08:25:47 -10:00
J. Nick Koston adfc3fa667 Merge remote-tracking branch 'origin/dev' into HEAD 2026-10-09 18:27:54 -10:00
J. Nick Koston 407e43a19a Merge remote-tracking branch 'origin/dev' into climate-custom-modes-const-vector
# Conflicts:
#	tests/components/template/common-base.yaml
2026-10-06 08:38:09 -06:00
J. Nick Koston 1033d84077 Construct ClimateTraits out of line so traits() does not copy a .rodata template 2026-10-05 22:03:57 -05:00
J. Nick Koston c2a8b54464 Merge remote-tracking branch 'origin/climate-custom-modes-test-coverage' into climate-custom-modes-const-vector 2026-10-05 21:41:09 -05:00
J. Nick Koston 16186156b7 Cover the C array and std::vector fan mode setters 2026-10-05 21:40:46 -05:00
J. Nick Koston 9bd71274f1 Merge branch 'climate-custom-modes-test-coverage' into climate-custom-modes-const-vector
# Conflicts:
#	tests/components/template/common-base.yaml
2026-10-05 21:29:29 -05:00
J. Nick Koston 568f488806 [climate] Cover how configs and components read and set custom modes in the template test 2026-10-05 21:29:23 -05:00
J. Nick Koston 0b675edeee Encode custom mode lists straight from the ConstVector; review fixes 2026-10-05 21:13:24 -05:00
J. Nick Koston 3915b1fc35 Merge remote-tracking branch 'origin/dev' into climate-custom-modes-const-vector 2026-10-05 21:08:06 -05:00
J. Nick Koston c3155fb780 [climate] Cover how configs and components read and set custom modes in the template test 2026-10-05 20:40:59 -05:00
J. Nick Koston 1db752fb83 [climate] Keep custom fan modes and presets in a ConstVector; remove the deprecated ClimateTraits setters 2026-10-05 20:40:59 -05:00
24 changed files with 185 additions and 387 deletions
+2 -2
View File
@@ -1328,9 +1328,9 @@ message ListEntitiesClimateResponse {
bool supports_action = 12; // Deprecated: use feature_flags
repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer_no_template) = "climate::ClimateFanModeMask"];
repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer_no_template) = "climate::ClimateSwingModeMask"];
repeated string supported_custom_fan_modes = 15 [(container_pointer_no_template) = "std::vector<const char *>"];
repeated string supported_custom_fan_modes = 15 [(container_pointer_no_template) = "ConstVector<const char *, true>"];
repeated ClimatePreset supported_presets = 16 [(container_pointer_no_template) = "climate::ClimatePresetMask"];
repeated string supported_custom_presets = 17 [(container_pointer_no_template) = "std::vector<const char *>"];
repeated string supported_custom_presets = 17 [(container_pointer_no_template) = "ConstVector<const char *, true>"];
bool disabled_by_default = 18;
string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
EntityCategory entity_category = 20;
+2 -2
View File
@@ -1776,9 +1776,9 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
bool supports_action{false};
const climate::ClimateFanModeMask *supported_fan_modes{};
const climate::ClimateSwingModeMask *supported_swing_modes{};
const std::vector<const char *> *supported_custom_fan_modes{};
const ConstVector<const char *, true> *supported_custom_fan_modes{};
const climate::ClimatePresetMask *supported_presets{};
const std::vector<const char *> *supported_custom_presets{};
const ConstVector<const char *, true> *supported_custom_presets{};
float visual_current_temperature_step{0.0f};
bool supports_current_humidity{false};
bool supports_target_humidity{false};
@@ -61,7 +61,7 @@ void BedJetClimate::dump_config() {
void BedJetClimate::setup() {
// Set custom modes once during setup — stored on Climate base class, wired via get_traits()
this->set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES);
this->set_supported_custom_fan_modes_static(BEDJET_FAN_STEP_NAMES, BEDJET_FAN_SPEED_COUNT);
this->set_supported_custom_presets({
this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT",
"M1",
+18
View File
@@ -442,6 +442,24 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
await web_server.add_entity_config(var, web_server_config)
def set_custom_fan_modes(var: MockObj, modes: list[str]) -> None:
"""Point the climate at a shared flash table of custom fan modes."""
if modes:
table = cg.shared_progmem_array(
"climate_custom_fan_modes", cg.const_char_ptr, modes
)
cg.add(var.set_supported_custom_fan_modes_static(table, len(modes)))
def set_custom_presets(var: MockObj, presets: list[str]) -> None:
"""Point the climate at a shared flash table of custom presets."""
if presets:
table = cg.shared_progmem_array(
"climate_custom_presets", cg.const_char_ptr, presets
)
cg.add(var.set_supported_custom_presets_static(table, len(presets)))
async def register_climate(var: MockObj, config: ConfigType) -> None:
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
+5 -15
View File
@@ -479,10 +479,8 @@ void Climate::publish_state() {
ClimateTraits Climate::get_traits() {
auto traits = this->traits();
// Wire custom mode pointers from Climate-owned storage
if (this->supported_custom_fan_modes_)
traits.set_supported_custom_fan_modes_(this->supported_custom_fan_modes_);
if (this->supported_custom_presets_)
traits.set_supported_custom_presets_(this->supported_custom_presets_);
traits.set_supported_custom_fan_modes_(&this->supported_custom_fan_modes_);
traits.set_supported_custom_presets_(&this->supported_custom_presets_);
#ifdef USE_CLIMATE_VISUAL_OVERRIDES
if (!std::isnan(this->visual_min_temperature_override_)) {
traits.set_visual_min_temperature(this->visual_min_temperature_override_);
@@ -628,7 +626,7 @@ template<typename T> bool set_primary_mode(optional<T> &primary, const char *&cu
* After: fan_mode=nullopt, custom_fan_mode_="Turbo" (pointer from traits)
*
* Lifetime Safety:
* - found_ptr must come from traits.find_custom_*_mode_()
* - found_ptr must come from Climate::find_custom_*_()
* - Only pointers found in traits are stored, ensuring they remain valid
* - Prevents dangling pointers from temporary strings
*
@@ -684,11 +682,7 @@ const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode) {
}
const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode, size_t len) {
if (this->supported_custom_fan_modes_) {
return vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len);
}
// Fallback for deprecated path: external components may set modes on ClimateTraits directly
return this->get_traits().find_custom_fan_mode_(custom_fan_mode, len);
return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, len);
}
const char *Climate::find_custom_preset_(const char *custom_preset) {
@@ -696,11 +690,7 @@ const char *Climate::find_custom_preset_(const char *custom_preset) {
}
const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) {
if (this->supported_custom_presets_) {
return vector_find(*this->supported_custom_presets_, custom_preset, len);
}
// Fallback for deprecated path: external components may set modes on ClimateTraits directly
return this->get_traits().find_custom_preset_(custom_preset, len);
return vector_find(this->supported_custom_presets_, custom_preset, len);
}
void Climate::dump_traits_(const char *tag) {
+18 -24
View File
@@ -246,26 +246,34 @@ class Climate : public EntityBase {
}
#endif
/// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits).
/// Set the supported custom fan modes; the list is copied (stored on Climate, referenced by ClimateTraits).
void set_supported_custom_fan_modes(std::initializer_list<const char *> modes) {
this->ensure_custom_fan_modes_().assign(modes.begin(), modes.end());
this->supported_custom_fan_modes_.assign_copy(modes.begin(), modes.size());
}
void set_supported_custom_fan_modes(const std::vector<const char *> &modes) {
this->ensure_custom_fan_modes_() = modes;
this->supported_custom_fan_modes_.assign_copy(modes.data(), modes.size());
}
template<size_t N> void set_supported_custom_fan_modes(const char *const (&modes)[N]) {
this->ensure_custom_fan_modes_().assign(modes, modes + N);
this->supported_custom_fan_modes_.assign_copy(modes, N);
}
/// Points at a static table that outlives the climate (codegen); call before any runtime copy.
void set_supported_custom_fan_modes_static(const char *const *modes, size_t count) {
this->supported_custom_fan_modes_.assign_static(modes, count);
}
/// Set the supported custom presets (stored on Climate, referenced by ClimateTraits).
/// Set the supported custom presets; the list is copied (stored on Climate, referenced by ClimateTraits).
void set_supported_custom_presets(std::initializer_list<const char *> presets) {
this->ensure_custom_presets_().assign(presets.begin(), presets.end());
this->supported_custom_presets_.assign_copy(presets.begin(), presets.size());
}
void set_supported_custom_presets(const std::vector<const char *> &presets) {
this->ensure_custom_presets_() = presets;
this->supported_custom_presets_.assign_copy(presets.data(), presets.size());
}
template<size_t N> void set_supported_custom_presets(const char *const (&presets)[N]) {
this->ensure_custom_presets_().assign(presets, presets + N);
this->supported_custom_presets_.assign_copy(presets, N);
}
/// Points at a static table that outlives the climate (codegen); call before any runtime copy.
void set_supported_custom_presets_static(const char *const *presets, size_t count) {
this->supported_custom_presets_.assign_static(presets, count);
}
/// Check if a custom fan mode is currently active.
@@ -388,22 +396,8 @@ class Climate : public EntityBase {
#endif
private:
/// Lazy-allocate custom mode vectors (never freed — entity lives forever).
std::vector<const char *> &ensure_custom_fan_modes_() {
if (!this->supported_custom_fan_modes_) {
this->supported_custom_fan_modes_ = new std::vector<const char *>(); // NOLINT
}
return *this->supported_custom_fan_modes_;
}
std::vector<const char *> &ensure_custom_presets_() {
if (!this->supported_custom_presets_) {
this->supported_custom_presets_ = new std::vector<const char *>(); // NOLINT
}
return *this->supported_custom_presets_;
}
std::vector<const char *> *supported_custom_fan_modes_{nullptr};
std::vector<const char *> *supported_custom_presets_{nullptr};
ClimateCustomModes supported_custom_fan_modes_;
ClimateCustomModes supported_custom_presets_;
/** The active custom fan mode (private - enforces use of safe setters).
*
+7 -22
View File
@@ -2,31 +2,16 @@
namespace esphome::climate {
// Compat: shared empty vector for getters when no custom modes are set.
// Remove in 2026.11.0 when deprecated ClimateTraits setters are removed
// and getters can return const vector * instead of const vector &.
static const std::vector<const char *> EMPTY_CUSTOM_MODES; // NOLINT
ClimateTraits::ClimateTraits() = default;
const std::vector<const char *> &ClimateTraits::get_supported_custom_fan_modes() const {
if (this->supported_custom_fan_modes_) {
return *this->supported_custom_fan_modes_;
}
// Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0.
if (!this->compat_custom_fan_modes_.empty()) {
return this->compat_custom_fan_modes_;
}
return EMPTY_CUSTOM_MODES;
static const ClimateCustomModes EMPTY_CUSTOM_MODES; // NOLINT
const ClimateCustomModes &ClimateTraits::get_supported_custom_fan_modes() const {
return this->supported_custom_fan_modes_ != nullptr ? *this->supported_custom_fan_modes_ : EMPTY_CUSTOM_MODES;
}
const std::vector<const char *> &ClimateTraits::get_supported_custom_presets() const {
if (this->supported_custom_presets_) {
return *this->supported_custom_presets_;
}
// Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0.
if (!this->compat_custom_presets_.empty()) {
return this->compat_custom_presets_;
}
return EMPTY_CUSTOM_MODES;
const ClimateCustomModes &ClimateTraits::get_supported_custom_presets() const {
return this->supported_custom_presets_ != nullptr ? *this->supported_custom_presets_ : EMPTY_CUSTOM_MODES;
}
int8_t ClimateTraits::get_target_temperature_accuracy_decimals() const {
+27 -107
View File
@@ -1,7 +1,7 @@
#pragma once
#include <cstring>
#include <vector>
#include <type_traits>
#include "climate_mode.h"
#include "esphome/core/finite_set_mask.h"
#include "esphome/core/helpers.h"
@@ -18,9 +18,12 @@ using ClimateSwingModeMask =
FiniteSetMask<ClimateSwingMode, DefaultBitPolicy<ClimateSwingMode, CLIMATE_SWING_HORIZONTAL + 1>>;
using ClimatePresetMask = FiniteSetMask<ClimatePreset, DefaultBitPolicy<ClimatePreset, CLIMATE_PRESET_ACTIVITY + 1>>;
// Lightweight linear search for small vectors (1-20 items) of const char* pointers
/// Custom fan modes and custom presets: a flash table from codegen, or an owned copy set at runtime.
using ClimateCustomModes = ConstVector<const char *, true>;
// Lightweight linear search for small lists (1-20 items) of const char* pointers
// Avoids std::find template overhead
inline bool vector_contains(const std::vector<const char *> &vec, const char *value, size_t len) {
template<typename List> inline bool vector_contains(const List &vec, const char *value, size_t len) {
for (const char *item : vec) {
if (strncmp(item, value, len) == 0 && item[len] == '\0')
return true;
@@ -28,12 +31,12 @@ inline bool vector_contains(const std::vector<const char *> &vec, const char *va
return false;
}
inline bool vector_contains(const std::vector<const char *> &vec, const char *value) {
template<typename List> inline bool vector_contains(const List &vec, const char *value) {
return vector_contains(vec, value, strlen(value));
}
// Find and return matching pointer from vector, or nullptr if not found
inline const char *vector_find(const std::vector<const char *> &vec, const char *value, size_t len) {
// Find and return matching pointer from a list, or nullptr if not found
template<typename List> inline const char *vector_find(const List &vec, const char *value, size_t len) {
for (const char *item : vec) {
if (strncmp(item, value, len) == 0 && item[len] == '\0')
return item;
@@ -71,9 +74,13 @@ inline const char *vector_find(const std::vector<const char *> &vec, const char
class Climate; // Forward declaration
class ClimateTraits {
friend class Climate; // Allow Climate to access protected find methods
friend class Climate; // Climate wires its custom mode lists into traits
public:
// Out of line on purpose: an inlined default init makes GCC copy the defaults from a .rodata
// template per traits() override, and .rodata is RAM on ESP8266.
ClimateTraits();
/// Get/set feature flags (see ClimateFeatures enum in climate_mode.h)
uint32_t get_feature_flags() const { return this->feature_flags_; }
void add_feature_flags(uint32_t feature_flags) { this->feature_flags_ |= feature_flags; }
@@ -93,42 +100,14 @@ class ClimateTraits {
if (!this->supported_fan_modes_.empty()) {
return true;
}
// Same precedence as get_supported_custom_fan_modes() getter
if (this->supported_custom_fan_modes_) {
return !this->supported_custom_fan_modes_->empty();
}
return !this->compat_custom_fan_modes_.empty(); // Compat: remove in 2026.11.0
return this->supported_custom_fan_modes_ != nullptr && !this->supported_custom_fan_modes_->empty();
}
const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_fan_modes_; }
// Remove before 2026.11.0
ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0")
void set_supported_custom_fan_modes(std::initializer_list<const char *> modes) {
// Compat: store in owned vector. Copies copy the vector (deprecated path still copies this vector).
this->compat_custom_fan_modes_ = modes;
}
// Remove before 2026.11.0
ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0")
void set_supported_custom_fan_modes(const std::vector<const char *> &modes) {
this->compat_custom_fan_modes_ = modes;
}
// Remove before 2026.11.0
template<size_t N>
ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0")
void set_supported_custom_fan_modes(const char *const (&modes)[N]) {
this->compat_custom_fan_modes_.assign(modes, modes + N);
}
// Deleted overloads to catch incorrect std::string usage at compile time with clear error messages
void set_supported_custom_fan_modes(const std::vector<std::string> &modes) = delete;
void set_supported_custom_fan_modes(std::initializer_list<std::string> modes) = delete;
// Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *.
const std::vector<const char *> &get_supported_custom_fan_modes() const;
/// Empty when the climate has no custom fan modes. Set them on the Climate entity.
const ClimateCustomModes &get_supported_custom_fan_modes() const;
bool supports_custom_fan_mode(const char *custom_fan_mode) const {
return (this->supported_custom_fan_modes_ &&
vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode)) ||
vector_contains(this->compat_custom_fan_modes_, custom_fan_mode); // Compat: remove in 2026.11.0
return vector_contains(this->get_supported_custom_fan_modes(), custom_fan_mode);
}
bool supports_custom_fan_mode(const std::string &custom_fan_mode) const {
return this->supports_custom_fan_mode(custom_fan_mode.c_str());
@@ -140,32 +119,10 @@ class ClimateTraits {
bool get_supports_presets() const { return !this->supported_presets_.empty(); }
const ClimatePresetMask &get_supported_presets() const { return this->supported_presets_; }
// Remove before 2026.11.0
ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0")
void set_supported_custom_presets(std::initializer_list<const char *> presets) {
this->compat_custom_presets_ = presets;
}
// Remove before 2026.11.0
ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0")
void set_supported_custom_presets(const std::vector<const char *> &presets) {
this->compat_custom_presets_ = presets;
}
// Remove before 2026.11.0
template<size_t N>
ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0")
void set_supported_custom_presets(const char *const (&presets)[N]) {
this->compat_custom_presets_.assign(presets, presets + N);
}
// Deleted overloads to catch incorrect std::string usage at compile time with clear error messages
void set_supported_custom_presets(const std::vector<std::string> &presets) = delete;
void set_supported_custom_presets(std::initializer_list<std::string> presets) = delete;
// Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *.
const std::vector<const char *> &get_supported_custom_presets() const;
/// Empty when the climate has no custom presets. Set them on the Climate entity.
const ClimateCustomModes &get_supported_custom_presets() const;
bool supports_custom_preset(const char *custom_preset) const {
return (this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset)) ||
vector_contains(this->compat_custom_presets_, custom_preset); // Compat: remove in 2026.11.0
return vector_contains(this->get_supported_custom_presets(), custom_preset);
}
bool supports_custom_preset(const std::string &custom_preset) const {
return this->supports_custom_preset(custom_preset.c_str());
@@ -232,38 +189,8 @@ class ClimateTraits {
}
/// Set custom mode pointers (only Climate::get_traits() should call these).
void set_supported_custom_fan_modes_(const std::vector<const char *> *modes) {
this->supported_custom_fan_modes_ = modes;
}
void set_supported_custom_presets_(const std::vector<const char *> *presets) {
this->supported_custom_presets_ = presets;
}
/// Find and return the matching custom fan mode pointer from supported modes, or nullptr if not found
/// This is protected as it's an implementation detail - use Climate::find_custom_fan_mode_() instead
const char *find_custom_fan_mode_(const char *custom_fan_mode) const {
return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode));
}
const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len) const {
if (this->supported_custom_fan_modes_) {
return vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len);
}
// Compat: check owned vector from deprecated setters. Remove in 2026.11.0.
return vector_find(this->compat_custom_fan_modes_, custom_fan_mode, len);
}
/// Find and return the matching custom preset pointer from supported presets, or nullptr if not found
/// This is protected as it's an implementation detail - use Climate::find_custom_preset_() instead
const char *find_custom_preset_(const char *custom_preset) const {
return this->find_custom_preset_(custom_preset, strlen(custom_preset));
}
const char *find_custom_preset_(const char *custom_preset, size_t len) const {
if (this->supported_custom_presets_) {
return vector_find(*this->supported_custom_presets_, custom_preset, len);
}
// Compat: check owned vector from deprecated setters. Remove in 2026.11.0.
return vector_find(this->compat_custom_presets_, custom_preset, len);
}
void set_supported_custom_fan_modes_(const ClimateCustomModes *modes) { this->supported_custom_fan_modes_ = modes; }
void set_supported_custom_presets_(const ClimateCustomModes *presets) { this->supported_custom_presets_ = presets; }
uint32_t feature_flags_{0};
float visual_min_temperature_{10};
@@ -279,17 +206,10 @@ class ClimateTraits {
climate::ClimatePresetMask supported_presets_;
TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS};
/** Custom mode storage - pointers to vectors owned by the Climate base class.
*
* ClimateTraits does not own this data; Climate stores the vectors and
* get_traits() wires these pointers automatically.
*/
const std::vector<const char *> *supported_custom_fan_modes_{nullptr};
const std::vector<const char *> *supported_custom_presets_{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_custom_fan_modes_;
std::vector<const char *> compat_custom_presets_;
/// Owned by the Climate entity; get_traits() wires these pointers.
const ClimateCustomModes *supported_custom_fan_modes_{nullptr};
const ClimateCustomModes *supported_custom_presets_{nullptr};
};
static_assert(std::is_trivially_copyable_v<ClimateTraits>, "ClimateTraits is copied on every traits() call");
} // namespace esphome::climate
-3
View File
@@ -6,9 +6,6 @@
namespace esphome::midea::ac {
const char Constants::TAG[] PROGMEM = "midea";
const char *const Constants::FREEZE_PROTECTION = "Freeze Protection";
const char *const Constants::SILENT = "Silent";
const char *const Constants::TURBO = "Turbo";
ClimateMode Converters::to_climate_mode(MideaMode mode) {
switch (mode) {
+3 -3
View File
@@ -18,9 +18,9 @@ using MideaPreset = dudanov::midea::ac::Preset;
class Constants {
public:
static const char TAG[]; // PROGMEM, like ESPHOME_LOG_TAG
static const char *const FREEZE_PROTECTION;
static const char *const SILENT;
static const char *const TURBO;
static constexpr const char *FREEZE_PROTECTION = "Freeze Protection";
static constexpr const char *SILENT = "Silent";
static constexpr const char *TURBO = "Turbo";
};
class Converters {
@@ -52,8 +52,6 @@ class AirConditioner final : public ApplianceBase<dudanov::midea::ac::AirConditi
void set_supported_modes(ClimateModeMask modes) { this->supported_modes_ = modes; }
void set_supported_swing_modes(ClimateSwingModeMask modes) { this->supported_swing_modes_ = modes; }
void set_supported_presets(ClimatePresetMask presets) { this->supported_presets_ = presets; }
void set_custom_presets(std::initializer_list<const char *> presets) { this->set_supported_custom_presets(presets); }
void set_custom_fan_modes(std::initializer_list<const char *> modes) { this->set_supported_custom_fan_modes(modes); }
protected:
void control(const ClimateCall &call) override;
+2 -4
View File
@@ -234,10 +234,8 @@ async def to_code(config):
cg.add(var.set_supported_swing_modes(config[CONF_SUPPORTED_SWING_MODES]))
if CONF_SUPPORTED_PRESETS in config:
cg.add(var.set_supported_presets(config[CONF_SUPPORTED_PRESETS]))
if CONF_CUSTOM_PRESETS in config:
cg.add(var.set_custom_presets(config[CONF_CUSTOM_PRESETS]))
if CONF_CUSTOM_FAN_MODES in config:
cg.add(var.set_custom_fan_modes(config[CONF_CUSTOM_FAN_MODES]))
climate.set_custom_presets(var, config.get(CONF_CUSTOM_PRESETS, []))
climate.set_custom_fan_modes(var, config.get(CONF_CUSTOM_FAN_MODES, []))
sensors = sensor.sub_sensors(config)
await sensors(CONF_OUTDOOR_TEMPERATURE, var.set_outdoor_temperature_sensor)
await sensors(CONF_POWER_USAGE, var.set_power_sensor)
@@ -324,12 +324,7 @@ async def to_code(config: ConfigType) -> None:
for mode in config.get(CONF_SUPPORTED_FAN_MODES, []):
cg.add(var.add_supported_fan_mode(mode))
if CONF_CUSTOM_FAN_MODES in config:
cg.add(
var.set_supported_custom_fan_modes(
cg.ArrayInitializer(*config[CONF_CUSTOM_FAN_MODES])
)
)
climate.set_custom_fan_modes(var, config.get(CONF_CUSTOM_FAN_MODES, []))
for mode in config.get(CONF_SUPPORTED_SWING_MODES, []):
cg.add(var.add_supported_swing_mode(mode))
@@ -337,12 +332,7 @@ async def to_code(config: ConfigType) -> None:
for preset in config.get(CONF_SUPPORTED_PRESETS, []):
cg.add(var.add_supported_preset(preset))
if CONF_CUSTOM_PRESETS in config:
cg.add(
var.set_supported_custom_presets(
cg.ArrayInitializer(*config[CONF_CUSTOM_PRESETS])
)
)
climate.set_custom_presets(var, config.get(CONF_CUSTOM_PRESETS, []))
for key, trigger_getter, arg_type in SET_ACTIONS:
if (conf := config.get(key)) is not None:
@@ -0,0 +1,33 @@
esphome:
name: test
esp8266:
board: esp01_1m
sensor:
- platform: template
id: room_temp
lambda: return 21.0;
climate:
- platform: template
id: climate_a
name: Climate A
optimistic: true
sensor: room_temp
supported_modes: ["OFF", HEAT]
custom_fan_modes: [turbo, silent]
custom_presets: [eco]
- platform: template
id: climate_b
name: Climate B
optimistic: true
sensor: room_temp
supported_modes: ["OFF", HEAT]
custom_fan_modes: [turbo, silent]
- platform: template
id: climate_c
name: Climate C
optimistic: true
sensor: room_temp
supported_modes: ["OFF", HEAT]
@@ -0,0 +1,27 @@
"""Custom fan modes and presets from YAML live in shared flash tables."""
from collections.abc import Callable
from pathlib import Path
import re
def test_custom_modes_use_shared_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("custom_modes.yaml"))
fan_tables = re.findall(
r"static constexpr const char \* (climate_custom_fan_modes\w*)\[\] PROGMEM",
main_cpp,
)
assert len(fan_tables) == 1 # Climate A and B share one table
assert (
main_cpp.count(f"set_supported_custom_fan_modes_static({fan_tables[0]}, 2);")
== 2
)
assert (
"climate_a->set_supported_custom_presets_static(climate_custom_presets, 1);"
in main_cpp
)
assert "climate_c->set_supported_custom" not in main_cpp
+34
View File
@@ -1,4 +1,7 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/components/climate/climate.h"
namespace esphome::climate::testing {
@@ -70,4 +73,35 @@ TEST(ClimateRestoreStateTest, KeepsRestoringTheOtherFieldsWhenTheModeIsDropped)
EXPECT_EQ(climate.fan_mode, CLIMATE_FAN_HIGH);
}
static constexpr const char *const FAN_TABLE[] = {"Turbo", "Silent"};
TEST(ClimateCustomModesTest, StaticTableIsViewedNotCopied) {
TestClimate climate;
climate.set_supported_custom_fan_modes_static(FAN_TABLE, 2);
auto traits = climate.get_traits();
ASSERT_EQ(traits.get_supported_custom_fan_modes().size(), 2u);
EXPECT_EQ(traits.get_supported_custom_fan_modes().data(), FAN_TABLE);
EXPECT_TRUE(traits.get_supports_fan_modes());
EXPECT_TRUE(traits.supports_custom_fan_mode("Silent"));
EXPECT_FALSE(traits.supports_custom_fan_mode("Quiet"));
}
TEST(ClimateCustomModesTest, RuntimeListsAreCopiedAndReplaced) {
TestClimate climate;
std::vector<const char *> presets{"Eco", "Sleep"};
climate.set_supported_custom_presets(presets);
presets[0] = "Changed"; // the climate keeps its own copy
EXPECT_STREQ(climate.get_traits().get_supported_custom_presets()[0], "Eco");
climate.set_supported_custom_presets({"Night"}); // frees the previous copy
ASSERT_EQ(climate.get_traits().get_supported_custom_presets().size(), 1u);
EXPECT_STREQ(climate.get_traits().get_supported_custom_presets()[0], "Night");
}
TEST(ClimateCustomModesTest, TraitsWithoutAClimateHaveEmptyLists) {
ClimateTraits traits;
EXPECT_TRUE(traits.get_supported_custom_fan_modes().empty());
EXPECT_TRUE(traits.get_supported_custom_presets().empty());
EXPECT_FALSE(traits.supports_custom_preset("Eco"));
}
} // namespace esphome::climate::testing
@@ -288,6 +288,10 @@ button:
ESP_LOGD("test", "%s", preset);
for (const char *mode : traits.get_supported_custom_fan_modes())
ESP_LOGD("test", "%s", mode);
if (fan_modes.size() > 1) {
const auto *it = std::find(fan_modes.begin(), fan_modes.end(), fan_modes[1]);
ESP_LOGD("test", "index %d", (int) (it - fan_modes.begin()));
}
std::vector<const char *> presets{"eco_plus", "max"};
id(template_climate_custom_modes).set_supported_custom_presets(presets);
id(template_climate_custom_modes).set_supported_custom_presets({"eco_plus"});
@@ -1,4 +0,0 @@
"""Legacy climate component — tests deprecated ClimateTraits setters backward compat.
Remove this entire directory in 2026.11.0 when the deprecated setters are removed.
"""
@@ -1,16 +0,0 @@
"""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)
@@ -1,55 +0,0 @@
#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
@@ -1,18 +0,0 @@
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
@@ -66,7 +66,6 @@
"tests/integration/test_ir_rf_transmit_complete.py": 10.73,
"tests/integration/test_large_message_batching.py": 13.94,
"tests/integration/test_legacy_area.py": 13.34,
"tests/integration/test_legacy_climate_compat.py": 13.19,
"tests/integration/test_legacy_fan_compat.py": 9.31,
"tests/integration/test_light_automations.py": 14.39,
"tests/integration/test_light_binary_effect_off_phase.py": 14.75,
@@ -1,96 +0,0 @@
"""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"