Compare commits

..
Author SHA1 Message Date
Jesse Hills 78a0ffb812 [core] Drop unused return values from FINAL_VALIDATE_SCHEMA validators
loader.py declares final_validate_schema as Callable[[ConfigType], None]
and config.py discards whatever it returns, so a validator wired straight
to FINAL_VALIDATE_SCHEMA that ends in "return config" implies the value
matters when it does not. Drop those returns and annotate the functions
-> None. Validators composed into a cv.All still return their config,
since each one feeds the next. Returned validation calls are kept as
plain statements so no validation step is lost.
2026-10-15 00:15:51 +13:00
56cc03c4d4 [haier] Haier Select component added (#16148)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Pavlo Dudnytskyi <Pavlo.Dudnytskyi@addsecure.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 23:27:18 -10:00
35b416c6c0 [web_server_idf] Keep active SSE sessions out of the LRU purge (#17383)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 22:19:06 -10:00
Petar Petrov 7f2c3ba3f5 [display] Let other components draw into a display (#20622) 2026-10-14 06:11:47 +00:00
Keith BurzinskiandClaude Opus 5.5 a97604d574 [esp32_ble] Rely on preferences for NVS init (#20708)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:10:51 -10:00
Petar Petrov 1146b77836 [socket] Add TcpClientLink::connecting() (#20722) 2026-10-13 19:19:17 -10:00
J. Nick Koston 1338902ac5 [heatpumpir] Link only the configured protocol classes (#20707) 2026-10-14 17:06:22 +13:00
60a1d70805 [core] Move deferred include processing to parse_yaml (#16344)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-13 17:46:54 -10:00
Ryan LudwigandCursor 887d4c3a83 [esp32_can] Add NO_ACK mode (#20706)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 17:36:15 -10:00
J. Nick Koston c1fa73470e [display] Walk the blit fallback in frame buffer order and resolve the bitness once (#20651) 2026-10-13 17:31:13 -10:00
J. Nick Koston e41d16ba7e [haier] Keep the alarm message table in flash (#20641) 2026-10-14 03:23:28 +00:00
J. Nick Koston 5171113c87 [core] Let PROGMEM_STRING_TABLE hold more than 255 bytes (#20646) 2026-10-13 17:17:48 -10:00
techdregsandJ. Nick Koston 5718e4b779 [tmp102] Add text sensor for diagnostics (#20629)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-13 17:06:56 -10:00
110 changed files with 1891 additions and 503 deletions
+1
View File
@@ -240,6 +240,7 @@ esphome/components/gt911/* @clydebarrow @jesserockz
esphome/components/haier/* @paveldn
esphome/components/haier/binary_sensor/* @paveldn
esphome/components/haier/button/* @paveldn
esphome/components/haier/select/* @paveldn
esphome/components/haier/sensor/* @paveldn
esphome/components/haier/switch/* @paveldn
esphome/components/haier/text_sensor/* @paveldn
+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};
@@ -29,7 +29,7 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
return config
def _inherit_ca_certificate_path(config: ConfigType) -> ConfigType:
def _inherit_ca_certificate_path(config: ConfigType) -> None:
# Default to the CA certificate configured on the http_request component so
# HTTPS playback verifies against the same trust anchor without repeating
# the option on every source. Needed because audio_http sources are often
@@ -37,15 +37,15 @@ def _inherit_ca_certificate_path(config: ConfigType) -> ConfigType:
# The PEM replaces the built-in certificate bundle as the sole trust anchor,
# so make the inheritance visible in the log.
if CONF_CA_CERTIFICATE_PATH in config:
return config
return
fconf = fv.full_config.get()
if CONF_HTTP_REQUEST not in fconf:
return config
return
http_request_config = fconf[CONF_HTTP_REQUEST]
# Mirror http_request's own semantics: it only applies its CA when SSL
# verification is enabled, so neither does the inheritance.
if not http_request_config.get(CONF_VERIFY_SSL, True):
return config
return
if ca_cert_path := http_request_config.get(CONF_CA_CERTIFICATE_PATH):
_LOGGER.info(
"audio_http source '%s' is inheriting ca_certificate_path from the "
@@ -55,7 +55,6 @@ def _inherit_ca_certificate_path(config: ConfigType) -> ConfigType:
)
# Already validated by http_request's schema; the value is a resolved Path.
config[CONF_CA_CERTIFICATE_PATH] = ca_cert_path
return config
FINAL_VALIDATE_SCHEMA = _inherit_ca_certificate_path
@@ -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",
@@ -48,7 +48,7 @@ def _reject_debug(uart_conf: ConfigType) -> ConfigType:
return uart_conf
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()
# Bridges of any platform must own their interfaces exclusively; shared ring
# buffers and overwritten callbacks would corrupt both streams silently. The
@@ -81,7 +81,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
)
fv.id_declaration_match_schema(_reject_debug)(config[CONF_UART_ID])
return config
FINAL_VALIDATE_SCHEMA = _final_validate
-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
+36
View File
@@ -250,6 +250,42 @@ def add_metadata(
)
KEY_EXTERNAL_WRITERS = "external_writers"
def _get_external_writer_list() -> list[tuple[ID, str]]:
"""Get the raw external writer list. Each entry is (display id, owner)."""
return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_EXTERNAL_WRITERS, [])
def register_external_writer(display_id: ID, owner: str) -> None:
"""Declare that ``owner`` draws into the display from outside its lambda or pages.
Call from the owner's CONFIG_SCHEMA. The ID may still be unresolved; the ID pass
resolves it in place before final validation.
"""
_get_external_writer_list().append((display_id, owner))
def get_external_writers(display_id: ID) -> list[str]:
"""Get the owners registered for a display. Only valid once IDs are resolved."""
return [
owner
for id_, owner in _get_external_writer_list()
if id_.id is not None and id_.id == display_id.id
]
def has_external_writer(display_id: ID) -> bool:
"""Tell a driver's final validation that something else draws into the display.
LVGL anywhere in the config also counts, as the drivers assumed before.
"""
from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN
return LVGL_DOMAIN in full_config.get() or bool(get_external_writers(display_id))
async def register_display(var, config):
await cg.register_component(var, config)
await setup_display_core_(var, config)
+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;
+1 -2
View File
@@ -57,7 +57,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_time(await cg.get_variable(time_id)))
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
# The unit only talks over an encrypted, bonded link
ble_conf = fv.full_config.get()[esp32_ble.DOMAIN]
if (
@@ -69,7 +69,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
"ecocomfort2 needs esp32_ble configured with io_capability: none, "
"auth_req_mode: sc_bond and max_key_size: 16"
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -6
View File
@@ -40,7 +40,6 @@ from esphome.const import (
CONF_WIDTH,
)
from esphome.cpp_generator import RawExpression
from esphome.final_validate import full_config
from . import models
@@ -198,15 +197,11 @@ def _final_validate(config) -> None:
"epaper_spi", require_miso=False, require_mosi=True
)(config)
global_config = full_config.get()
from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN
if CONF_LAMBDA not in config and CONF_PAGES not in config:
if LVGL_DOMAIN in global_config:
if display.has_external_writer(config[CONF_ID]):
if CONF_UPDATE_INTERVAL not in config:
config[CONF_UPDATE_INTERVAL] = update_interval("never")
else:
# If no drawing methods are configured, and LVGL is not enabled, show a test card
config[CONF_SHOW_TEST_CARD] = True
elif CONF_UPDATE_INTERVAL not in config:
config[CONF_UPDATE_INTERVAL] = update_interval("1min")
@@ -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
@@ -131,6 +131,7 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) {
void ESP32Preferences::open() {
// Runs from app_main() before the logger is initialized; any logging here
// must be deferred. See s_open_err and make_preference() below.
// Components that need NVS check nvs_handle instead of initializing it again.
nvs_flash_init();
esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle);
if (err == 0)
+4 -12
View File
@@ -5,6 +5,7 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
@@ -28,7 +29,6 @@ extern "C" {
#include <freertos/FreeRTOS.h>
#include <freertos/FreeRTOSConfig.h>
#include <freertos/task.h>
#include <nvs_flash.h>
#ifdef USE_ARDUINO
// Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c).
@@ -77,8 +77,9 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000;
void ESP32BLE::setup() {
global_ble = this;
if (!ble_pre_setup_()) {
ESP_LOGE(TAG, "BLE could not be prepared for configuration");
// Preferences opens NVS in app_main(); a zero handle means it could not be opened, even after an erase
if (global_preferences->nvs_handle == 0) {
ESP_LOGE(TAG, "NVS unavailable");
this->mark_failed();
return;
}
@@ -185,15 +186,6 @@ void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) {
}
#endif
bool ESP32BLE::ble_pre_setup_() {
esp_err_t err = nvs_flash_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_flash_init failed: %d", err);
return false;
}
return true;
}
#ifdef USE_ESP32_BLE_ADVERTISING
void ESP32BLE::advertising_init_() {
if (this->advertising_ != nullptr)
-1
View File
@@ -185,7 +185,6 @@ class ESP32BLE final : public Component {
}
this->ble_events_.get_and_reset_dropped_count();
}
bool ble_pre_setup_();
#ifdef USE_ESP32_BLE_ADVERTISING
void advertising_init_();
#endif
@@ -10,7 +10,6 @@
#include <esp_gap_ble_api.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <nvs_flash.h>
#include <cstring>
#include "esphome/core/hal.h"
@@ -7,7 +7,6 @@
#ifdef USE_ESP32
#include <nvs_flash.h>
#include <freertos/FreeRTOSConfig.h>
#include <esp_bt_main.h>
#ifndef CONFIG_BT_CONTROLLER_DISABLED
@@ -16,7 +16,6 @@
#include <freertos/FreeRTOS.h>
#include <freertos/FreeRTOSConfig.h>
#include <freertos/task.h>
#include <nvs_flash.h>
#include <cinttypes>
#ifdef USE_OTA
+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 {
@@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None:
add_idf_sdkconfig_option("CONFIG_IEEE802154_SLEEP_ENABLE", True)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()
entries = full_config.get(power_management.DOMAIN, [])
if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in entries) > 1:
@@ -248,7 +248,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
f"{CONF_POWER_DOWN_FLASH} is not allowed when the device has PSRAM",
path=[CONF_POWER_DOWN_FLASH],
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+3 -4
View File
@@ -148,21 +148,20 @@ CONFIG_SCHEMA = cv.All(
)
def _validate_variant(config: ConfigType) -> ConfigType:
def _validate_variant(config: ConfigType) -> None:
# ESP-NOW rides the Wi-Fi PHY. Radio-less esp32 variants have no native
# ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that
# supplies the esp_now_* symbols. Fail here with a clear message instead of
# letting the build reach an "undefined reference to esp_now_*" link error.
if not CORE.is_esp32:
return config
return
variant = get_esp32_variant()
if wifi.variant_has_wifi(variant):
return config
return
if variant != VARIANT_ESP32P4:
raise cv.Invalid(f"ESP-NOW is not supported on {variant} (no Wi-Fi radio)")
if ESP32_HOSTED_DOMAIN not in fv.full_config.get():
raise cv.Invalid(f"ESP-NOW on {variant} requires the esp32_hosted component")
return config
FINAL_VALIDATE_SCHEMA = _validate_variant
+198 -14
View File
@@ -2,7 +2,10 @@
#include <string>
#include "esphome/components/climate/climate.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
#include "hon_climate.h"
#include "hon_packet.h"
@@ -11,6 +14,26 @@ using namespace esphome::uart;
namespace esphome::haier {
static bool vertical_axis_is_auto(climate::ClimateSwingMode swing_mode) {
return (swing_mode == climate::CLIMATE_SWING_VERTICAL) || (swing_mode == climate::CLIMATE_SWING_BOTH);
}
static bool horizontal_axis_is_auto(climate::ClimateSwingMode swing_mode) {
return (swing_mode == climate::CLIMATE_SWING_HORIZONTAL) || (swing_mode == climate::CLIMATE_SWING_BOTH);
}
static bool is_auto(hon_protocol::VerticalSwingMode mode) {
return (mode == hon_protocol::VerticalSwingMode::AUTO) || (mode == hon_protocol::VerticalSwingMode::AUTO_SPECIAL);
}
static bool is_auto(hon_protocol::HorizontalSwingMode mode) { return mode == hon_protocol::HorizontalSwingMode::AUTO; }
// A position the user just set wins over the appliance's echo of the old one for this long
constexpr uint32_t AIRFLOW_SELECT_DEBOUNCE_MS = 10000;
static bool in_debounce(uint32_t set_time, uint32_t now) {
return set_time != 0 && now - set_time < AIRFLOW_SELECT_DEBOUNCE_MS;
}
ESPHOME_LOG_TAG(TAG, "haier.climate");
constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000;
constexpr int PROTOCOL_OUTDOOR_TEMPERATURE_OFFSET = -64;
@@ -20,6 +43,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,
@@ -67,21 +111,60 @@ bool HonClimate::get_quiet_mode_state() const {
}
esphome::optional<hon_protocol::VerticalSwingMode> HonClimate::get_vertical_airflow() const {
if (!this->current_vertical_swing_.has_value()) {
return this->current_vertical_swing_;
}
// While the appliance swings, or right after the user chose a position, report the chosen position
if (is_auto(this->current_vertical_swing_.value()) ||
in_debounce(this->vertical_direction_set_time_, App.get_loop_component_start_time())) {
return this->settings_.last_vertiacal_swing;
}
return this->current_vertical_swing_;
};
}
void HonClimate::set_vertical_airflow(hon_protocol::VerticalSwingMode direction) {
if (is_auto(direction)) {
ESP_LOGW(TAG, "Vertical AUTO mode is not allowed for airflow position selector");
return;
}
if (this->settings_.last_vertiacal_swing != direction) {
this->settings_.last_vertiacal_swing = direction;
this->hon_rtc_.save(&this->settings_);
}
this->pending_vertical_direction_ = direction;
this->force_send_control_ = true;
this->vertical_direction_set_time_ = App.get_loop_component_start_time();
#ifdef USE_SELECT
this->publish_airflow_selects_();
#endif
}
esphome::optional<hon_protocol::HorizontalSwingMode> HonClimate::get_horizontal_airflow() const {
if (!this->current_horizontal_swing_.has_value()) {
return this->current_horizontal_swing_;
}
if (is_auto(this->current_horizontal_swing_.value()) ||
in_debounce(this->horizontal_direction_set_time_, App.get_loop_component_start_time())) {
return this->settings_.last_horizontal_swing;
}
return this->current_horizontal_swing_;
}
void HonClimate::set_horizontal_airflow(hon_protocol::HorizontalSwingMode direction) {
if (is_auto(direction)) {
ESP_LOGW(TAG, "Horizontal AUTO mode is not allowed for airflow position selector");
return;
}
if (this->settings_.last_horizontal_swing != direction) {
this->settings_.last_horizontal_swing = direction;
this->hon_rtc_.save(&this->settings_);
}
this->pending_horizontal_direction_ = direction;
this->force_send_control_ = true;
this->horizontal_direction_set_time_ = App.get_loop_component_start_time();
#ifdef USE_SELECT
this->publish_airflow_selects_();
#endif
}
const char *HonClimate::get_cleaning_status_text() const {
@@ -511,6 +594,18 @@ void HonClimate::initialization() {
} else {
this->settings_ = {hon_protocol::VerticalSwingMode::CENTER, hon_protocol::HorizontalSwingMode::CENTER, true, false};
}
bool settings_fixed = false;
if (is_auto(this->settings_.last_vertiacal_swing)) {
this->settings_.last_vertiacal_swing = hon_protocol::VerticalSwingMode::CENTER;
settings_fixed = true;
}
if (is_auto(this->settings_.last_horizontal_swing)) {
this->settings_.last_horizontal_swing = hon_protocol::HorizontalSwingMode::CENTER;
settings_fixed = true;
}
if (settings_fixed) {
this->hon_rtc_.save(&this->settings_);
}
this->current_vertical_swing_ = this->settings_.last_vertiacal_swing;
this->current_horizontal_swing_ = this->settings_.last_horizontal_swing;
this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF;
@@ -646,12 +741,21 @@ haier_protocol::HaierMessage HonClimate::get_control_message() {
}
}
}
const ClimateSwingMode target_swing_mode = this->current_hvac_settings_.swing_mode.value_or(this->swing_mode);
if (this->pending_vertical_direction_.has_value()) {
out_data->vertical_swing_mode = (uint8_t) this->pending_vertical_direction_.value();
if (!vertical_axis_is_auto(target_swing_mode)) {
out_data->vertical_swing_mode = (uint8_t) this->pending_vertical_direction_.value();
} else {
ESP_LOGD(TAG, "Vertical swing is on; the position is kept for when it is turned off");
}
this->pending_vertical_direction_.reset();
}
if (this->pending_horizontal_direction_.has_value()) {
out_data->horizontal_swing_mode = (uint8_t) this->pending_horizontal_direction_.value();
if (!horizontal_axis_is_auto(target_swing_mode)) {
out_data->horizontal_swing_mode = (uint8_t) this->pending_horizontal_direction_.value();
} else {
ESP_LOGD(TAG, "Horizontal swing is on; the position is kept for when it is turned off");
}
this->pending_horizontal_direction_.reset();
}
{
@@ -687,9 +791,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) {
@@ -810,6 +922,41 @@ void HonClimate::set_quiet_mode_switch(switch_::Switch *sw) {
}
#endif // USE_SWITCH
#ifdef USE_SELECT
template<typename Mode, size_t N>
static void publish_airflow_index(select::Select *sel, const std::array<Mode, N> &order, Mode mode) {
if (sel == nullptr) {
return;
}
// Saved settings never hold AUTO after initialization(); any other value outside the table shows as Center
size_t index = N;
size_t center = 0;
for (size_t i = 0; i < N; i++) {
const Mode entry = airflow_order_at(order, i);
if (entry == mode) {
index = i;
} else if (entry == Mode::CENTER) {
center = i;
}
}
if (index == N) {
index = center;
}
const auto active = sel->active_index();
if (active.has_value() && *active == index) {
return;
}
sel->publish_state(index);
}
void HonClimate::publish_airflow_selects_() {
publish_airflow_index(this->vertical_airflow_select_, VERTICAL_SWING_MODES_ORDER,
this->settings_.last_vertiacal_swing);
publish_airflow_index(this->horizontal_airflow_select_, HORIZONTAL_SWING_MODES_ORDER,
this->settings_.last_horizontal_swing);
}
#endif // USE_SELECT
haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *packet_buffer, uint8_t size) {
size_t expected_size =
2 + this->status_message_header_size_ + this->real_control_packet_size_ + this->real_sensors_packet_size_;
@@ -1044,16 +1191,25 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *
// Saving last known non auto mode for vertical and horizontal swing
this->current_vertical_swing_ = (hon_protocol::VerticalSwingMode) packet.control.vertical_swing_mode;
this->current_horizontal_swing_ = (hon_protocol::HorizontalSwingMode) packet.control.horizontal_swing_mode;
bool save_settings = ((this->current_vertical_swing_.value() != hon_protocol::VerticalSwingMode::AUTO) &&
(this->current_vertical_swing_.value() != hon_protocol::VerticalSwingMode::AUTO_SPECIAL) &&
(this->current_vertical_swing_.value() != this->settings_.last_vertiacal_swing)) ||
((this->current_horizontal_swing_.value() != hon_protocol::HorizontalSwingMode::AUTO) &&
(this->current_horizontal_swing_.value() != this->settings_.last_horizontal_swing));
if (save_settings) {
this->settings_.last_vertiacal_swing = this->current_vertical_swing_.value();
this->settings_.last_horizontal_swing = this->current_horizontal_swing_.value();
const uint32_t now_ms = App.get_loop_component_start_time();
bool save_vertical = !in_debounce(this->vertical_direction_set_time_, now_ms) &&
!is_auto(this->current_vertical_swing_.value()) &&
(this->current_vertical_swing_.value() != this->settings_.last_vertiacal_swing);
bool save_horizontal = !in_debounce(this->horizontal_direction_set_time_, now_ms) &&
!is_auto(this->current_horizontal_swing_.value()) &&
(this->current_horizontal_swing_.value() != this->settings_.last_horizontal_swing);
if (save_vertical || save_horizontal) {
if (save_vertical) {
this->settings_.last_vertiacal_swing = this->current_vertical_swing_.value();
}
if (save_horizontal) {
this->settings_.last_horizontal_swing = this->current_horizontal_swing_.value();
}
this->hon_rtc_.save(&this->settings_);
}
#ifdef USE_SELECT
this->publish_airflow_selects_();
#endif
should_publish = should_publish || (old_swing_mode != this->swing_mode);
}
this->last_valid_status_timestamp_ = std::chrono::steady_clock::now();
@@ -1229,8 +1385,10 @@ void HonClimate::fill_control_messages_queue_() {
(uint8_t) hon_protocol::DataParameters::SET_POINT,
buffer, 2);
}
ClimateSwingMode target_swing_mode = this->swing_mode;
// Vertical swing mode
if (climate_control.swing_mode.has_value()) {
target_swing_mode = climate_control.swing_mode.value();
uint8_t vertical_swing_buf[] = {0x00, (uint8_t) hon_protocol::VerticalSwingMode::AUTO};
uint8_t horizontal_swing_buf[] = {0x00, (uint8_t) hon_protocol::HorizontalSwingMode::AUTO};
switch (climate_control.swing_mode.value()) {
@@ -1256,6 +1414,32 @@ void HonClimate::fill_control_messages_queue_() {
(uint8_t) hon_protocol::DataParameters::VERTICAL_SWING_MODE,
vertical_swing_buf, 2);
}
// A swing change above already sent both axes from settings_.last_*, which set_*_airflow() updated
const bool swing_sent = climate_control.swing_mode.has_value();
if (this->pending_vertical_direction_.has_value()) {
if (vertical_axis_is_auto(target_swing_mode)) {
ESP_LOGD(TAG, "Vertical swing is on; the position is kept for when it is turned off");
} else if (!swing_sent) {
uint8_t vertical_swing_buf[] = {0x00, (uint8_t) this->pending_vertical_direction_.value()};
this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL,
(uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER +
(uint8_t) hon_protocol::DataParameters::VERTICAL_SWING_MODE,
vertical_swing_buf, 2);
}
this->pending_vertical_direction_.reset();
}
if (this->pending_horizontal_direction_.has_value()) {
if (horizontal_axis_is_auto(target_swing_mode)) {
ESP_LOGD(TAG, "Horizontal swing is on; the position is kept for when it is turned off");
} else if (!swing_sent) {
uint8_t horizontal_swing_buf[] = {0x00, (uint8_t) this->pending_horizontal_direction_.value()};
this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL,
(uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER +
(uint8_t) hon_protocol::DataParameters::HORIZONTAL_SWING_MODE,
horizontal_swing_buf, 2);
}
this->pending_horizontal_direction_.reset();
}
// Fan mode
if (climate_control.fan_mode.has_value()) {
switch (climate_control.fan_mode.value()) {
+38
View File
@@ -1,5 +1,6 @@
#pragma once
#include <array>
#include <chrono>
#include <queue>
#ifdef USE_SENSOR
@@ -14,12 +15,36 @@
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
#ifdef USE_SELECT
#include "esphome/components/select/select.h"
#endif
#include "esphome/core/automation.h"
#include "esphome/core/hal.h"
#include "haier_base.h"
#include "hon_packet.h"
namespace esphome::haier {
#ifdef USE_SELECT
// Positions in the order the select platform lists them; in flash on ESP8266, so read with airflow_order_at()
inline constexpr std::array<hon_protocol::VerticalSwingMode, 7> VERTICAL_SWING_MODES_ORDER PROGMEM{
hon_protocol::VerticalSwingMode::HEALTH_UP, hon_protocol::VerticalSwingMode::MAX_UP,
hon_protocol::VerticalSwingMode::UP, hon_protocol::VerticalSwingMode::CENTER,
hon_protocol::VerticalSwingMode::DOWN, hon_protocol::VerticalSwingMode::MAX_DOWN,
hon_protocol::VerticalSwingMode::HEALTH_DOWN,
};
inline constexpr std::array<hon_protocol::HorizontalSwingMode, 5> HORIZONTAL_SWING_MODES_ORDER PROGMEM{
hon_protocol::HorizontalSwingMode::MAX_LEFT, hon_protocol::HorizontalSwingMode::LEFT,
hon_protocol::HorizontalSwingMode::CENTER, hon_protocol::HorizontalSwingMode::RIGHT,
hon_protocol::HorizontalSwingMode::MAX_RIGHT,
};
template<typename Mode, size_t N> Mode airflow_order_at(const std::array<Mode, N> &order, size_t index) {
static_assert(sizeof(Mode) == 1);
return static_cast<Mode>(progmem_read_byte(reinterpret_cast<const uint8_t *>(order.data() + index)));
}
#endif
enum class CleaningState : uint8_t {
NO_CLEANING = 0,
SELF_CLEAN = 1,
@@ -100,6 +125,16 @@ class HonClimate final : public HaierClimateBase {
protected:
switch_::Switch *beeper_switch_{nullptr};
switch_::Switch *quiet_mode_switch_{nullptr};
#endif
#ifdef USE_SELECT
public:
void set_vertical_airflow_select(select::Select *sel) { this->vertical_airflow_select_ = sel; }
void set_horizontal_airflow_select(select::Select *sel) { this->horizontal_airflow_select_ = sel; }
protected:
void publish_airflow_selects_();
select::Select *vertical_airflow_select_{nullptr};
select::Select *horizontal_airflow_select_{nullptr};
#endif
public:
HonClimate();
@@ -195,6 +230,9 @@ class HonClimate final : public HaierClimateBase {
uint8_t big_data_counter_{0};
esphome::optional<hon_protocol::VerticalSwingMode> current_vertical_swing_{};
esphome::optional<hon_protocol::HorizontalSwingMode> current_horizontal_swing_{};
// millis() of the last user-set position per axis, 0 when none
uint32_t vertical_direction_set_time_{0};
uint32_t horizontal_direction_set_time_{0};
HonSettings settings_{};
ESPPreferenceObject hon_rtc_;
SwitchState quiet_mode_state_{SwitchState::SWITCH_OFF};
-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
@@ -0,0 +1,67 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType
from ..climate import (
CONF_HAIER_ID,
CONF_HORIZONTAL_AIRFLOW,
CONF_VERTICAL_AIRFLOW,
HonClimate,
haier_ns,
)
CODEOWNERS = ["@paveldn"]
VerticalAirflowSelect = haier_ns.class_("VerticalAirflowSelect", select.Select)
HorizontalAirflowSelect = haier_ns.class_("HorizontalAirflowSelect", select.Select)
# Same order as VERTICAL_SWING_MODES_ORDER and HORIZONTAL_SWING_MODES_ORDER in hon_climate.h
AIRFLOW_VERTICAL_DIRECTION_OPTIONS = [
"Health Up",
"Max Up",
"Up",
"Center",
"Down",
"Max Down",
"Health Down",
]
AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = [
"Max Left",
"Left",
"Center",
"Right",
"Max Right",
]
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_HAIER_ID): cv.use_id(HonClimate),
cv.Optional(CONF_VERTICAL_AIRFLOW): select.select_schema(
VerticalAirflowSelect,
icon="mdi:arrow-expand-vertical",
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_HORIZONTAL_AIRFLOW): select.select_schema(
HorizontalAirflowSelect,
icon="mdi:arrow-expand-horizontal",
entity_category=ENTITY_CATEGORY_CONFIG,
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_HAIER_ID])
selects = select.sub_selects(config, parent=hub)
await selects(
CONF_VERTICAL_AIRFLOW,
hub.set_vertical_airflow_select,
options=AIRFLOW_VERTICAL_DIRECTION_OPTIONS,
)
await selects(
CONF_HORIZONTAL_AIRFLOW,
hub.set_horizontal_airflow_select,
options=AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS,
)
@@ -0,0 +1,15 @@
#pragma once
#include "esphome/components/select/select.h"
#include "../hon_climate.h"
namespace esphome::haier {
class HorizontalAirflowSelect : public select::Select, public Parented<HonClimate> {
protected:
void control(size_t index) override {
this->parent_->set_horizontal_airflow(airflow_order_at(HORIZONTAL_SWING_MODES_ORDER, index));
}
};
} // namespace esphome::haier
@@ -0,0 +1,15 @@
#pragma once
#include "esphome/components/select/select.h"
#include "../hon_climate.h"
namespace esphome::haier {
class VerticalAirflowSelect : public select::Select, public Parented<HonClimate> {
protected:
void control(size_t index) override {
this->parent_->set_vertical_airflow(airflow_order_at(VERTICAL_SWING_MODES_ORDER, index));
}
};
} // namespace esphome::haier
+2 -2
View File
@@ -126,8 +126,8 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_protocol(config[CONF_PROTOCOL]))
tag = heatpumpir_ns.ProtocolTag.template(config[CONF_PROTOCOL])
var = await climate_ir.new_climate_ir(config, tag())
cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT]))
cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT]))
cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE]))
+68 -64
View File
@@ -5,7 +5,6 @@
#if defined(USE_ARDUINO) || defined(USE_ESP32)
#include <cmath>
#include <map>
#include <IRSender.h>
#include <HeatpumpIRFactory.h>
#include "esphome/components/remote_base/remote_base.h"
@@ -45,71 +44,76 @@ class IRSenderESPHome : public IRSender {
ESPHOME_LOG_TAG(TAG, "heatpumpir.climate");
const std::map<Protocol, std::function<HeatpumpIR *()>> PROTOCOL_CONSTRUCTOR_MAP = {
{PROTOCOL_AUX, []() { return new AUXHeatpumpIR(); }}, // NOLINT
{PROTOCOL_BALLU, []() { return new BalluHeatpumpIR(); }}, // NOLINT
{PROTOCOL_CARRIER_MCA, []() { return new CarrierMCAHeatpumpIR(); }}, // NOLINT
{PROTOCOL_CARRIER_NQV, []() { return new CarrierNQVHeatpumpIR(); }}, // NOLINT
{PROTOCOL_DAIKIN_ARC417, []() { return new DaikinHeatpumpARC417IR(); }}, // NOLINT
{PROTOCOL_DAIKIN_ARC480, []() { return new DaikinHeatpumpARC480A14IR(); }}, // NOLINT
{PROTOCOL_DAIKIN, []() { return new DaikinHeatpumpIR(); }}, // NOLINT
{PROTOCOL_ELECTROLUXYAL, []() { return new ElectroluxYALHeatpumpIR(); }}, // NOLINT
{PROTOCOL_FUEGO, []() { return new FuegoHeatpumpIR(); }}, // NOLINT
{PROTOCOL_FUJITSU_AWYZ, []() { return new FujitsuHeatpumpIR(); }}, // NOLINT
{PROTOCOL_GREE, []() { return new GreeGenericHeatpumpIR(); }}, // NOLINT
{PROTOCOL_GREEYAA, []() { return new GreeYAAHeatpumpIR(); }}, // NOLINT
{PROTOCOL_GREEYAN, []() { return new GreeYANHeatpumpIR(); }}, // NOLINT
{PROTOCOL_GREEYAC, []() { return new GreeYACHeatpumpIR(); }}, // NOLINT
{PROTOCOL_GREEYT, []() { return new GreeYTHeatpumpIR(); }}, // NOLINT
{PROTOCOL_GREEYAP, []() { return new GreeYAPHeatpumpIR(); }}, // NOLINT
{PROTOCOL_HISENSE_AUD, []() { return new HisenseHeatpumpIR(); }}, // NOLINT
{PROTOCOL_HITACHI, []() { return new HitachiHeatpumpIR(); }}, // NOLINT
{PROTOCOL_HYUNDAI, []() { return new HyundaiHeatpumpIR(); }}, // NOLINT
{PROTOCOL_IVT, []() { return new IVTHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MIDEA, []() { return new MideaHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_FA, []() { return new MitsubishiFAHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_FD, []() { return new MitsubishiFDHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_FE, []() { return new MitsubishiFEHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_HEAVY_FDTC, []() { return new MitsubishiHeavyFDTCHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_HEAVY_ZJ, []() { return new MitsubishiHeavyZJHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_HEAVY_ZM, []() { return new MitsubishiHeavyZMHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_HEAVY_ZMP, []() { return new MitsubishiHeavyZMPHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_KJ, []() { return new MitsubishiKJHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_MSC, []() { return new MitsubishiMSCHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_MSY, []() { return new MitsubishiMSYHeatpumpIR(); }}, // NOLINT
{PROTOCOL_MITSUBISHI_SEZ, []() { return new MitsubishiSEZKDXXHeatpumpIR(); }}, // NOLINT
{PROTOCOL_PANASONIC_CKP, []() { return new PanasonicCKPHeatpumpIR(); }}, // NOLINT
{PROTOCOL_PANASONIC_DKE, []() { return new PanasonicDKEHeatpumpIR(); }}, // NOLINT
{PROTOCOL_PANASONIC_EKE, []() { return new PanasonicEKEHeatpumpIR(); }}, // NOLINT
{PROTOCOL_PANASONIC_JKE, []() { return new PanasonicJKEHeatpumpIR(); }}, // NOLINT
{PROTOCOL_PANASONIC_LKE, []() { return new PanasonicLKEHeatpumpIR(); }}, // NOLINT
{PROTOCOL_PANASONIC_NKE, []() { return new PanasonicNKEHeatpumpIR(); }}, // NOLINT
{PROTOCOL_SAMSUNG_AQV, []() { return new SamsungAQVHeatpumpIR(); }}, // NOLINT
{PROTOCOL_SAMSUNG_FJM, []() { return new SamsungFJMHeatpumpIR(); }}, // NOLINT
{PROTOCOL_SHARP, []() { return new SharpHeatpumpIR(); }}, // NOLINT
{PROTOCOL_TOSHIBA_DAISEIKAI, []() { return new ToshibaDaiseikaiHeatpumpIR(); }}, // NOLINT
{PROTOCOL_TOSHIBA, []() { return new ToshibaHeatpumpIR(); }}, // NOLINT
{PROTOCOL_ZHLT01, []() { return new ZHLT01HeatpumpIR(); }}, // NOLINT
{PROTOCOL_NIBE, []() { return new NibeHeatpumpIR(); }}, // NOLINT
{PROTOCOL_QLIMA_1, []() { return new Qlima1HeatpumpIR(); }}, // NOLINT
{PROTOCOL_QLIMA_2, []() { return new Qlima2HeatpumpIR(); }}, // NOLINT
{PROTOCOL_SAMSUNG_AQV12MSAN, []() { return new SamsungAQV12MSANHeatpumpIR(); }}, // NOLINT
{PROTOCOL_ZHJG01, []() { return new ZHJG01HeatpumpIR(); }}, // NOLINT
{PROTOCOL_AIRWAY, []() { return new AIRWAYHeatpumpIR(); }}, // NOLINT
{PROTOCOL_BGH_AUD, []() { return new BGHHeatpumpIR(); }}, // NOLINT
{PROTOCOL_PANASONIC_ALTDKE, []() { return new PanasonicAltDKEHeatpumpIR(); }}, // NOLINT
{PROTOCOL_PHILCO_PHS32, []() { return new PhilcoPHS32HeatpumpIR(); }}, // NOLINT
{PROTOCOL_VAILLANTVAI8, []() { return new VaillantHeatpumpIR(); }}, // NOLINT
{PROTOCOL_R51M, []() { return new R51MHeatpumpIR(); }}, // NOLINT
};
template<Protocol P> struct ProtocolClass;
template<Protocol P> HeatpumpIR *create_heatpump_ir() {
return new typename ProtocolClass<P>::type(); // NOLINT
}
// Codegen calls only the configured protocols, so the linker drops the other classes
#define HEATPUMPIR_PROTOCOL(protocol, cls) \
template<> struct ProtocolClass<protocol> { using type = cls; }; \
template HeatpumpIR *create_heatpump_ir<protocol>();
HEATPUMPIR_PROTOCOL(PROTOCOL_AUX, AUXHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_BALLU, BalluHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_MCA, CarrierMCAHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_NQV, CarrierNQVHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC417, DaikinHeatpumpARC417IR)
HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC480, DaikinHeatpumpARC480A14IR)
HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN, DaikinHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_ELECTROLUXYAL, ElectroluxYALHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_FUEGO, FuegoHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_FUJITSU_AWYZ, FujitsuHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_GREE, GreeGenericHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAA, GreeYAAHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAN, GreeYANHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAC, GreeYACHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYT, GreeYTHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAP, GreeYAPHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_HISENSE_AUD, HisenseHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_HITACHI, HitachiHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_HYUNDAI, HyundaiHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_IVT, IVTHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MIDEA, MideaHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FA, MitsubishiFAHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FD, MitsubishiFDHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FE, MitsubishiFEHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_FDTC, MitsubishiHeavyFDTCHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZJ, MitsubishiHeavyZJHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZM, MitsubishiHeavyZMHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZMP, MitsubishiHeavyZMPHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_KJ, MitsubishiKJHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSC, MitsubishiMSCHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSY, MitsubishiMSYHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_SEZ, MitsubishiSEZKDXXHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_CKP, PanasonicCKPHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_DKE, PanasonicDKEHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_EKE, PanasonicEKEHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_JKE, PanasonicJKEHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_LKE, PanasonicLKEHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_NKE, PanasonicNKEHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV, SamsungAQVHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_FJM, SamsungFJMHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_SHARP, SharpHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA_DAISEIKAI, ToshibaDaiseikaiHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA, ToshibaHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_ZHLT01, ZHLT01HeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_NIBE, NibeHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_1, Qlima1HeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_2, Qlima2HeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV12MSAN, SamsungAQV12MSANHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_ZHJG01, ZHJG01HeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_AIRWAY, AIRWAYHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_BGH_AUD, BGHHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_ALTDKE, PanasonicAltDKEHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_PHILCO_PHS32, PhilcoPHS32HeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_VAILLANTVAI8, VaillantHeatpumpIR)
HEATPUMPIR_PROTOCOL(PROTOCOL_R51M, R51MHeatpumpIR)
#undef HEATPUMPIR_PROTOCOL
void HeatpumpIRClimate::setup() {
auto protocol_constructor = PROTOCOL_CONSTRUCTOR_MAP.find(protocol_);
if (protocol_constructor == PROTOCOL_CONSTRUCTOR_MAP.end()) {
ESP_LOGE(TAG, "Invalid protocol");
return;
}
this->heatpump_ir_ = protocol_constructor->second();
climate_ir::ClimateIR::setup();
if (this->sensor_) {
this->sensor_->add_on_state_callback([this](float state) {
+20 -12
View File
@@ -89,25 +89,21 @@ enum VerticalDirection {
VERTICAL_DIRECTION_DOWN = 5,
};
/// Creates the HeatpumpIR library class for a protocol; instantiated in heatpumpir.cpp.
template<Protocol P> HeatpumpIR *create_heatpump_ir();
/// Selects the protocol at compile time so the enum and the library object always match.
template<Protocol P> struct ProtocolTag {};
// Temperature
const float TEMP_MIN = 0; // Celsius
const float TEMP_MAX = 100; // Celsius
class HeatpumpIRClimate final : public climate_ir::ClimateIR {
public:
HeatpumpIRClimate()
: climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true,
{climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH,
climate::CLIMATE_FAN_AUTO},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL,
climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {}
template<Protocol P>
explicit HeatpumpIRClimate(ProtocolTag<P> /*tag*/) : HeatpumpIRClimate(P, create_heatpump_ir<P>()) {}
void setup() override;
void set_protocol(Protocol protocol) {
this->protocol_ = protocol;
if (this->has_fan_byte_presets_()) {
this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST};
}
}
void set_horizontal_default(HorizontalDirection horizontal_direction) {
this->default_horizontal_direction_ = horizontal_direction;
}
@@ -119,6 +115,18 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR {
void set_min_temperature(float temperature) { this->min_temperature_ = temperature; }
protected:
HeatpumpIRClimate(Protocol protocol, HeatpumpIR *heatpump_ir)
: climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true,
{climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH,
climate::CLIMATE_FAN_AUTO},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL,
climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}),
heatpump_ir_(heatpump_ir),
protocol_(protocol) {
if (this->has_fan_byte_presets_()) {
this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST};
}
}
HeatpumpIR *heatpump_ir_;
void control(const climate::ClimateCall &call) override;
/// Transmit via IR the state of this climate controller.
+2 -3
View File
@@ -44,10 +44,10 @@ HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA = cv.Schema(
)
def final_validate_entity_id(config: ConfigType) -> ConfigType:
def final_validate_entity_id(config: ConfigType) -> None:
"""Without an entity_id, the entity must be a wizard input, as Home Assistant then supplies it."""
if CONF_ENTITY_ID in config:
return config
return
from esphome.components.api import wizard
if config[CONF_ID].id not in wizard.wizard_input_ids(
@@ -56,7 +56,6 @@ def final_validate_entity_id(config: ConfigType) -> ConfigType:
raise cv.Invalid(
f"{CONF_ENTITY_ID} is required unless this entity is a wizard input"
)
return config
def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None:
+2 -3
View File
@@ -599,7 +599,7 @@ def legacy_platform_migration_warning(
domain: str, platform: str, removal_version: str
) -> tuple[
Callable[[ConfigType], ConfigType],
Callable[[ConfigType], ConfigType],
Callable[[ConfigType], None],
]:
"""Build the per-entry capture and one-shot warning validators for a
deprecated top-level component that is now an ``image:`` platform.
@@ -618,7 +618,7 @@ def legacy_platform_migration_warning(
data.setdefault(entries_key, []).append(dict(config))
return config
def finalize(config: ConfigType) -> ConfigType:
def finalize(config: ConfigType) -> None:
data = CORE.data.setdefault(domain, {})
if not data.get(shown_key):
data[shown_key] = True
@@ -639,7 +639,6 @@ def legacy_platform_migration_warning(
domain,
yaml_util.dump({DOMAIN: migrated}),
)
return config
return capture, finalize
+1 -5
View File
@@ -37,7 +37,6 @@ from esphome.const import (
CONF_WIDTH,
)
from esphome.cpp_generator import RawExpression
from esphome.final_validate import full_config
from esphome.types import ConfigType
AUTO_LOAD = ["split_buffer"]
@@ -348,11 +347,8 @@ def _final_validate(config: ConfigType) -> None:
config
)
global_config = full_config.get()
from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN
if CONF_LAMBDA not in config and CONF_PAGES not in config:
if LVGL_DOMAIN in global_config:
if display.has_external_writer(config[CONF_ID]):
if CONF_UPDATE_INTERVAL not in config:
config[CONF_UPDATE_INTERVAL] = update_interval("never")
else:
+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)
+1 -1
View File
@@ -264,7 +264,7 @@ def _final_validate(config: ConfigType) -> None:
# If no drawing methods are configured, and LVGL is not enabled, show a test card
config[CONF_SHOW_TEST_CARD] = True
if CONF_SPI_ID in config:
config = spi.final_validate_device_schema(
spi.final_validate_device_schema(
"mipi_rgb", require_miso=False, require_mosi=True
)(config)
+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 -2
View File
@@ -317,7 +317,7 @@ def _has_pairing_method(config: ConfigType) -> bool:
return CONF_STATIC_PAIRING_CODE in config or _offers_dynamic_pairing_code(config)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
dynamic_code = _offers_dynamic_pairing_code(config)
# The protocol allows only one pairing code method.
if dynamic_code and CONF_STATIC_PAIRING_CODE in config:
@@ -334,7 +334,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
CONF_UNPAIRED_ACCESS,
CONF_STATIC_PAIRING_CODE,
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -65,7 +65,7 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()
switch_type = config[CONF_TYPE]
same_type = [
@@ -77,7 +77,7 @@ def _final_validate(config: ConfigType) -> ConfigType:
if len(same_type) > 1:
raise cv.Invalid(f"Only one sendspin '{switch_type}' switch is allowed")
if switch_type != CONF_UNPAIRED_ACCESS:
return config
return
hub_config = full_config.get(DOMAIN, {})
if CONF_UNPAIRED_ACCESS in hub_config:
raise cv.Invalid(
@@ -91,7 +91,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
CONF_UNPAIRED_ACCESS,
CONF_STATIC_PAIRING_CODE,
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -76,13 +76,12 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
is_usb = is_usb_uart_channel(config[CONF_UART_ID], fv.full_config.get())
if config[CONF_PORT_TYPE] == "USB_SERIAL" and not is_usb:
raise cv.Invalid(
f"{CONF_PORT_TYPE} USB_SERIAL requires {CONF_UART_ID} to be a usb_uart channel"
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -61,6 +61,8 @@ class TcpClientLink {
void close();
bool connected() const { return this->connected_; }
/// A connect attempt is in flight.
bool connecting() const { return this->sock_ != nullptr && !this->connected_; }
bool ready() const { return this->sock_ != nullptr && this->sock_->ready(); }
/// Shared retry clock, also usable for a listen socket.
void note_attempt() { this->last_attempt_ms_ = App.get_loop_component_start_time(); }
+2 -3
View File
@@ -539,13 +539,13 @@ def _walk_config(value: Any, path: tuple[Any, ...] = ()):
yield from _walk_config(child, (*path, index))
def _final_validate(config: Any) -> Any:
def _final_validate(config: Any) -> None:
buses = config if isinstance(config, list) else [config]
software_bus_ids = {
bus[CONF_ID] for bus in buses if CONF_INTERFACE_INDEX not in bus
}
if not software_bus_ids:
return config
return
for candidate, path in _walk_config(fv.full_config.get()):
if (
candidate.get(CONF_PSRAM_DMA)
@@ -553,7 +553,6 @@ def _final_validate(config: Any) -> Any:
):
with cv.prepend_path([cv.ROOT_CONFIG_PATH, *path, CONF_PSRAM_DMA]):
raise cv.Invalid("psram_dma requires a hardware SPI interface")
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+2 -3
View File
@@ -55,13 +55,13 @@ CONFIG_SCHEMA = cv.Schema(
)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
fconf = fv.full_config.get()
hub_path = fconf.get_path_for_id(config[CONF_TAS58XX_ID])
hub_conf = fconf.get_config_for_path(hub_path[:-1])
if hub_conf[CONF_MODEL] == DAC_TAS5825M:
return config
return
unsupported = sorted(frozenset(TAS5825M_ONLY_FAULT_SENSORS).intersection(config))
if unsupported:
@@ -69,7 +69,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
f"binary_sensor platform tas58xx: '{', '.join(unsupported)}' is only available for 'model: {DAC_TAS5825M}' "
f"- Remove from YAML for 'model: {hub_conf[CONF_MODEL]}'"
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -36,7 +36,7 @@ CONFIG_SCHEMA = (
)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
# Historical mode runs at 1200 baud, standard mode at 9600 baud.
baud_rate = 1200 if config[CONF_HISTORICAL_MODE] else 9600
uart.final_validate_device_schema(
@@ -46,7 +46,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
parity="EVEN",
stop_bits=1,
)(config)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -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:
+1 -2
View File
@@ -49,12 +49,11 @@ def _validate_factory_default_address(config: ConfigType) -> ConfigType:
return config
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
if CONF_FACTORY_RESET in config:
fv.id_declaration_match_schema(_validate_factory_default_address)(
config[CONF_TFLUNA_ID]
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -56,9 +56,9 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.polling_component_schema("60s"))
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
if CONF_ACTUAL_CURRENT not in config:
return config
return
full_config = fv.full_config.get()
path = full_config.get_path_for_id(config[CONF_TMC2209_ID])[:-1]
if full_config.get_config_for_path(path).get(CONF_ANALOG_CURRENT_SCALE):
@@ -67,7 +67,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
"the current then depends on VREF",
path=[CONF_ACTUAL_CURRENT],
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -110,7 +110,7 @@ def _validate_analog_current_scale(config: ConfigType) -> ConfigType:
return config
def final_validate(config: ConfigType) -> ConfigType:
def final_validate(config: ConfigType) -> None:
"""Reject two drivers of the family with the same address on one UART bus."""
for other in fv.full_config.get().get("stepper", []):
if other is config:
@@ -123,7 +123,6 @@ def final_validate(config: ConfigType) -> ConfigType:
f"Address {config[CONF_ADDRESS]} is already used by '{other[CONF_ID]}' "
"on the same UART bus"
)
return config
async def new_tmc22xx(config: ConfigType) -> cg.Pvariable:
+2 -2
View File
@@ -14,8 +14,8 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend(
)
def _final_validate(config: ConfigType) -> ConfigType:
return validate_unique_child(config, "binary_sensor")
def _final_validate(config: ConfigType) -> None:
validate_unique_child(config, "binary_sensor")
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -51,12 +51,11 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
validate_unique_child(config, "number")
full = fv.full_config.get()
parent = full.get_config_for_path(full.get_path_for_id(config[CONF_TMP102_ID])[:-1])
add_number_parent(config[CONF_TMP102_ID], parent.get(CONF_EXTENDED_MODE, False))
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+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) -> None:
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 -2
View File
@@ -37,7 +37,7 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
# Two muxes on one bridge would each believe they own the bus.
owned = fv.full_config.get().data.setdefault(DOMAIN, set())
bridge_id = str(config[CONF_BRIDGE_ID])
@@ -48,7 +48,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
[CONF_BRIDGE_ID],
)
owned.add(bridge_id)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -81,7 +81,7 @@ def _reject_dummy_receiver(uart_conf: ConfigType) -> ConfigType:
return uart_conf
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
# A second reader would split the bytes with this one, and every connect
# discards what the other reader has not read yet.
full_config = fv.full_config.get()
@@ -122,7 +122,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
[CONF_PORT],
)
ports.add(port)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
+1 -2
View File
@@ -93,7 +93,7 @@ def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]:
return configs
def _final_validate(config: ConfigType) -> ConfigType:
def _final_validate(config: ConfigType) -> None:
# Any two overlapping clients could otherwise open the same device
devices = config.get(CONF_DEVICES) or []
uarts = fv.full_config.get().get("usb_uart") or []
@@ -117,7 +117,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
if redundant
else "narrow its filter so it no longer matches that device",
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -959,10 +959,39 @@ void AsyncEventSourceResponse::process_close_() {
}
}
void AsyncEventSourceResponse::refresh_lru_() {
// httpd only advances a session's LRU counter when a request arrives, and an event stream receives one
// request ever, so the purge that frees a socket for a new client would evict the live streams first.
// The HTTPD task owns the session table, so the refresh runs there.
// A stalled stream is the socket the purge should take, and a zeroed fd has no session left to refresh.
if (this->send_failure_started_ms_ != 0 || this->fd_.load() == 0)
return;
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_lru_refresh_ms_ < LRU_REFRESH_INTERVAL_MS ||
this->lru_work_queued_.load(std::memory_order_acquire)) {
return;
}
this->last_lru_refresh_ms_ = now; // a full queue also waits for the next interval
this->lru_work_queued_.store(true, std::memory_order_release);
if (httpd_queue_work(this->hd_, &AsyncEventSourceResponse::refresh_lru_work, this) != ESP_OK) {
this->lru_work_queued_.store(false, std::memory_order_release);
}
}
void AsyncEventSourceResponse::refresh_lru_work(void *arg) {
auto *response = static_cast<AsyncEventSourceResponse *>(arg);
const int fd = response->fd_.load();
if (response->owns_session_(fd)) {
httpd_sess_update_lru_counter(response->hd_, fd);
}
// Release self only after the HTTPD-task callback has finished every access.
response->lru_work_queued_.store(false, std::memory_order_release);
}
void AsyncEventSourceResponse::close_session_work(void *arg) {
auto *response = static_cast<AsyncEventSourceResponse *>(arg);
const int fd = response->fd_.load();
if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) {
if (response->owns_session_(fd)) {
// The HTTPD task remains the session owner. Shutting the socket down makes
// its next select/recv path delete the session and invoke destroy().
shutdown(fd, SHUT_RDWR);
@@ -976,7 +1005,7 @@ ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) {
// httpd frees a session before closing its socket, so the fd may already be a new client's.
// Treated as would-block; the stall timer ends a session that never becomes ours again.
const int fd = this->fd_.load();
if (httpd_sess_get_ctx(this->hd_, fd) != this) {
if (!this->owns_session_(fd)) {
return 0;
}
struct msghdr msg {};
@@ -1085,6 +1114,7 @@ void AsyncEventSourceResponse::loop() {
this->process_close_();
return;
}
this->refresh_lru_();
drain_tail_();
process_deferred_queue_();
if (this->close_requested_)
@@ -339,10 +339,15 @@ class AsyncEventSourceResponse {
};
void process_close_();
static void close_session_work(void *arg);
void refresh_lru_();
static void refresh_lru_work(void *arg);
// httpd frees a session before closing its socket, so the fd may already belong to a new client
bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; }
// Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object.
// Deletable only after destroy() zeroed fd_ and no queued HTTPD work still references this object.
bool safe_to_delete_() const {
return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire);
return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire) &&
!this->lru_work_queued_.load(std::memory_order_acquire);
}
static void destroy(void *p);
@@ -357,11 +362,13 @@ class AsyncEventSourceResponse {
RAMUniquePtr<uint8_t[]> tail_;
uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress.
uint32_t next_close_attempt_ms_{0};
uint32_t last_lru_refresh_ms_{0};
uint16_t tail_cap_{0};
uint16_t tail_len_{0}; // Zero means nothing pending
uint16_t tail_sent_{0};
// Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done.
std::atomic<bool> close_work_queued_{false};
std::atomic<bool> lru_work_queued_{false};
// Main-loop only; the HTTPD task never reads or writes these flags.
bool close_requested_{false};
bool close_retry_warning_logged_{false};
@@ -383,6 +390,7 @@ class AsyncEventSourceResponse {
static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000;
static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250;
static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000;
static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000;
};
using AsyncEventSourceClient = AsyncEventSourceResponse;
@@ -5,7 +5,6 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_check.h"
#include "nvs_flash.h"
#include "zigbee_attribute_esp32.h"
#include "zigbee_esp32.h"
#include "esphome/core/application.h"
+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)); \
+10 -4
View File
@@ -1120,8 +1120,12 @@ def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> An
res = parse_yaml(fname, f_handle, track_document_range=track_document_range)
except (UnicodeDecodeError, OSError) as err:
raise EsphomeError(f"Error reading file {fname}: {err}") from err
# Top-level !include returns a deferred IncludeFile; resolve it so
# callers always receive the final content.
return res
def _resolve_include(res: Any) -> Any:
# A document that is just `!include` parses to a deferred IncludeFile; resolve it so
# every parse_yaml caller, including the VS Code and ACE loaders, gets the final content.
if isinstance(res, IncludeFile):
res = res.load() if res.should_load() else {}
return res
@@ -1147,7 +1151,7 @@ def parse_yaml(
# tracked mode; reject the combination instead of half-applying it.
raise ValueError("track_document_range=False requires the default yaml_loader")
try:
return _load_yaml_internal_with_type(
res = _load_yaml_internal_with_type(
ESPHomeLoader,
file_name,
file_handle,
@@ -1159,13 +1163,15 @@ def parse_yaml(
# readable exceptions
# Rewind the stream so we can try again
file_handle.seek(0, 0)
return _load_yaml_internal_with_type(
res = _load_yaml_internal_with_type(
ESPHomePurePythonLoader,
file_name,
file_handle,
yaml_loader,
track_document_range=track_document_range,
)
# Resolved after the fallback so a failed first parse never loads the include twice
return _resolve_include(res)
def _load_yaml_internal_with_type(
+4 -2
View File
@@ -28,6 +28,7 @@ def test_warn_legacy_animation_warns_once(
) -> None:
"""The deprecation warning fires exactly once and never mutates the config."""
config: ConfigType = {"id": "test_animation", "file": "anim.gif", "type": "rgb565"}
unchanged = dict(config)
# A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the
# one-shot warning can print a pasteable migrated block.
@@ -35,10 +36,11 @@ def test_warn_legacy_animation_warns_once(
with caplog.at_level(logging.WARNING):
# First call: flag not yet set -> warns and records the flag.
assert _warn_legacy_animation(config) is config
_warn_legacy_animation(config)
# Second call: flag already set -> stays silent (the dedup branch).
assert _warn_legacy_animation(config) is config
_warn_legacy_animation(config)
assert config == unchanged
assert CORE.data[DOMAIN]["legacy_warning_shown"] is True
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
@@ -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,28 @@
esphome:
name: test
esp32:
board: esp32dev
external_components:
- source:
type: local
path: ../external_components
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
display:
- platform: epaper_spi
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO25
fake_display_writer:
@@ -0,0 +1,21 @@
esphome:
name: test
esp32:
board: esp32dev
external_components:
- source:
type: local
path: ../external_components
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
display:
- platform: it8951
model: m5stack-m5paper
fake_display_writer:
@@ -0,0 +1,25 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
display:
- platform: it8951
id: it8951_display
model: m5stack-m5paper
- platform: epaper_spi
id: epaper_display
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO25
@@ -0,0 +1,33 @@
esphome:
name: test
esp32:
board: esp32dev
external_components:
- source:
type: local
path: ../external_components
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
display:
- platform: it8951
id: it8951_display
model: m5stack-m5paper
- platform: epaper_spi
id: epaper_display
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO25
fake_display_writer:
display_id: epaper_display
@@ -0,0 +1,20 @@
"""External component that draws into a display from outside its lambda or pages."""
from esphome.components import display
import esphome.config_validation as cv
from esphome.const import CONF_DISPLAY_ID
from esphome.types import ConfigType
DEPENDENCIES = ["display"]
DOMAIN = "fake_display_writer"
def _register_writer(config: ConfigType) -> ConfigType:
display.register_external_writer(config[CONF_DISPLAY_ID], DOMAIN)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema({cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(display.Display)}),
_register_writer,
)
@@ -0,0 +1,98 @@
"""Tests for the display external writer registry."""
from collections.abc import Callable, Generator
from pathlib import Path
import sys
import pytest
from esphome import loader
from esphome.components.display import (
Display,
get_external_writers,
has_external_writer,
register_external_writer,
)
from esphome.core import ID
from esphome.final_validate import full_config
TEST_CARD = "show_test_card()"
UPDATE_NEVER = "set_update_interval(4294967295UL)"
@pytest.fixture
def unload_fake_writer() -> Generator[None]:
"""Drop the fake external component so later tests start clean."""
yield
loader._COMPONENT_CACHE.pop("fake_display_writer", None)
sys.modules.pop("esphome.components.fake_display_writer", None)
loader.clear_component_meta_finders()
def test_unresolved_id_matches_after_resolution() -> None:
"""An auto-picked ID registered during schema validation matches once resolved."""
pending = ID(None, is_declaration=False, type=Display)
register_external_writer(pending, "owner")
declared = ID("epd", is_declaration=True, type=Display)
assert get_external_writers(declared) == []
assert has_external_writer(declared) is False
pending.id = "epd"
assert get_external_writers(declared) == ["owner"]
assert has_external_writer(declared) is True
assert get_external_writers(ID("other", is_declaration=True, type=Display)) == []
def test_all_owners_returned() -> None:
register_external_writer(ID("epd"), "first")
register_external_writer(ID("epd"), "second")
register_external_writer(ID("other"), "third")
assert get_external_writers(ID("epd")) == ["first", "second"]
def test_lvgl_counts_as_external_writer() -> None:
assert has_external_writer(ID("epd")) is False
full_config.get()["lvgl"] = []
assert has_external_writer(ID("epd")) is True
def test_drivers_show_test_card_without_owner(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("no_owner.yaml"))
assert f"it8951_display->{TEST_CARD}" in main_cpp
assert f"epaper_display->{TEST_CARD}" in main_cpp
assert UPDATE_NEVER not in main_cpp
@pytest.mark.usefixtures("unload_fake_writer")
@pytest.mark.parametrize(
"config_file", ["it8951_owner_auto_id.yaml", "epaper_spi_owner_auto_id.yaml"]
)
def test_owned_display_has_no_test_card(
config_file: str,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The owner picks the display ID automatically, so it resolves after the schema runs."""
main_cpp = generate_main(component_config_path(config_file))
assert TEST_CARD not in main_cpp
assert UPDATE_NEVER in main_cpp
@pytest.mark.usefixtures("unload_fake_writer")
def test_only_owned_display_loses_test_card(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("owner_by_id.yaml"))
assert f"it8951_display->{TEST_CARD}" in main_cpp
assert f"epaper_display->{TEST_CARD}" not in main_cpp
assert f"epaper_display->{UPDATE_NEVER}" in main_cpp
assert f"it8951_display->{UPDATE_NEVER}" not in main_cpp
@@ -28,6 +28,7 @@ def test_warn_legacy_online_image_warns_once(
) -> None:
"""The deprecation warning fires exactly once and never mutates the config."""
config: ConfigType = {"id": "test_online_image", "url": "http://example.com/i.png"}
unchanged = dict(config)
# A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the
# one-shot warning can print a pasteable migrated block.
@@ -35,10 +36,11 @@ def test_warn_legacy_online_image_warns_once(
with caplog.at_level(logging.WARNING):
# First call: flag not yet set -> warns and records the flag.
assert _warn_legacy_online_image(config) is config
_warn_legacy_online_image(config)
# Second call: flag already set -> stays silent (the dedup branch).
assert _warn_legacy_online_image(config) is config
_warn_legacy_online_image(config)
assert config == unchanged
assert CORE.data[DOMAIN]["legacy_warning_shown"] is True
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
@@ -114,7 +114,8 @@ def test_switch_without_hub_key_accepted(
set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}})
config = _switch_config(CONF_UNPAIRED_ACCESS)
assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config
# Should not raise.
SWITCH_FINAL_VALIDATE_SCHEMA(config)
def test_switch_without_pairing_method_warns(
@@ -153,7 +154,8 @@ def test_enabled_switch_ignores_hub_key(set_core_config: SetCoreConfigCallable)
)
config = _switch_config("enabled")
assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config
# Should not raise.
SWITCH_FINAL_VALIDATE_SCHEMA(config)
@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS])
@@ -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:
+8
View File
@@ -121,3 +121,11 @@ switch:
name: Haier health mode
quiet_mode:
name: Haier quiet mode
select:
- platform: haier
haier_id: haier_ac
horizontal_airflow:
name: Haier airflow horizontal
vertical_airflow:
name: Haier airflow vertical
@@ -102,6 +102,19 @@ TEST_F(TcpClientLinkClock, PendingConnectTimesOutIntoBackoff) {
EXPECT_TRUE(this->link_.in_backoff());
}
TEST_F(TcpClientLinkClock, ConnectingUntilThePendingAttemptEnds) {
this->link_.begin("link_test");
EXPECT_FALSE(this->link_.connecting());
this->set_pending_socket();
this->link_.note_attempt();
this->link_.poll();
EXPECT_TRUE(this->link_.connecting());
set_loop_time(1000 + 10000);
this->link_.poll();
EXPECT_FALSE(this->link_.connecting());
EXPECT_FALSE(this->link_.connected());
}
TEST_F(TcpClientLinkClock, ConnectTimeoutFollowsALongerInterval) {
this->link_.set_reconnect_interval(20000);
this->link_.begin("link_test");
@@ -177,6 +190,7 @@ TEST_F(TcpClientLinkClock, BackoffSpansAMillisWrap) {
TEST(TcpClientLink, AdoptedSocketFlushesQueuedBytes) {
LinkPeer p;
ASSERT_TRUE(p.link_.connected());
EXPECT_FALSE(p.link_.connecting());
EXPECT_EQ(p.link_.queue(reinterpret_cast<const uint8_t *>("ping"), 4), 4u);
EXPECT_TRUE(p.link_.flush_tx());
char buf[8];
@@ -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) {

Some files were not shown because too many files have changed in this diff Show More