Compare commits

..
53 changed files with 1089 additions and 333 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) = "ConstVector<const char *, true>"];
repeated string supported_custom_fan_modes = 15 [(container_pointer_no_template) = "std::vector<const char *>"];
repeated ClimatePreset supported_presets = 16 [(container_pointer_no_template) = "climate::ClimatePresetMask"];
repeated string supported_custom_presets = 17 [(container_pointer_no_template) = "ConstVector<const char *, true>"];
repeated string supported_custom_presets = 17 [(container_pointer_no_template) = "std::vector<const char *>"];
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 ConstVector<const char *, true> *supported_custom_fan_modes{};
const std::vector<const char *> *supported_custom_fan_modes{};
const climate::ClimatePresetMask *supported_presets{};
const ConstVector<const char *, true> *supported_custom_presets{};
const std::vector<const char *> *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_static(BEDJET_FAN_STEP_NAMES, BEDJET_FAN_SPEED_COUNT);
this->set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES);
this->set_supported_custom_presets({
this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT",
"M1",
-18
View File
@@ -442,24 +442,6 @@ 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)
+15 -5
View File
@@ -479,8 +479,10 @@ void Climate::publish_state() {
ClimateTraits Climate::get_traits() {
auto traits = this->traits();
// Wire custom mode pointers from Climate-owned storage
traits.set_supported_custom_fan_modes_(&this->supported_custom_fan_modes_);
traits.set_supported_custom_presets_(&this->supported_custom_presets_);
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_);
#ifdef USE_CLIMATE_VISUAL_OVERRIDES
if (!std::isnan(this->visual_min_temperature_override_)) {
traits.set_visual_min_temperature(this->visual_min_temperature_override_);
@@ -626,7 +628,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 Climate::find_custom_*_()
* - found_ptr must come from traits.find_custom_*_mode_()
* - Only pointers found in traits are stored, ensuring they remain valid
* - Prevents dangling pointers from temporary strings
*
@@ -682,7 +684,11 @@ 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) {
return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, 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);
}
const char *Climate::find_custom_preset_(const char *custom_preset) {
@@ -690,7 +696,11 @@ const char *Climate::find_custom_preset_(const char *custom_preset) {
}
const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) {
return vector_find(this->supported_custom_presets_, custom_preset, 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);
}
void Climate::dump_traits_(const char *tag) {
+24 -18
View File
@@ -246,34 +246,26 @@ class Climate : public EntityBase {
}
#endif
/// Set the supported custom fan modes; the list is copied (stored on Climate, referenced by ClimateTraits).
/// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits).
void set_supported_custom_fan_modes(std::initializer_list<const char *> modes) {
this->supported_custom_fan_modes_.assign_copy(modes.begin(), modes.size());
this->ensure_custom_fan_modes_().assign(modes.begin(), modes.end());
}
void set_supported_custom_fan_modes(const std::vector<const char *> &modes) {
this->supported_custom_fan_modes_.assign_copy(modes.data(), modes.size());
this->ensure_custom_fan_modes_() = modes;
}
template<size_t N> void set_supported_custom_fan_modes(const char *const (&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);
this->ensure_custom_fan_modes_().assign(modes, modes + N);
}
/// Set the supported custom presets; the list is copied (stored on Climate, referenced by ClimateTraits).
/// Set the supported custom presets (stored on Climate, referenced by ClimateTraits).
void set_supported_custom_presets(std::initializer_list<const char *> presets) {
this->supported_custom_presets_.assign_copy(presets.begin(), presets.size());
this->ensure_custom_presets_().assign(presets.begin(), presets.end());
}
void set_supported_custom_presets(const std::vector<const char *> &presets) {
this->supported_custom_presets_.assign_copy(presets.data(), presets.size());
this->ensure_custom_presets_() = presets;
}
template<size_t N> void set_supported_custom_presets(const char *const (&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);
this->ensure_custom_presets_().assign(presets, presets + N);
}
/// Check if a custom fan mode is currently active.
@@ -396,8 +388,22 @@ class Climate : public EntityBase {
#endif
private:
ClimateCustomModes supported_custom_fan_modes_;
ClimateCustomModes supported_custom_presets_;
/// 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};
/** The active custom fan mode (private - enforces use of safe setters).
*
+22 -7
View File
@@ -2,16 +2,31 @@
namespace esphome::climate {
ClimateTraits::ClimateTraits() = default;
// 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
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_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;
}
const ClimateCustomModes &ClimateTraits::get_supported_custom_presets() const {
return this->supported_custom_presets_ != nullptr ? *this->supported_custom_presets_ : 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;
}
int8_t ClimateTraits::get_target_temperature_accuracy_decimals() const {
+107 -27
View File
@@ -1,7 +1,7 @@
#pragma once
#include <cstring>
#include <type_traits>
#include <vector>
#include "climate_mode.h"
#include "esphome/core/finite_set_mask.h"
#include "esphome/core/helpers.h"
@@ -18,12 +18,9 @@ using ClimateSwingModeMask =
FiniteSetMask<ClimateSwingMode, DefaultBitPolicy<ClimateSwingMode, CLIMATE_SWING_HORIZONTAL + 1>>;
using ClimatePresetMask = FiniteSetMask<ClimatePreset, DefaultBitPolicy<ClimatePreset, CLIMATE_PRESET_ACTIVITY + 1>>;
/// 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
// Lightweight linear search for small vectors (1-20 items) of const char* pointers
// Avoids std::find template overhead
template<typename List> inline bool vector_contains(const List &vec, const char *value, size_t len) {
inline bool vector_contains(const std::vector<const char *> &vec, const char *value, size_t len) {
for (const char *item : vec) {
if (strncmp(item, value, len) == 0 && item[len] == '\0')
return true;
@@ -31,12 +28,12 @@ template<typename List> inline bool vector_contains(const List &vec, const char
return false;
}
template<typename List> inline bool vector_contains(const List &vec, const char *value) {
inline bool vector_contains(const std::vector<const char *> &vec, const char *value) {
return vector_contains(vec, value, strlen(value));
}
// 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) {
// 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) {
for (const char *item : vec) {
if (strncmp(item, value, len) == 0 && item[len] == '\0')
return item;
@@ -74,13 +71,9 @@ template<typename List> inline const char *vector_find(const List &vec, const ch
class Climate; // Forward declaration
class ClimateTraits {
friend class Climate; // Climate wires its custom mode lists into traits
friend class Climate; // Allow Climate to access protected find methods
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; }
@@ -100,14 +93,42 @@ class ClimateTraits {
if (!this->supported_fan_modes_.empty()) {
return true;
}
return this->supported_custom_fan_modes_ != nullptr && !this->supported_custom_fan_modes_->empty();
// 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
}
const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_fan_modes_; }
/// Empty when the climate has no custom fan modes. Set them on the Climate entity.
const ClimateCustomModes &get_supported_custom_fan_modes() const;
// 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;
bool supports_custom_fan_mode(const char *custom_fan_mode) const {
return vector_contains(this->get_supported_custom_fan_modes(), custom_fan_mode);
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
}
bool supports_custom_fan_mode(const std::string &custom_fan_mode) const {
return this->supports_custom_fan_mode(custom_fan_mode.c_str());
@@ -119,10 +140,32 @@ class ClimateTraits {
bool get_supports_presets() const { return !this->supported_presets_.empty(); }
const ClimatePresetMask &get_supported_presets() const { return this->supported_presets_; }
/// Empty when the climate has no custom presets. Set them on the Climate entity.
const ClimateCustomModes &get_supported_custom_presets() const;
// 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;
bool supports_custom_preset(const char *custom_preset) const {
return vector_contains(this->get_supported_custom_presets(), custom_preset);
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
}
bool supports_custom_preset(const std::string &custom_preset) const {
return this->supports_custom_preset(custom_preset.c_str());
@@ -189,8 +232,38 @@ class ClimateTraits {
}
/// Set custom mode pointers (only Climate::get_traits() should call these).
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; }
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);
}
uint32_t feature_flags_{0};
float visual_min_temperature_{10};
@@ -206,10 +279,17 @@ class ClimateTraits {
climate::ClimatePresetMask supported_presets_;
TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS};
/// Owned by the Climate entity; get_traits() wires these pointers.
const ClimateCustomModes *supported_custom_fan_modes_{nullptr};
const ClimateCustomModes *supported_custom_presets_{nullptr};
/** 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_;
};
static_assert(std::is_trivially_copyable_v<ClimateTraits>, "ClimateTraits is copied on every traits() call");
} // namespace esphome::climate
+53 -27
View File
@@ -5,6 +5,7 @@
#include "display_color_utils.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::display {
@@ -52,40 +53,65 @@ void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_
this->line(x1, y1, x2, y2, color);
}
void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels
uint32_t color_value;
// flatten keeps to_color() inlined in the pixel loop; in PSRAM builds GCC at
// -Os stops inlining it once there are two call sites, which costs a call and
// spills per pixel. Without PSRAM there is one call site and it changes
// nothing, and draw_pixel_at() stays a virtual call either way.
template<ColorBitness BITNESS>
static void __attribute__((flatten))
draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
bool big_endian, int x_offset, int y_offset, int x_pad) {
const size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels
// BITNESS is a constant here, so to_color()'s three per pixel divisions
// become a multiply and shift and its bitness switch folds.
const auto draw_source_pixel = [&](int x, int y, size_t source_idx) ESPHOME_ALWAYS_INLINE {
display->draw_pixel_at(
x + x_start, y + y_start,
ColorUtil::to_color(ColorUtil::read_packed<BITNESS>(ptr, source_idx, big_endian), order, BITNESS));
};
// The source and frame buffers are row-major, so walking rows keeps the read
// and the write sequential. When the display swaps the axes before writing,
// walking columns keeps the write sequential instead; with the frame buffer
// in PSRAM a strided write costs more than a strided read.
#ifdef USE_PSRAM
if (display->pixel_axes_swapped()) {
size_t column_idx = y_offset * line_stride + x_offset;
for (int x = 0; x != w; x++, column_idx++) {
size_t source_idx = column_idx;
for (int y = 0; y != h; y++, source_idx += line_stride) {
draw_source_pixel(x, y, source_idx);
}
}
return;
}
#endif
// Row walk: builds without PSRAM, and PSRAM builds whose axes are not swapped.
for (int y = 0; y != h; y++) {
size_t source_idx = (y_offset + y) * line_stride + x_offset;
size_t source_idx_mod;
for (int x = 0; x != w; x++, source_idx++) {
switch (bitness) {
default:
color_value = ptr[source_idx];
break;
case COLOR_BITNESS_565:
source_idx_mod = source_idx * 2;
if (big_endian) {
color_value = (ptr[source_idx_mod] << 8) + ptr[source_idx_mod + 1];
} else {
color_value = ptr[source_idx_mod] + (ptr[source_idx_mod + 1] << 8);
}
break;
case COLOR_BITNESS_888:
source_idx_mod = source_idx * 3;
if (big_endian) {
color_value = (ptr[source_idx_mod + 0] << 16) + (ptr[source_idx_mod + 1] << 8) + ptr[source_idx_mod + 2];
} else {
color_value = ptr[source_idx_mod + 0] + (ptr[source_idx_mod + 1] << 8) + (ptr[source_idx_mod + 2] << 16);
}
break;
}
this->draw_pixel_at(x + x_start, y + y_start, ColorUtil::to_color(color_value, order, bitness));
draw_source_pixel(x, y, source_idx);
}
}
}
void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
switch (bitness) {
case COLOR_BITNESS_565:
draw_packed_pixels<COLOR_BITNESS_565>(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset,
x_pad);
break;
case COLOR_BITNESS_888:
draw_packed_pixels<COLOR_BITNESS_888>(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset,
x_pad);
break;
default:
draw_packed_pixels<COLOR_BITNESS_332>(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset,
x_pad);
break;
}
}
void HOT Display::horizontal_line(int x, int y, int width, Color color) {
// Future: Could be made more efficient by manipulating buffer directly in certain rotations.
for (int i = x; i < x + width; i++)
+11
View File
@@ -139,6 +139,11 @@ enum DisplayRotation {
DISPLAY_ROTATION_270_DEGREES = 270,
};
/// Whether a rotation swaps the x and y axes.
constexpr bool rotation_swaps_axes(DisplayRotation rotation) {
return rotation == DISPLAY_ROTATION_90_DEGREES || rotation == DISPLAY_ROTATION_270_DEGREES;
}
const int EDGES_TRIGON = 3;
const int EDGES_TRIANGLE = 3;
const int EDGES_TETRAGON = 4;
@@ -717,6 +722,12 @@ class Display : public PollingComponent {
virtual DisplayRotation get_rotation() const { return this->rotation_; }
/// True when draw_pixel_at() swaps x and y before writing, so pixels that are
/// neighbours along x land a row apart in the frame buffer. Not the same as
/// get_width()/get_height() swapping: a display that rotates in hardware
/// reports swapped dimensions but writes unswapped pixels.
virtual bool pixel_axes_swapped() const { return rotation_swaps_axes(this->rotation_); }
/** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays,
* returns the type the display is currently configured to.
*/
@@ -2,6 +2,7 @@
#include <array>
#include <cstdint>
#include "esphome/core/color.h"
#include "esphome/core/helpers.h"
namespace esphome::display {
enum ColorOrder : uint8_t { COLOR_ORDER_RGB = 0, COLOR_ORDER_BGR = 1, COLOR_ORDER_GRB = 2 };
@@ -10,6 +11,27 @@ inline static uint8_t esp_scale(uint8_t i, uint8_t scale, uint8_t max_value = 25
class ColorUtil {
public:
/// Reads one packed pixel of the given bitness from a source buffer into the
/// value to_color() takes. BITNESS is a template argument so the byte layout
/// folds; pass it on to to_color() as a constant too, and its channel scaling
/// divides by a constant, which the compiler turns into a multiply and shift.
template<ColorBitness BITNESS>
static ESPHOME_ALWAYS_INLINE uint32_t read_packed(const uint8_t *ptr, size_t index, bool big_endian) {
if constexpr (BITNESS == COLOR_BITNESS_565) {
const size_t idx = index * 2;
if (big_endian)
return (ptr[idx] << 8) + ptr[idx + 1];
return ptr[idx] + (ptr[idx + 1] << 8);
} else if constexpr (BITNESS == COLOR_BITNESS_888) {
const size_t idx = index * 3;
if (big_endian)
return (ptr[idx + 0] << 16) + (ptr[idx + 1] << 8) + ptr[idx + 2];
return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16);
} else {
return ptr[index];
}
}
static Color to_color(uint32_t colorcode, ColorOrder color_order,
ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) {
uint8_t first_color, second_color, third_color;
@@ -106,6 +106,7 @@ class EPaperBase : public Display,
int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; }
int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; }
bool pixel_axes_swapped() const override { return this->effective_transform_ & SWAP_XY; }
void draw_pixel_at(int x, int y, Color color) override;
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
+1
View File
@@ -47,6 +47,7 @@ CanMode = esp32_can_ns.enum("CanMode")
CAN_MODES = {
"NORMAL": CanMode.CAN_MODE_NORMAL,
"LISTENONLY": CanMode.CAN_MODE_LISTEN_ONLY,
"NO_ACK": CanMode.CAN_MODE_NO_ACK,
}
# Currently the driver only supports a subset of the bit rates defined in canbus
+5 -2
View File
@@ -79,10 +79,13 @@ bool ESP32Can::setup_internal() {
}
// Select TWAI mode based on configuration
twai_mode_t twai_mode = (this->mode_ == CAN_MODE_LISTEN_ONLY) ? TWAI_MODE_LISTEN_ONLY : TWAI_MODE_NORMAL;
twai_mode_t twai_mode = TWAI_MODE_NORMAL;
if (this->mode_ == CAN_MODE_LISTEN_ONLY) {
twai_mode = TWAI_MODE_LISTEN_ONLY;
ESP_LOGI(TAG, "CAN bus configured in LISTEN_ONLY mode (passive, no ACKs)");
} else if (this->mode_ == CAN_MODE_NO_ACK) {
twai_mode = TWAI_MODE_NO_ACK;
ESP_LOGI(TAG, "CAN bus configured in NO_ACK mode (transmit without requiring ACK)");
}
twai_general_config_t g_config =
+1
View File
@@ -12,6 +12,7 @@ namespace esphome::esp32_can {
enum CanMode : uint8_t {
CAN_MODE_NORMAL = 0,
CAN_MODE_LISTEN_ONLY = 1,
CAN_MODE_NO_ACK = 2,
};
class ESP32Can final : public canbus::Canbus {
+33 -3
View File
@@ -3,6 +3,7 @@
#include "esphome/components/climate/climate.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
#include "hon_climate.h"
#include "hon_packet.h"
@@ -20,6 +21,27 @@ constexpr size_t ALARM_STATUS_REQUEST_INTERVAL_MS = 600000;
const uint8_t ONE_BUF[] = {0x00, 0x01};
const uint8_t ZERO_BUF[] = {0x00, 0x00};
// Alarm texts indexed by alarm code; the last entry is the fallback for unknown codes
PROGMEM_STRING_TABLE(
HonAlarmMessages, "Outdoor module failure", "Outdoor defrost sensor failure",
"Outdoor compressor exhaust sensor failure", "Outdoor EEPROM abnormality", "Indoor coil sensor failure",
"Indoor-outdoor communication failure", "Power supply overvoltage protection",
"Communication failure between panel and indoor unit", "Outdoor compressor overheat protection",
"Outdoor environmental sensor abnormality", "Full water protection", "Indoor EEPROM failure",
"Outdoor out air sensor failure", "CBD and module communication failure", "Indoor DC fan failure",
"Outdoor DC fan failure", "Door switch failure", "Dust filter needs cleaning reminder", "Water shortage protection",
"Humidity sensor failure", "Indoor temperature sensor failure", "Manipulator limit failure",
"Indoor PM2.5 sensor failure", "Outdoor PM2.5 sensor failure", "Indoor heating overload/high load alarm",
"Outdoor AC current protection", "Outdoor compressor operation abnormality", "Outdoor DC current protection",
"Outdoor no-load failure", "CT current abnormality", "Indoor cooling freeze protection",
"High and low pressure protection", "Compressor out air temperature is too high",
"Outdoor evaporator sensor failure", "Outdoor cooling overload", "Water pump drainage failure",
"Three-phase power supply failure", "Four-way valve failure", "External alarm/scraper flow switch failure",
"Temperature cutoff protection alarm", "Different mode operation failure", "Electronic expansion valve failure",
"Dual heat source sensor Tw failure", "Communication failure with the wired controller",
"Indoor unit address duplication failure", "50Hz zero crossing failure", "Outdoor unit failure",
"Formaldehyde sensor failure", "VOC sensor failure", "CO2 sensor failure", "Firewall failure", "Unknown");
HonClimate::HonClimate()
: cleaning_status_(CleaningState::NO_CLEANING), got_valid_outdoor_temp_(false), active_alarms_{0x00, 0x00, 0x00,
0x00, 0x00, 0x00,
@@ -687,9 +709,17 @@ void HonClimate::process_alarm_message_(const uint8_t *packet, uint8_t size, boo
if ((packet[2 + i] & alarm_bit) != (this->active_alarms_[i] & alarm_bit)) {
bool alarm_status = (packet[2 + i] & alarm_bit) != 0;
int log_level = alarm_status ? ESPHOME_LOG_LEVEL_WARN : ESPHOME_LOG_LEVEL_INFO;
const char *alarm_message = alarm_code < esphome::haier::hon_protocol::HON_ALARM_COUNT
? esphome::haier::hon_protocol::HON_ALARM_MESSAGES[alarm_code].c_str()
: "Unknown";
#ifdef USE_ESP8266
// Static so a delayed on_alarm automation can still read it, but only until the next
// alarm change, including a later one in this packet; `code` stays correct
static char alarm_message[HonAlarmMessages::MAX_LENGTH + 1];
ESPHOME_strncpy_P(alarm_message,
reinterpret_cast<const char *>(
HonAlarmMessages::get_progmem_str(alarm_code, HonAlarmMessages::LAST_INDEX)),
sizeof(alarm_message));
#else
const char *alarm_message = HonAlarmMessages::get_progmem_str(alarm_code, HonAlarmMessages::LAST_INDEX);
#endif
esp_log_printf_(log_level, TAG, __LINE__, "Alarm %s (%d): %s", alarm_status ? "activated" : "deactivated",
alarm_code, alarm_message);
if (alarm_status) {
-56
View File
@@ -197,60 +197,4 @@ enum class SubcommandsControl : uint16_t {
// content: all values like in status packet)
};
const std::string HON_ALARM_MESSAGES[] = {
"Outdoor module failure",
"Outdoor defrost sensor failure",
"Outdoor compressor exhaust sensor failure",
"Outdoor EEPROM abnormality",
"Indoor coil sensor failure",
"Indoor-outdoor communication failure",
"Power supply overvoltage protection",
"Communication failure between panel and indoor unit",
"Outdoor compressor overheat protection",
"Outdoor environmental sensor abnormality",
"Full water protection",
"Indoor EEPROM failure",
"Outdoor out air sensor failure",
"CBD and module communication failure",
"Indoor DC fan failure",
"Outdoor DC fan failure",
"Door switch failure",
"Dust filter needs cleaning reminder",
"Water shortage protection",
"Humidity sensor failure",
"Indoor temperature sensor failure",
"Manipulator limit failure",
"Indoor PM2.5 sensor failure",
"Outdoor PM2.5 sensor failure",
"Indoor heating overload/high load alarm",
"Outdoor AC current protection",
"Outdoor compressor operation abnormality",
"Outdoor DC current protection",
"Outdoor no-load failure",
"CT current abnormality",
"Indoor cooling freeze protection",
"High and low pressure protection",
"Compressor out air temperature is too high",
"Outdoor evaporator sensor failure",
"Outdoor cooling overload",
"Water pump drainage failure",
"Three-phase power supply failure",
"Four-way valve failure",
"External alarm/scraper flow switch failure",
"Temperature cutoff protection alarm",
"Different mode operation failure",
"Electronic expansion valve failure",
"Dual heat source sensor Tw failure",
"Communication failure with the wired controller",
"Indoor unit address duplication failure",
"50Hz zero crossing failure",
"Outdoor unit failure",
"Formaldehyde sensor failure",
"VOC sensor failure",
"CO2 sensor failure",
"Firewall failure",
};
constexpr size_t HON_ALARM_COUNT = sizeof(HON_ALARM_MESSAGES) / sizeof(HON_ALARM_MESSAGES[0]);
} // namespace esphome::haier::hon_protocol
+1
View File
@@ -204,6 +204,7 @@ class IT8951Display : public Display,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; }
int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; }
bool pixel_axes_swapped() const override { return this->effective_transform_ & TRANSFORM_SWAP_XY; }
protected:
int get_height_internal() override { return this->height_; }
+3
View File
@@ -6,6 +6,9 @@
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 constexpr const char *FREEZE_PROTECTION = "Freeze Protection";
static constexpr const char *SILENT = "Silent";
static constexpr const char *TURBO = "Turbo";
static const char *const FREEZE_PROTECTION;
static const char *const SILENT;
static const char *const TURBO;
};
class Converters {
@@ -52,6 +52,8 @@ 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;
+4 -2
View File
@@ -234,8 +234,10 @@ 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]))
climate.set_custom_presets(var, config.get(CONF_CUSTOM_PRESETS, []))
climate.set_custom_fan_modes(var, config.get(CONF_CUSTOM_FAN_MODES, []))
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]))
sensors = sensor.sub_sensors(config)
await sensors(CONF_OUTDOOR_TEMPERATURE, var.set_outdoor_temperature_sensor)
await sensors(CONF_POWER_USAGE, var.set_power_sensor)
+5 -13
View File
@@ -130,19 +130,8 @@ class MipiSpi : public display::Display,
}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
int get_width() override {
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
return HEIGHT;
return WIDTH;
}
int get_height() override {
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
return WIDTH;
return HEIGHT;
}
int get_width() override { return display::rotation_swaps_axes(this->rotation_) ? HEIGHT : WIDTH; }
int get_height() override { return display::rotation_swaps_axes(this->rotation_) ? WIDTH : HEIGHT; }
void set_init_sequence(const uint8_t *sequence, size_t len) {
this->init_sequence_ = sequence;
@@ -621,6 +610,9 @@ class MipiSpiBuffer
#endif
}
// With hardware rotation the buffer is written in drawn coordinates.
bool pixel_axes_swapped() const override { return !HAS_HARDWARE_ROTATION && display::Display::pixel_axes_swapped(); }
// Draw a pixel at the given coordinates.
void draw_pixel_at(int x, int y, Color color) override {
if (this->is_point_clipped(x, y))
@@ -1,4 +1,5 @@
#include "seeed_mr60fda2.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -109,23 +110,10 @@ static uint8_t find_nearest_index(float value, const float *arr, int size) {
return nearest_index;
}
/**
* @brief Convert a float value to a byte array.
*
* This function converts a float value to a byte array.
*
* @param value The float value to convert.
* @param bytes The byte array to store the converted value.
*/
static void float_to_bytes(float value, unsigned char *bytes) {
union {
float float_value;
unsigned char byte_array[4];
} u;
u.float_value = value;
memcpy(bytes, u.byte_array, 4);
}
// Read with 32-bit loads or progmem_memcpy only; the radar takes the floats little-endian
alignas(4) static const float INSTALL_HEIGHT[7] PROGMEM = {2.4f, 2.5f, 2.6f, 2.7f, 2.8f, 2.9f, 3.0f};
alignas(4) static const float HEIGHT_THRESHOLD[7] PROGMEM = {0.0f, 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f};
alignas(4) static const float SENSITIVITY[3] PROGMEM = {3, 15, 30};
/**
* @brief Convert a 32-bit unsigned integer to a byte array.
@@ -334,7 +322,7 @@ void MR60FDA2Component::set_install_height(uint8_t index) {
if (index >= std::size(INSTALL_HEIGHT))
return;
uint8_t send_data[13] = {0x01, 0x00, 0x00, 0x00, 0x04, 0x0E, 0x04, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00};
float_to_bytes(INSTALL_HEIGHT[index], &send_data[8]);
progmem_memcpy(&send_data[8], &INSTALL_HEIGHT[index], 4);
send_data[12] = calculate_checksum(send_data + 8, 4);
this->write_array(send_data, 13);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
@@ -347,7 +335,7 @@ void MR60FDA2Component::set_height_threshold(uint8_t index) {
if (index >= std::size(HEIGHT_THRESHOLD))
return;
uint8_t send_data[13] = {0x01, 0x00, 0x00, 0x00, 0x04, 0x0E, 0x08, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00};
float_to_bytes(HEIGHT_THRESHOLD[index], &send_data[8]);
progmem_memcpy(&send_data[8], &HEIGHT_THRESHOLD[index], 4);
send_data[12] = calculate_checksum(send_data + 8, 4);
this->write_array(send_data, 13);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
@@ -48,14 +48,6 @@ enum FrameLocation {
LOCATE_PROCESS_FRAME,
};
static const float INSTALL_HEIGHT[7] = {2.4f, 2.5f, 2.6f, 2.7f, 2.8f, 2.9f, 3.0f};
static const float HEIGHT_THRESHOLD[7] = {0.0f, 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f};
static const float SENSITIVITY[3] = {3, 15, 30};
static const char *const INSTALL_HEIGHT_STR[7] = {"2.4m", "2.5m", "2.6", "2.7m", "2.8", "2.9m", "3.0m"};
static const char *const HEIGHT_THRESHOLD_STR[7] = {"0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"};
static const char *const SENSITIVITY_STR[3] = {"1", "2", "3"};
class MR60FDA2Component final : public Component,
public uart::UARTDevice { // The class name must be the name defined by text_sensor.py
#ifdef USE_BINARY_SENSOR
@@ -324,7 +324,12 @@ async def to_code(config: ConfigType) -> None:
for mode in config.get(CONF_SUPPORTED_FAN_MODES, []):
cg.add(var.add_supported_fan_mode(mode))
climate.set_custom_fan_modes(var, config.get(CONF_CUSTOM_FAN_MODES, []))
if CONF_CUSTOM_FAN_MODES in config:
cg.add(
var.set_supported_custom_fan_modes(
cg.ArrayInitializer(*config[CONF_CUSTOM_FAN_MODES])
)
)
for mode in config.get(CONF_SUPPORTED_SWING_MODES, []):
cg.add(var.add_supported_swing_mode(mode))
@@ -332,7 +337,12 @@ async def to_code(config: ConfigType) -> None:
for preset in config.get(CONF_SUPPORTED_PRESETS, []):
cg.add(var.add_supported_preset(preset))
climate.set_custom_presets(var, config.get(CONF_CUSTOM_PRESETS, []))
if CONF_CUSTOM_PRESETS in config:
cg.add(
var.set_supported_custom_presets(
cg.ArrayInitializer(*config[CONF_CUSTOM_PRESETS])
)
)
for key, trigger_getter, arg_type in SET_ACTIONS:
if (conf := config.get(key)) is not None:
+31
View File
@@ -0,0 +1,31 @@
"""TMP102 threshold status entity."""
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC
from esphome.types import ConfigType
from . import CONF_TMP102_ID, TMP102Component, validate_unique_child
CONFIG_SCHEMA = text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC
).extend(
{
cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component),
}
)
def _final_validate(config: ConfigType) -> ConfigType:
return validate_unique_child(config, "text_sensor")
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_TMP102_TEXT_SENSOR")
parent = await cg.get_variable(config[CONF_TMP102_ID])
var = await text_sensor.new_text_sensor(config)
cg.add(parent.set_threshold_status_text_sensor(var))
+98 -7
View File
@@ -1,8 +1,14 @@
#include "tmp102.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
#ifdef USE_TMP102_TEXT_SENSOR
#include "esphome/core/progmem.h"
#endif
#include <cmath>
#ifdef USE_TMP102_TEXT_SENSOR
#include <cstdio>
#endif
namespace esphome::tmp102 {
@@ -12,6 +18,30 @@ static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0;
static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00;
static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f;
enum TMP102ThresholdStatus : uint8_t {
TMP102_THRESHOLD_STATUS_NOT_CONFIGURED,
TMP102_THRESHOLD_STATUS_SETUP_FAILED,
TMP102_THRESHOLD_STATUS_OK,
TMP102_THRESHOLD_STATUS_RESTORE_RESET,
TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED,
TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED,
TMP102_THRESHOLD_STATUS_RECOVERY_FAILED,
TMP102_THRESHOLD_STATUS_RECOVERED,
TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED,
TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED,
TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED,
TMP102_THRESHOLD_STATUS_COUNT,
};
#ifdef USE_TMP102_TEXT_SENSOR
PROGMEM_STRING_TABLE(ThresholdStatusStrings, "Not configured", "Setup failed", "OK", "Restored thresholds reset",
"Restore reset; save failed", "Preference save failed", "Configuration recovery failed",
"Configuration restored", "Write failed: high threshold", "Write failed: low threshold",
"Rejected: sensor setup failed");
static_assert(ThresholdStatusStrings::COUNT == TMP102_THRESHOLD_STATUS_COUNT);
#endif
#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_BINARY_SENSOR)
static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01;
static constexpr uint16_t TMP102_ALERT_BIT = 0x0020;
@@ -42,15 +72,30 @@ static uint16_t encode_temperature(float temperature, bool extended) {
return static_cast<uint16_t>(static_cast<uint32_t>(static_cast<uint16_t>(steps)) << (extended ? 3 : 4));
}
#endif
#endif
#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR)
void TMP102Component::setup() {
if (!this->configure_)
#ifdef USE_TMP102_CONFIGURE
if (this->configure_) {
this->setup_configured_();
return;
}
#endif
this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_NOT_CONFIGURED);
}
#endif
#ifdef USE_TMP102_CONFIGURE
void TMP102Component::setup_configured_() {
bool corrected = false;
#ifdef USE_TMP102_NUMBER
float restored[2]{};
this->prepare_number_limits_(restored);
bool restore_corrected[2]{};
corrected = this->prepare_number_limits_(restored, restore_corrected);
#endif
if (!this->write_configuration_()) {
this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SETUP_FAILED);
this->mark_failed();
return;
}
@@ -61,14 +106,17 @@ void TMP102Component::setup() {
if (control == nullptr)
continue;
const float value = this->get_limit_temperature_(static_cast<TMP102LimitType>(limit));
if (value != restored[limit])
if (restore_corrected[limit] || value != restored[limit])
saved &= control->save_value_(value);
control->publish_state(value);
}
if (!saved) {
ESP_LOGW(TAG, "Failed to save threshold preferences");
this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED);
return;
}
#endif
this->publish_threshold_status_(corrected ? TMP102_THRESHOLD_STATUS_RESTORE_RESET : TMP102_THRESHOLD_STATUS_OK);
}
#endif
@@ -83,6 +131,9 @@ void TMP102Component::dump_config() {
#ifdef USE_TMP102_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_);
#endif
#ifdef USE_TMP102_TEXT_SENSOR
LOG_TEXT_SENSOR(" ", "Threshold Status", this->threshold_status_text_sensor_);
#endif
#ifdef USE_TMP102_CONFIGURE
if (!this->configure_)
return;
@@ -203,6 +254,9 @@ bool TMP102Component::check_configuration_() {
this->set_warning_and_invalidate_alert_();
if (!this->write_configuration_()) {
ESP_LOGW(TAG, "Failed to restore configuration and thresholds");
this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERY_FAILED);
} else {
this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERED);
}
return false;
}
@@ -228,22 +282,25 @@ void TMP102Component::set_limit_control(TMP102LimitNumber *number) {
this->limit_controls_[number->limit_type_] = number;
}
void TMP102Component::prepare_number_limits_(float *restored) {
bool TMP102Component::prepare_number_limits_(float *restored, bool *restore_corrected) {
const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT;
uint16_t raw[2] = {this->low_limit_, this->high_limit_};
bool corrected = false;
for (uint8_t limit = 0; limit < 2; ++limit) {
TMP102LimitNumber *control = this->limit_controls_[limit];
if (control == nullptr)
continue;
restored[limit] = control->load_value_(decode_temperature(raw[limit], extended));
restored[limit] = control->load_value_(decode_temperature(raw[limit], extended), restore_corrected[limit]);
corrected |= restore_corrected[limit];
raw[limit] = encode_temperature(restored[limit], extended);
}
if (static_cast<int16_t>(raw[TMP102_LIMIT_LOW]) > static_cast<int16_t>(raw[TMP102_LIMIT_HIGH])) {
ESP_LOGW(TAG, "Restored thresholds are inverted; using configured values");
return;
return true;
}
this->low_limit_ = raw[TMP102_LIMIT_LOW];
this->high_limit_ = raw[TMP102_LIMIT_HIGH];
return corrected;
}
float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const {
@@ -261,10 +318,13 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper
ESP_LOGW(TAG, "Rejected %s threshold %.4f°C: crosses %s threshold %.4f°C",
high ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"), decode_temperature(raw, extended),
high ? LOG_STR_LITERAL("low") : LOG_STR_LITERAL("high"), decode_temperature(other, extended));
this->publish_threshold_rejection_(high, decode_temperature(raw, extended), decode_temperature(other, extended));
return false;
}
if (!this->write_register_(static_cast<uint8_t>(TMP102_REGISTER_LOW_LIMIT + limit), raw)) {
this->status_set_warning();
this->publish_threshold_status_(high ? TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED
: TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED);
return false;
}
(high ? this->high_limit_ : this->low_limit_) = raw;
@@ -273,6 +333,32 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper
#endif
#endif
#ifdef USE_TMP102_TEXT_SENSOR
void TMP102Component::publish_threshold_status_(uint8_t status) {
if (this->threshold_status_text_sensor_ == nullptr)
return;
char text[40];
ESPHOME_strncpy_P(
text,
reinterpret_cast<ESPHOME_PGM_P>(ThresholdStatusStrings::get_progmem_str(status, TMP102_THRESHOLD_STATUS_OK)),
sizeof(text));
text[sizeof(text) - 1] = '\0';
this->threshold_status_text_sensor_->publish_state(text);
}
void TMP102Component::publish_threshold_rejection_(bool high, float value, float other) {
if (this->threshold_status_text_sensor_ == nullptr)
return;
char text[96];
if (high) {
ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: high %.4f°C crosses low %.4f°C"), value, other);
} else {
ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: low %.4f°C crosses high %.4f°C"), value, other);
}
this->threshold_status_text_sensor_->publish_state(text);
}
#endif
#ifdef USE_TMP102_BINARY_SENSOR
void TMP102Component::invalidate_alert_() {
if (this->alert_binary_sensor_ != nullptr)
@@ -294,7 +380,7 @@ bool TMP102Component::read_alert_state_() {
#endif
#ifdef USE_TMP102_NUMBER
float TMP102LimitNumber::load_value_(float fallback) {
float TMP102LimitNumber::load_value_(float fallback, bool &corrected) {
float value = fallback;
if (this->restore_value_) {
this->pref_ = this->make_entity_preference<float>();
@@ -303,6 +389,7 @@ float TMP102LimitNumber::load_value_(float fallback) {
if (!std::isfinite(value) || value < this->traits.get_min_value() || value > this->traits.get_max_value()) {
ESP_LOGW(TAG, "Invalid restored threshold; using configured value");
value = fallback;
corrected = true;
}
return value;
}
@@ -313,6 +400,7 @@ void TMP102LimitNumber::control(float value) {
// A chip that failed setup holds nothing the entity could show
if (this->parent_->is_failed()) {
ESP_LOGW(TAG, "Rejected threshold change: sensor setup failed");
this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED);
return;
}
const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value);
@@ -320,6 +408,9 @@ void TMP102LimitNumber::control(float value) {
this->publish_state(value);
if (accepted && !this->save_value_(value)) {
ESP_LOGW(TAG, "Failed to save threshold preference");
this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED);
} else if (accepted) {
this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_OK);
}
}
#endif
+28 -4
View File
@@ -12,11 +12,20 @@
#include "esphome/core/preferences.h"
#endif
#include "esphome/components/sensor/sensor.h"
#ifdef USE_TMP102_TEXT_SENSOR
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#include <cstdint>
namespace esphome::tmp102 {
#ifdef USE_HOST
namespace testing {
class TMP102Test;
}
#endif
#ifdef USE_TMP102_NUMBER
enum TMP102LimitType : uint8_t {
TMP102_LIMIT_LOW = 0,
@@ -31,8 +40,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu
#ifdef USE_TMP102_BINARY_SENSOR
SUB_BINARY_SENSOR(alert)
#endif
#ifdef USE_TMP102_CONFIGURE
#ifdef USE_TMP102_TEXT_SENSOR
SUB_TEXT_SENSOR(threshold_status)
#endif
#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR)
void setup() override;
#endif
#ifdef USE_TMP102_CONFIGURE
void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) {
this->config_ = config;
this->high_limit_ = high_limit;
@@ -47,6 +61,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu
void update() override;
protected:
#ifdef USE_TMP102_TEXT_SENSOR
void publish_threshold_status_(uint8_t status);
void publish_threshold_rejection_(bool high, float value, float other);
#else
void publish_threshold_status_(uint8_t) {}
void publish_threshold_rejection_(bool, float, float) {}
#endif
void set_warning_and_invalidate_alert_() {
this->status_set_warning();
#ifdef USE_TMP102_BINARY_SENSOR
@@ -58,6 +79,7 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu
void invalidate_alert_();
#endif
#ifdef USE_TMP102_CONFIGURE
void setup_configured_();
bool write_register_(uint8_t reg, uint16_t value);
bool write_configuration_();
bool check_configuration_();
@@ -67,11 +89,10 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu
friend class TMP102LimitNumber;
bool set_limit_temperature_(TMP102LimitType limit, float temperature);
float get_limit_temperature_(TMP102LimitType limit) const;
void prepare_number_limits_(float *restored);
bool prepare_number_limits_(float *restored, bool *restore_corrected);
TMP102LimitNumber *limit_controls_[2]{};
#endif
uint16_t config_{0};
uint16_t high_limit_{0};
uint16_t low_limit_{0};
@@ -87,7 +108,10 @@ class TMP102LimitNumber final : public number::Number, public Parented<TMP102Com
protected:
friend class TMP102Component;
float load_value_(float fallback);
#ifdef USE_HOST
friend class testing::TMP102Test;
#endif
float load_value_(float fallback, bool &corrected);
bool save_value_(float value);
void control(float value) override;
+1
View File
@@ -272,6 +272,7 @@
#define USE_TMP102_BINARY_SENSOR
#define USE_TMP102_CONFIGURE
#define USE_TMP102_NUMBER
#define USE_TMP102_TEXT_SENSOR
#define USE_TOUCHSCREEN
#define USE_UART_DEBUGGER
#define USE_UART_WAKE_LOOP_ON_RX
+28 -7
View File
@@ -1,5 +1,6 @@
#pragma once
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
@@ -68,6 +69,7 @@ template<size_t N> struct FixedString {
};
/// Compile-time string table that packs strings into a single blob with offset lookup.
/// Blobs over 255 bytes have no offsets and are scanned, so keep those for rarely read strings.
/// Use PROGMEM_STRING_TABLE macro to instantiate with proper flash placement on ESP8266.
///
/// Example:
@@ -78,6 +80,11 @@ template<size_t N> struct FixedString {
template<FixedString... Strs> struct ProgmemStringTable {
static constexpr size_t COUNT = sizeof...(Strs);
static constexpr size_t BLOB_SIZE = (0 + ... + (Strs.size() + 1));
/// Length of the longest string, for sizing a RAM copy on ESP8266
static constexpr size_t MAX_LENGTH = std::max({Strs.size()...});
/// One-byte offsets address up to 255 bytes; a larger blob has no offset table and is scanned instead,
/// which costs less flash than wider offsets for the rarely read tables that get this big.
static constexpr bool INDEXED = BLOB_SIZE <= UINT8_MAX;
/// Generate packed string blob at compile time
static constexpr auto make_blob() {
@@ -91,18 +98,28 @@ template<FixedString... Strs> struct ProgmemStringTable {
return result;
}
/// Generate offset table at compile time (uint8_t limits blob to 255 bytes)
/// Generate offset table at compile time
static constexpr auto make_offsets() {
static_assert(COUNT > 0, "PROGMEM_STRING_TABLE must contain at least one string");
static_assert(COUNT <= 255, "PROGMEM_STRING_TABLE supports at most 255 strings with uint8_t indices");
static_assert(BLOB_SIZE <= 255, "PROGMEM_STRING_TABLE blob exceeds 255 bytes; use fewer/shorter strings");
std::array<uint8_t, COUNT> result{};
size_t pos = 0, idx = 0;
((result[idx++] = static_cast<uint8_t>(pos), pos += Strs.size() + 1), ...);
return result;
if constexpr (INDEXED) {
std::array<uint8_t, COUNT> result{};
size_t pos = 0, idx = 0;
((result[idx++] = static_cast<uint8_t>(pos), pos += Strs.size() + 1), ...);
return result;
} else {
return std::array<uint8_t, 0>{};
}
}
};
/// Skip idx strings in a packed NUL-separated blob that may live in flash.
inline const char *progmem_skip_strings(const char *blob, uint8_t idx) {
while (idx-- > 0)
blob += ESPHOME_strlen_P(blob) + 1;
return blob;
}
// Forward declaration for LogString (defined in log.h)
struct LogString;
@@ -115,6 +132,7 @@ struct LogString;
static constexpr size_t COUNT = Table::COUNT; \
static constexpr uint8_t LAST_INDEX = COUNT - 1; \
static constexpr size_t BLOB_SIZE = Table::BLOB_SIZE; \
static constexpr size_t MAX_LENGTH = Table::MAX_LENGTH; \
static constexpr auto BLOB ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_blob(); \
static constexpr auto OFFSETS ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_offsets(); \
static const char *get_(uint8_t idx, uint8_t fallback) { \
@@ -122,7 +140,10 @@ struct LogString;
idx = fallback; \
/* std::launder is used here to prevent the inter-procedural analysis that */ \
/* causes the false positive that the string is not null terminated */ \
return std::launder(&BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]); \
if constexpr (Table::INDEXED) \
return std::launder(&BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]); \
else \
return std::launder(::esphome::progmem_skip_strings(BLOB.data(), idx)); \
} \
static ::ProgmemStr get_progmem_str(uint8_t idx, uint8_t fallback) { \
return reinterpret_cast<::ProgmemStr>(get_(idx, fallback)); \
@@ -1,33 +0,0 @@
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]
@@ -1,27 +0,0 @@
"""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
@@ -0,0 +1,23 @@
---
esphome:
name: tmp102-test
esp32:
board: esp32dev
i2c:
sda: GPIO21
scl: GPIO22
sensor:
- platform: tmp102
id: parent_sensor
name: Temperature
text_sensor:
- platform: tmp102
tmp102_id: parent_sensor
name: First Status
- platform: tmp102
tmp102_id: parent_sensor
name: Second Status
@@ -0,0 +1,20 @@
---
esphome:
name: tmp102-test
esp32:
board: esp32dev
i2c:
sda: GPIO21
scl: GPIO22
sensor:
- platform: tmp102
id: parent_sensor
name: Temperature
text_sensor:
- platform: tmp102
tmp102_id: parent_sensor
name: Threshold Status
@@ -58,6 +58,30 @@ def test_duplicate_alert_entities_are_rejected(
)
def test_status_entity_is_read_only(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
) -> None:
source = generate_main(component_config_path("status.yaml"))
assert "set_threshold_status_text_sensor" in source
assert "set_configuration" not in source
defines = " ".join(define.name for define in CORE.defines)
assert "USE_TMP102_TEXT_SENSOR" in defines
assert "USE_TMP102_CONFIGURE" not in defines
def test_duplicate_status_entities_are_rejected(
component_config_path: Callable[[str], Path],
capsys: pytest.CaptureFixture[str],
) -> None:
CORE.config_path = component_config_path("duplicate_status.yaml")
assert read_config({}) is None
assert (
"Only one TMP102 text_sensor may reference each temperature sensor"
in capsys.readouterr().out
)
def test_threshold_numbers_enable_configuration_once(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
-34
View File
@@ -1,7 +1,4 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/components/climate/climate.h"
namespace esphome::climate::testing {
@@ -73,35 +70,4 @@ 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
@@ -0,0 +1,65 @@
#include <gtest/gtest.h>
#include <cstring>
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::core::testing {
PROGMEM_STRING_TABLE(SmallStrings, "OFF", "HEAT", "COOL", "UNKNOWN");
// Eight strings of 40 characters: a 328-byte blob, past what one-byte offsets can address, so it is scanned.
PROGMEM_STRING_TABLE(LargeStrings, "0123456789012345678901234567890123456789",
"1123456789012345678901234567890123456789", "2123456789012345678901234567890123456789",
"3123456789012345678901234567890123456789", "4123456789012345678901234567890123456789",
"5123456789012345678901234567890123456789", "6123456789012345678901234567890123456789",
"7123456789012345678901234567890123456789");
// Five 50-character strings pack into exactly 255 bytes; one more byte switches to scanning.
PROGMEM_STRING_TABLE(Exactly255, "01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789");
PROGMEM_STRING_TABLE(Exactly256, "01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789x");
TEST(ProgmemStringTable, ScansInsteadOfIndexingAfter255Bytes) {
static_assert(Exactly255::BLOB_SIZE == 255);
static_assert(Exactly255::Table::INDEXED);
static_assert(Exactly256::BLOB_SIZE == 256);
static_assert(!Exactly256::Table::INDEXED);
static_assert(Exactly256::OFFSETS.empty());
EXPECT_EQ(Exactly255::OFFSETS[4], 204);
EXPECT_STREQ(reinterpret_cast<const char *>(Exactly256::get_progmem_str(4, 0)),
"01234567890123456789012345678901234567890123456789x");
}
TEST(ProgmemStringTable, SmallTableKeepsOneByteOffsets) {
static_assert(SmallStrings::BLOB_SIZE <= 255);
static_assert(SmallStrings::Table::INDEXED);
static_assert(SmallStrings::MAX_LENGTH == 7);
EXPECT_STREQ(reinterpret_cast<const char *>(SmallStrings::get_log_str(0, SmallStrings::LAST_INDEX)), "OFF");
EXPECT_STREQ(reinterpret_cast<const char *>(SmallStrings::get_log_str(2, SmallStrings::LAST_INDEX)), "COOL");
EXPECT_STREQ(reinterpret_cast<const char *>(SmallStrings::get_log_str(200, SmallStrings::LAST_INDEX)), "UNKNOWN");
}
TEST(ProgmemStringTable, LargeTableIsScanned) {
static_assert(LargeStrings::BLOB_SIZE > 255);
static_assert(!LargeStrings::Table::INDEXED);
static_assert(LargeStrings::MAX_LENGTH == 40);
EXPECT_STREQ(reinterpret_cast<const char *>(LargeStrings::get_progmem_str(0, LargeStrings::LAST_INDEX)),
"0123456789012345678901234567890123456789");
EXPECT_STREQ(reinterpret_cast<const char *>(LargeStrings::get_progmem_str(7, LargeStrings::LAST_INDEX)),
"7123456789012345678901234567890123456789");
EXPECT_STREQ(reinterpret_cast<const char *>(LargeStrings::get_progmem_str(8, LargeStrings::LAST_INDEX)),
"7123456789012345678901234567890123456789");
EXPECT_STREQ(reinterpret_cast<const char *>(LargeStrings::get_progmem_str(3, LargeStrings::LAST_INDEX)),
"3123456789012345678901234567890123456789");
}
} // namespace esphome::core::testing
+20
View File
@@ -0,0 +1,20 @@
from pathlib import Path
import esphome.codegen as cg
from esphome.const import CONF_ESPHOME, CONF_INCLUDES
from esphome.core import CORE
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
TESTS_DIR = Path(__file__).parent
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The column walk in Display::draw_pixels_at() is only built with PSRAM; define it
# when display's own tests are built so both walk orders run, and leave other
# components' test binaries on the plain build.
async def to_code_testing(config: ConfigType) -> None:
if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]):
cg.add_define("USE_PSRAM")
manifest.to_code = to_code_testing
@@ -0,0 +1,109 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <initializer_list>
#include <tuple>
#include <vector>
#include "esphome/components/display/display.h"
namespace esphome::display::testing {
// Records every pixel write; pixel_axes_swapped() picks the walk order.
class RecordingDisplay : public Display {
public:
void set_axes_swapped(bool swapped) { this->axes_swapped_ = swapped; }
bool pixel_axes_swapped() const override { return this->axes_swapped_; }
void draw_pixel_at(int x, int y, Color color) override { this->pixels_.emplace_back(x, y, color.raw_32); }
void update() override {}
DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return 64; }
int get_height_internal() override { return 64; }
std::vector<std::tuple<int, int, uint32_t>> sorted_pixels() const {
auto sorted = this->pixels_;
std::sort(sorted.begin(), sorted.end());
return sorted;
}
size_t count() const { return this->pixels_.size(); }
const std::vector<std::tuple<int, int, uint32_t>> &pixels() const { return this->pixels_; }
protected:
bool axes_swapped_{false};
std::vector<std::tuple<int, int, uint32_t>> pixels_;
};
// A 7x5 region with a 2 pixel left offset, a 3 line top offset and 4 pixels of
// right padding, so the source index arithmetic of both walks is exercised.
static constexpr int W = 7, H = 5, X_OFFSET = 2, Y_OFFSET = 3, X_PAD = 4;
static constexpr int LINE_STRIDE = X_OFFSET + W + X_PAD;
static constexpr int LINES = Y_OFFSET + H;
static std::vector<uint8_t> make_source(int bytes_per_pixel) {
std::vector<uint8_t> source(LINE_STRIDE * LINES * bytes_per_pixel);
uint8_t value = 1;
for (auto &byte : source)
byte = value++;
return source;
}
static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool big_endian) {
const auto source = make_source(bytes_per_pixel);
RecordingDisplay rows;
RecordingDisplay columns;
columns.set_axes_swapped(true);
rows.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD);
columns.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD);
EXPECT_EQ(rows.count(), static_cast<size_t>(W * H));
EXPECT_EQ(columns.count(), static_cast<size_t>(W * H));
EXPECT_EQ(rows.sorted_pixels(), columns.sorted_pixels());
}
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk332) { expect_walks_match(COLOR_BITNESS_332, 1, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565BigEndian) { expect_walks_match(COLOR_BITNESS_565, 2, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565LittleEndian) { expect_walks_match(COLOR_BITNESS_565, 2, false); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888BigEndian) { expect_walks_match(COLOR_BITNESS_888, 3, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888LittleEndian) { expect_walks_match(COLOR_BITNESS_888, 3, false); }
// One known pixel per bitness and byte order, so a decode that swapped bytes or channels
// could not pass the walk comparison above by being wrong the same way twice.
static void expect_golden(ColorBitness bitness, std::initializer_list<uint8_t> bytes, bool big_endian, Color expected) {
const std::vector<uint8_t> source(bytes);
RecordingDisplay display;
display.draw_pixels_at(3, 4, 1, 1, source.data(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0);
ASSERT_EQ(display.count(), 1u);
EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32));
}
TEST(DrawPixelsAt, Golden565BigEndian) {
expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, true, Color(230, 0, 57)); // 0xE007: r 28, g 0, b 7
}
TEST(DrawPixelsAt, Golden565LittleEndian) {
expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, false, Color(0, 255, 0)); // 0x07E0: pure green
}
TEST(DrawPixelsAt, Golden888BigEndian) {
expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x10, 0x20, 0x30));
}
TEST(DrawPixelsAt, Golden888LittleEndian) {
expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, false, Color(0x30, 0x20, 0x10));
}
TEST(DrawPixelsAt, Golden332) {
expect_golden(COLOR_BITNESS_332, {0xE3}, true, Color(255, 0, 255)); // r 7 of 7, g 0, b 3 of 3
}
// The column walk really is a different order, otherwise the tests above prove nothing.
TEST(DrawPixelsAt, ColumnWalkChangesOrder) {
const auto source = make_source(1);
RecordingDisplay rows;
RecordingDisplay columns;
columns.set_axes_swapped(true);
rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD);
columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET,
X_PAD);
ASSERT_GT(rows.count(), 1u);
ASSERT_GT(columns.count(), 1u);
EXPECT_EQ(std::get<0>(rows.pixels()[1]), 1); // the row walk steps x first
EXPECT_EQ(std::get<1>(columns.pixels()[1]), 1); // the column walk steps y first
}
} // namespace esphome::display::testing
+1 -1
View File
@@ -18,7 +18,7 @@ canbus:
tx_pin: ${tx_pin}
can_id: 4
bit_rate: 50kbps
mode: NORMAL
mode: NO_ACK
on_frame:
- can_id: 500
then:
@@ -288,10 +288,6 @@ 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"});
+3 -1
View File
@@ -6,11 +6,12 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
for platform in ("sensor", "binary_sensor", "number"):
for platform in ("sensor", "binary_sensor", "number", "text_sensor"):
CORE.testing_ensure_platform_registered(platform)
cg.add_define("USE_TMP102_CONFIGURE")
cg.add_define("USE_TMP102_BINARY_SENSOR")
cg.add_define("USE_TMP102_NUMBER")
cg.add_define("USE_TMP102_TEXT_SENSOR")
manifest.to_code = to_code_testing
manifest.dependencies = manifest.dependencies + [
@@ -18,4 +19,5 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
"sensor",
"binary_sensor",
"number",
"text_sensor",
]
+5
View File
@@ -30,3 +30,8 @@ binary_sensor:
- platform: tmp102
tmp102_id: tmp102_extended_temperature
name: TMP102 Alert
text_sensor:
- platform: tmp102
tmp102_id: tmp102_extended_temperature
name: TMP102 Threshold Status
@@ -0,0 +1,12 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
sensor:
- platform: tmp102
id: tmp102_text_sensor_parent
name: TMP102 Temperature
text_sensor:
- platform: tmp102
tmp102_id: tmp102_text_sensor_parent
name: TMP102 Threshold Status
+94
View File
@@ -4,6 +4,7 @@
#include <array>
#include <cmath>
#include <functional>
#include <utility>
#include "esphome/components/host/preferences.h"
#include "esphome/components/tmp102/tmp102.h"
@@ -29,6 +30,7 @@ class RegisterBus final : public i2c::I2CBus {
unsigned transactions{0};
unsigned writes{0};
uint8_t pointer{0};
std::function<void()> on_write;
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out,
size_t out_size) override {
@@ -42,6 +44,10 @@ class RegisterBus final : public i2c::I2CBus {
return i2c::ERROR_NOT_ACKNOWLEDGED;
++this->writes;
this->registers[this->pointer] = (uint16_t(data[1]) << 8) | data[2];
if (this->on_write) {
auto callback = std::move(this->on_write);
callback();
}
}
}
if (out_size != 0) {
@@ -59,6 +65,7 @@ class TMP102Test : public ::testing::Test {
RegisterBus bus_;
TMP102Component chip_;
binary_sensor::BinarySensor alert_;
text_sensor::TextSensor status_;
TMP102LimitNumber high_{TMP102_LIMIT_HIGH};
TMP102LimitNumber low_{TMP102_LIMIT_LOW};
unsigned publications_{0};
@@ -122,6 +129,8 @@ class TMP102Test : public ::testing::Test {
static void save(TMP102LimitNumber &number, float value) {
ASSERT_TRUE(number.make_entity_preference<float>().save(&value));
}
static void fail_saves(TMP102LimitNumber &number) { number.pref_ = {}; }
};
TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) {
@@ -136,6 +145,24 @@ TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) {
EXPECT_EQ(this->bus_.registers, original);
}
TEST_F(TMP102Test, ReadOnlyStatusDoesNotConfigureOrWriteRegisters) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
const auto original = this->bus_.registers;
this->chip_.setup();
EXPECT_EQ(this->bus_.transactions, 0);
EXPECT_EQ(this->bus_.registers, original);
ASSERT_TRUE(this->status_.has_state());
EXPECT_EQ(this->status_.state, "Not configured");
}
TEST_F(TMP102Test, ConfiguredSetupPublishesStatus) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
this->configure();
this->chip_.setup();
ASSERT_TRUE(this->status_.has_state());
EXPECT_EQ(this->status_.state, "OK");
}
TEST_F(TMP102Test, SampleFormatOverridesConfiguredModeInBothDirections) {
for (bool configured_extended : {false, true}) {
this->configure(configured_extended ? 0x0010 : 0, configured_extended ? 0x2800 : 0x5000,
@@ -408,6 +435,7 @@ TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) {
}
TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
this->numbers_();
save(this->high_, 20);
save(this->low_, 35);
@@ -416,30 +444,53 @@ TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) {
EXPECT_FLOAT_EQ(this->low_.state, 25);
EXPECT_FLOAT_EQ(saved(this->high_), 30);
EXPECT_FLOAT_EQ(saved(this->low_), 25);
EXPECT_EQ(this->status_.state, "Restored thresholds reset");
}
TEST_F(TMP102Test, InvalidRestoredPairAndSaveFailurePublishCombinedStatus) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
this->numbers_();
save(this->high_, 20);
save(this->low_, 35);
this->bus_.on_write = [this]() {
fail_saves(this->high_);
fail_saves(this->low_);
};
this->chip_.setup();
EXPECT_FLOAT_EQ(this->high_.state, 30);
EXPECT_FLOAT_EQ(this->low_.state, 25);
EXPECT_EQ(this->status_.state, "Restore reset; save failed");
}
TEST_F(TMP102Test, InvalidRestoredValuesAreRepaired) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
this->numbers_();
save(this->high_, NAN);
save(this->low_, INFINITY);
this->chip_.setup();
EXPECT_FLOAT_EQ(this->high_.state, 30);
EXPECT_FLOAT_EQ(this->low_.state, 25);
EXPECT_FLOAT_EQ(saved(this->high_), 30);
EXPECT_FLOAT_EQ(saved(this->low_), 25);
EXPECT_EQ(this->status_.state, "Restored thresholds reset");
}
TEST_F(TMP102Test, FailedSetupDoesNotOverwritePreferences) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
this->numbers_();
save(this->high_, 90);
save(this->low_, 85);
this->bus_.fail_write = 3;
this->chip_.setup();
EXPECT_TRUE(this->chip_.is_failed());
EXPECT_EQ(this->status_.state, "Setup failed");
EXPECT_FALSE(this->high_.has_state());
EXPECT_FLOAT_EQ(saved(this->high_), 90);
EXPECT_FLOAT_EQ(saved(this->low_), 85);
}
TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
this->numbers_();
save(this->high_, 90);
this->bus_.fail_write = 3;
@@ -450,6 +501,7 @@ TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) {
EXPECT_FALSE(this->high_.has_state());
EXPECT_EQ(this->bus_.writes, writes);
EXPECT_FLOAT_EQ(saved(this->high_), 90);
EXPECT_EQ(this->status_.state, "Rejected: sensor setup failed");
}
TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) {
@@ -464,6 +516,7 @@ TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) {
}
TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
this->numbers_();
save(this->high_, 90);
save(this->low_, 85);
@@ -475,6 +528,47 @@ TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) {
EXPECT_FLOAT_EQ(this->high_.state, 90);
EXPECT_EQ(publications, 1);
EXPECT_FLOAT_EQ(saved(this->high_), 90);
EXPECT_EQ(this->status_.state, "Write failed: high threshold");
this->bus_.fail_write = 2;
this->low_.make_call().set_value(80).perform();
EXPECT_FLOAT_EQ(this->low_.state, 85);
EXPECT_FLOAT_EQ(saved(this->low_), 85);
EXPECT_EQ(this->status_.state, "Write failed: low threshold");
}
TEST_F(TMP102Test, PreferenceSaveFailurePublishesStatus) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
this->numbers_();
this->chip_.setup();
fail_saves(this->high_);
this->high_.make_call().set_value(35).perform();
EXPECT_FLOAT_EQ(this->high_.state, 35);
EXPECT_EQ(this->status_.state, "Preference save failed");
}
TEST_F(TMP102Test, RejectedNumberWritePublishesStatus) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
this->numbers_();
this->chip_.setup();
this->high_.make_call().set_value(20).perform();
EXPECT_EQ(this->status_.state, "Rejected: high 20.0000°C crosses low 25.0000°C");
this->high_.make_call().set_value(35).perform();
EXPECT_EQ(this->status_.state, "OK");
}
TEST_F(TMP102Test, ConfigurationRecoveryPublishesStatus) {
this->chip_.set_threshold_status_text_sensor(&this->status_);
this->configure();
this->chip_.setup();
this->bus_.registers[1] = 0;
this->chip_.update();
EXPECT_EQ(this->status_.state, "Configuration restored");
this->bus_.registers[1] = 0;
this->bus_.fail_write = 1;
this->chip_.update();
EXPECT_EQ(this->status_.state, "Configuration recovery failed");
}
TEST_F(TMP102Test, RestoreDisabledNeverChangesSavedValue) {
@@ -0,0 +1,4 @@
"""Legacy climate component — tests deprecated ClimateTraits setters backward compat.
Remove this entire directory in 2026.11.0 when the deprecated setters are removed.
"""
@@ -0,0 +1,16 @@
"""Legacy climate platform that uses deprecated ClimateTraits setters."""
import esphome.codegen as cg
from esphome.components import climate
import esphome.config_validation as cv
from esphome.types import ConfigType
legacy_climate_ns = cg.esphome_ns.namespace("legacy_climate_test")
LegacyClimate = legacy_climate_ns.class_("LegacyClimate", climate.Climate, cg.Component)
CONFIG_SCHEMA = climate.climate_schema(LegacyClimate).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = await climate.new_climate(config)
await cg.register_component(var, config)
@@ -0,0 +1,55 @@
#pragma once
#include "esphome/components/climate/climate.h"
#include "esphome/core/component.h"
namespace esphome::legacy_climate_test {
/// Test climate that uses the DEPRECATED ClimateTraits setters for custom modes.
/// This validates backward compatibility for external components that haven't migrated.
class LegacyClimate : public climate::Climate, public Component {
public:
void setup() override {
this->mode = climate::CLIMATE_MODE_OFF;
this->target_temperature = 22.0f;
this->current_temperature = 20.0f;
this->publish_state();
}
protected:
climate::ClimateTraits traits() override {
auto traits = climate::ClimateTraits();
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_COOL});
traits.set_visual_min_temperature(16.0f);
traits.set_visual_max_temperature(30.0f);
traits.set_visual_temperature_step(0.5f);
// DEPRECATED API: setting custom modes directly on ClimateTraits.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
traits.set_supported_custom_fan_modes({"Turbo", "Silent", "Auto"});
traits.set_supported_custom_presets({"Eco Mode", "Night Mode"});
#pragma GCC diagnostic pop
return traits;
}
void control(const climate::ClimateCall &call) override {
if (call.get_mode().has_value()) {
this->mode = *call.get_mode();
}
if (call.get_target_temperature().has_value()) {
this->target_temperature = *call.get_target_temperature();
}
if (call.has_custom_fan_mode()) {
this->set_custom_fan_mode_(call.get_custom_fan_mode());
}
if (call.has_custom_preset()) {
this->set_custom_preset_(call.get_custom_preset());
}
this->publish_state();
}
};
} // namespace esphome::legacy_climate_test
@@ -0,0 +1,18 @@
esphome:
name: legacy-climate-compat
host:
api:
logger:
level: DEBUG
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
components: [legacy_climate_component]
climate:
- platform: legacy_climate_component
name: "Legacy Climate"
id: legacy_climate
@@ -66,6 +66,7 @@
"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,
@@ -0,0 +1,96 @@
"""Integration test for backward compatibility of deprecated ClimateTraits setters.
Verifies that external components using the old traits.set_supported_custom_fan_modes()
and traits.set_supported_custom_presets() API still work correctly during the
deprecation period.
Remove this entire test file and the legacy_climate_component external component
in 2026.11.0 when the deprecated ClimateTraits setters are removed.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import aioesphomeapi
from aioesphomeapi import ClimateInfo
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_legacy_climate_compat(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that deprecated ClimateTraits custom mode setters still work end-to-end."""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, (
f"Expected 1 climate entity, got {len(climate_infos)}"
)
test_climate = climate_infos[0]
# Verify custom fan modes set via deprecated ClimateTraits setter are exposed
assert set(test_climate.supported_custom_fan_modes) == {
"Turbo",
"Silent",
"Auto",
}, (
f"Expected custom fan modes {{Turbo, Silent, Auto}}, "
f"got {test_climate.supported_custom_fan_modes}"
)
# Verify custom presets set via deprecated ClimateTraits setter are exposed
assert set(test_climate.supported_custom_presets) == {
"Eco Mode",
"Night Mode",
}, (
f"Expected custom presets {{Eco Mode, Night Mode}}, "
f"got {test_climate.supported_custom_presets}"
)
# Set up state tracking with InitialStateHelper
turbo_future: asyncio.Future[aioesphomeapi.ClimateState] = loop.create_future()
def on_state(state: aioesphomeapi.EntityState) -> None:
if (
isinstance(state, aioesphomeapi.ClimateState)
and state.custom_fan_mode == "Turbo"
and not turbo_future.done()
):
turbo_future.set_result(state)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Verify we can set a custom fan mode via API (tests find_custom_fan_mode_ compat path)
client.climate_command(test_climate.key, custom_fan_mode="Turbo")
try:
turbo_state = await asyncio.wait_for(turbo_future, timeout=5.0)
except TimeoutError:
pytest.fail("Custom fan mode 'Turbo' not received within 5 seconds")
assert turbo_state.custom_fan_mode == "Turbo"