mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 18:22:48 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0c7180f87 | ||
|
|
78c927ec0f | ||
|
|
d54037e67a | ||
|
|
f0ee63ac73 | ||
|
|
f358358c1b | ||
|
|
adfc3fa667 | ||
|
|
407e43a19a | ||
|
|
1033d84077 | ||
|
|
c2a8b54464 | ||
|
|
16186156b7 | ||
|
|
9bd71274f1 | ||
|
|
568f488806 | ||
|
|
0b675edeee | ||
|
|
3915b1fc35 | ||
|
|
c3155fb780 | ||
|
|
1db752fb83 |
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
-16
@@ -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)
|
||||
-55
@@ -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"
|
||||
Reference in New Issue
Block a user