From 0ae90512cfd8df3498f7575ca1e68ac38cf8bbf8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jan 2026 17:16:35 -1000 Subject: [PATCH 01/23] [wifi] Add CompactString to reduce WiFi scan heap fragmentation --- .../improv_serial/improv_serial_component.cpp | 2 +- esphome/components/wifi/wifi_component.cpp | 38 +++++---- esphome/components/wifi/wifi_component.h | 25 ++++-- .../wifi/wifi_component_esp8266.cpp | 4 +- .../wifi/wifi_component_esp_idf.cpp | 3 +- .../wifi/wifi_component_libretiny.cpp | 2 +- .../components/wifi/wifi_component_pico_w.cpp | 5 +- .../wifi_info/wifi_info_text_sensor.cpp | 2 +- esphome/core/helpers.cpp | 83 +++++++++++++++++++ esphome/core/helpers.h | 51 ++++++++++++ 10 files changed, 180 insertions(+), 35 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index b4d9943955..1ce490a590 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -267,7 +267,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command for (auto &scan : results) { if (scan.get_is_hidden()) continue; - const std::string &ssid = scan.get_ssid(); + std::string ssid = scan.get_ssid().c_str(); if (std::find(networks.begin(), networks.end(), ssid) != networks.end()) continue; // Send each ssid separately to avoid overflowing the buffer diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index ac90168afe..c8ea038739 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -351,7 +351,7 @@ bool WiFiComponent::needs_scan_results_() const { return this->scan_result_.empty() || !this->scan_result_[0].get_matches(); } -bool WiFiComponent::ssid_was_seen_in_scan_(const std::string &ssid) const { +bool WiFiComponent::ssid_was_seen_in_scan_(const CompactString &ssid) const { // Check if this SSID is configured as hidden // If explicitly marked hidden, we should always try hidden mode regardless of scan results for (const auto &conf : this->sta_) { @@ -939,9 +939,12 @@ WiFiAP WiFiComponent::get_sta() const { return config ? *config : WiFiAP{}; } void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &password) { + this->save_wifi_sta(ssid.c_str(), password.c_str()); +} +void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) { SavedWifiSettings save{}; // zero-initialized - all bytes set to \0, guaranteeing null termination - strncpy(save.ssid, ssid.c_str(), sizeof(save.ssid) - 1); // max 32 chars, byte 32 remains \0 - strncpy(save.password, password.c_str(), sizeof(save.password) - 1); // max 64 chars, byte 64 remains \0 + strncpy(save.ssid, ssid, sizeof(save.ssid) - 1); // max 32 chars, byte 32 remains \0 + strncpy(save.password, password, sizeof(save.password) - 1); // max 64 chars, byte 64 remains \0 this->pref_.save(&save); // ensure it's written immediately global_preferences->sync(); @@ -1786,11 +1789,11 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { } // Get SSID for logging (use pointer to avoid copy) - const std::string *ssid = nullptr; + const char *ssid = nullptr; if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) { - ssid = &this->scan_result_[0].get_ssid(); + ssid = this->scan_result_[0].get_ssid().c_str(); } else if (const WiFiAP *config = this->get_selected_sta_()) { - ssid = &config->get_ssid(); + ssid = config->get_ssid().c_str(); } // Only decrease priority on the last attempt for this phase @@ -1810,8 +1813,8 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { } char bssid_s[18]; format_mac_addr_upper(failed_bssid.value().data(), bssid_s); - ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", - ssid != nullptr ? ssid->c_str() : "", bssid_s, old_priority, new_priority); + ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "", + bssid_s, old_priority, new_priority); // After adjusting priority, check if all priorities are now at minimum // If so, clear the vector to save memory and reset for fresh start @@ -2059,10 +2062,14 @@ void WiFiComponent::save_fast_connect_settings_() { } #endif -void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = ssid; } +void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } +void WiFiAP::set_ssid(const char *ssid) { this->ssid_ = CompactString(ssid, strlen(ssid)); } void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; } void WiFiAP::clear_bssid() { this->bssid_ = {}; } -void WiFiAP::set_password(const std::string &password) { this->password_ = password; } +void WiFiAP::set_password(const std::string &password) { + this->password_ = CompactString(password.c_str(), password.size()); +} +void WiFiAP::set_password(const char *password) { this->password_ = CompactString(password, strlen(password)); } #ifdef USE_WIFI_WPA2_EAP void WiFiAP::set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } #endif @@ -2072,10 +2079,8 @@ void WiFiAP::clear_channel() { this->channel_ = 0; } void WiFiAP::set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } #endif void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; } -const std::string &WiFiAP::get_ssid() const { return this->ssid_; } const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; } bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; } -const std::string &WiFiAP::get_password() const { return this->password_; } #ifdef USE_WIFI_WPA2_EAP const optional &WiFiAP::get_eap() const { return this->eap_; } #endif @@ -2086,12 +2091,12 @@ const optional &WiFiAP::get_manual_ip() const { return this->manual_ip #endif bool WiFiAP::get_hidden() const { return this->hidden_; } -WiFiScanResult::WiFiScanResult(const bssid_t &bssid, std::string ssid, uint8_t channel, int8_t rssi, bool with_auth, - bool is_hidden) +WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, + bool with_auth, bool is_hidden) : bssid_(bssid), channel_(channel), rssi_(rssi), - ssid_(std::move(ssid)), + ssid_(ssid, ssid_len), with_auth_(with_auth), is_hidden_(is_hidden) {} bool WiFiScanResult::matches(const WiFiAP &config) const { @@ -2134,7 +2139,6 @@ bool WiFiScanResult::matches(const WiFiAP &config) const { bool WiFiScanResult::get_matches() const { return this->matches_; } void WiFiScanResult::set_matches(bool matches) { this->matches_ = matches; } const bssid_t &WiFiScanResult::get_bssid() const { return this->bssid_; } -const std::string &WiFiScanResult::get_ssid() const { return this->ssid_; } uint8_t WiFiScanResult::get_channel() const { return this->channel_; } int8_t WiFiScanResult::get_rssi() const { return this->rssi_; } bool WiFiScanResult::get_with_auth() const { return this->with_auth_; } @@ -2207,7 +2211,7 @@ void WiFiComponent::process_roaming_scan_() { for (const auto &result : this->scan_result_) { // Must be same SSID, different BSSID - if (current_ssid != result.get_ssid() || result.get_bssid() == current_bssid) + if (result.get_ssid() != current_ssid.c_str() || result.get_bssid() == current_bssid) continue; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index f27c522a1b..877ba3cd23 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -175,9 +175,13 @@ template using wifi_scan_vector_t = FixedVector; class WiFiAP { public: void set_ssid(const std::string &ssid); + void set_ssid(const char *ssid); + void set_ssid(const CompactString &ssid) { this->ssid_ = ssid; } void set_bssid(const bssid_t &bssid); void clear_bssid(); void set_password(const std::string &password); + void set_password(const char *password); + void set_password(const CompactString &password) { this->password_ = password; } #ifdef USE_WIFI_WPA2_EAP void set_eap(optional eap_auth); #endif // USE_WIFI_WPA2_EAP @@ -188,10 +192,10 @@ class WiFiAP { void set_manual_ip(optional manual_ip); #endif void set_hidden(bool hidden); - const std::string &get_ssid() const; + const CompactString &get_ssid() const { return this->ssid_; } + const CompactString &get_password() const { return this->password_; } const bssid_t &get_bssid() const; bool has_bssid() const; - const std::string &get_password() const; #ifdef USE_WIFI_WPA2_EAP const optional &get_eap() const; #endif // USE_WIFI_WPA2_EAP @@ -204,8 +208,8 @@ class WiFiAP { bool get_hidden() const; protected: - std::string ssid_; - std::string password_; + CompactString ssid_; + CompactString password_; #ifdef USE_WIFI_WPA2_EAP optional eap_; #endif // USE_WIFI_WPA2_EAP @@ -221,14 +225,15 @@ class WiFiAP { class WiFiScanResult { public: - WiFiScanResult(const bssid_t &bssid, std::string ssid, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden); + WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, bool with_auth, + bool is_hidden); bool matches(const WiFiAP &config) const; bool get_matches() const; void set_matches(bool matches); const bssid_t &get_bssid() const; - const std::string &get_ssid() const; + const CompactString &get_ssid() const { return this->ssid_; } uint8_t get_channel() const; int8_t get_rssi() const; bool get_with_auth() const; @@ -242,7 +247,7 @@ class WiFiScanResult { bssid_t bssid_; uint8_t channel_; int8_t rssi_; - std::string ssid_; + CompactString ssid_; int8_t priority_{0}; bool matches_{false}; bool with_auth_; @@ -381,6 +386,10 @@ class WiFiComponent : public Component { void set_passive_scan(bool passive); void save_wifi_sta(const std::string &ssid, const std::string &password); + void save_wifi_sta(const char *ssid, const char *password); + void save_wifi_sta(const CompactString &ssid, const CompactString &password) { + this->save_wifi_sta(ssid.c_str(), password.c_str()); + } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -541,7 +550,7 @@ class WiFiComponent : public Component { int8_t find_first_non_hidden_index_() const; /// Check if an SSID was seen in the most recent scan results /// Used to skip hidden mode for SSIDs we know are visible - bool ssid_was_seen_in_scan_(const std::string &ssid) const; + bool ssid_was_seen_in_scan_(const CompactString &ssid) const; /// Check if full scan results are needed (captive portal active, improv, listeners) bool needs_full_scan_results_() const; /// Check if network matches any configured network (for scan result filtering) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 613a3f8fdb..a7524fd8b0 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -777,8 +777,8 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { const char *ssid_cstr = reinterpret_cast(it->ssid); if (needs_full || this->matches_configured_network_(ssid_cstr, it->bssid)) { this->scan_result_.emplace_back( - bssid_t{it->bssid[0], it->bssid[1], it->bssid[2], it->bssid[3], it->bssid[4], it->bssid[5]}, - std::string(ssid_cstr, it->ssid_len), it->channel, it->rssi, it->authmode != AUTH_OPEN, it->is_hidden != 0); + bssid_t{it->bssid[0], it->bssid[1], it->bssid[2], it->bssid[3], it->bssid[4], it->bssid[5]}, ssid_cstr, + it->ssid_len, it->channel, it->rssi, it->authmode != AUTH_OPEN, it->is_hidden != 0); } else { this->log_discarded_scan_result_(ssid_cstr, it->bssid, it->rssi, it->channel); } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 229c430272..f7170eae4e 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -853,8 +853,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { if (needs_full || this->matches_configured_network_(ssid_cstr, record.bssid)) { bssid_t bssid; std::copy(record.bssid, record.bssid + 6, bssid.begin()); - std::string ssid(ssid_cstr); - this->scan_result_.emplace_back(bssid, std::move(ssid), record.primary, record.rssi, + this->scan_result_.emplace_back(bssid, ssid_cstr, strlen(ssid_cstr), record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); } else { this->log_discarded_scan_result_(ssid_cstr, record.bssid, record.rssi, record.primary); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index aeecefd26f..c3c0d885e2 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -690,7 +690,7 @@ void WiFiComponent::wifi_scan_done_callback_() { auto &ap = scan->ap[i]; this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3], ap.bssid.addr[4], ap.bssid.addr[5]}, - std::string(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN, + ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN, ssid_cstr[0] == '\0'); } else { auto &ap = scan->ap[i]; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 84c10d5d43..9c91d520b9 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -149,9 +149,8 @@ void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *re bssid_t bssid; std::copy(result->bssid, result->bssid + 6, bssid.begin()); - std::string ssid(ssid_cstr); - WiFiScanResult res(bssid, std::move(ssid), result->channel, result->rssi, result->auth_mode != CYW43_AUTH_OPEN, - ssid_cstr[0] == '\0'); + WiFiScanResult res(bssid, ssid_cstr, strlen(ssid_cstr), result->channel, result->rssi, + result->auth_mode != CYW43_AUTH_OPEN, ssid_cstr[0] == '\0'); if (std::find(this->scan_result_.begin(), this->scan_result_.end(), res) == this->scan_result_.end()) { this->scan_result_.push_back(res); } diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index a63b30b892..b5ebfd7390 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -89,7 +89,7 @@ void ScanResultsWiFiInfo::on_wifi_scan_results(const wifi::wifi_scan_vector_t end) break; diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index e7b901d71f..2f1eeb03e2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -857,4 +857,87 @@ void IRAM_ATTR HOT delay_microseconds_safe(uint32_t us) { ; } +// CompactString implementation +CompactString::CompactString(const char *str, size_t len) { + if (len > MAX_LENGTH) { + len = MAX_LENGTH; // Clamp to max valid length + } + + this->length_ = len; + if (len <= INLINE_CAPACITY) { + // Store inline with null terminator + this->is_heap_ = 0; + if (len > 0) { + std::memcpy(this->storage_, str, len); + } + this->storage_[len] = '\0'; + } else { + // Heap allocate with null terminator + this->is_heap_ = 1; + char *heap_data = new char[len + 1]; // NOLINT(cppcoreguidelines-owning-memory) + std::memcpy(heap_data, str, len); + heap_data[len] = '\0'; + this->set_heap_ptr_(heap_data); + } +} + +CompactString::CompactString(const CompactString &other) : length_(other.length_), is_heap_(other.is_heap_) { + if (!other.is_heap_) { + // Copy inline storage including null terminator + std::memcpy(this->storage_, other.storage_, other.length_ + 1); + } else { + char *heap_data = new char[other.length_ + 1]; // NOLINT(cppcoreguidelines-owning-memory) + std::memcpy(heap_data, other.get_heap_ptr_(), other.length_ + 1); + this->set_heap_ptr_(heap_data); + } +} + +CompactString &CompactString::operator=(const CompactString &other) { + if (this != &other) { + if (this->is_heap_) { + delete[] this->get_heap_ptr_(); // NOLINT(cppcoreguidelines-owning-memory) + } + this->length_ = other.length_; + this->is_heap_ = other.is_heap_; + if (!other.is_heap_) { + // Copy inline storage including null terminator + std::memcpy(this->storage_, other.storage_, other.length_ + 1); + } else { + char *heap_data = new char[other.length_ + 1]; // NOLINT(cppcoreguidelines-owning-memory) + std::memcpy(heap_data, other.get_heap_ptr_(), other.length_ + 1); + this->set_heap_ptr_(heap_data); + } + } + return *this; +} + +CompactString::CompactString(CompactString &&other) noexcept : length_(other.length_), is_heap_(other.is_heap_) { + // Copy full storage (includes null terminator for inline, or pointer for heap) + std::memcpy(this->storage_, other.storage_, INLINE_CAPACITY + 1); + other.length_ = 0; + other.is_heap_ = 0; + other.storage_[0] = '\0'; +} + +CompactString &CompactString::operator=(CompactString &&other) noexcept { + if (this != &other) { + if (this->is_heap_) { + delete[] this->get_heap_ptr_(); // NOLINT(cppcoreguidelines-owning-memory) + } + this->length_ = other.length_; + this->is_heap_ = other.is_heap_; + std::memcpy(this->storage_, other.storage_, INLINE_CAPACITY + 1); + other.length_ = 0; + other.is_heap_ = 0; + other.storage_[0] = '\0'; + } + return *this; +} + +CompactString::~CompactString() { + if (this->is_heap_) { + delete[] this->get_heap_ptr_(); // NOLINT(cppcoreguidelines-owning-memory) + } +} + } // namespace esphome diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 1aa29fa3f7..842f3b885e 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1749,4 +1749,55 @@ template::value, int> = 0> T &id(T ///@} +/// 20-byte string: 18 chars inline + null, heap for longer. Always null-terminated. +class CompactString { + public: + static constexpr uint8_t MAX_LENGTH = 127; + static constexpr uint8_t INLINE_CAPACITY = 18; // 18 chars + null terminator fits in 19 bytes + static constexpr uint8_t BUFFER_SIZE = MAX_LENGTH + 1; // For external buffer (128 bytes) + + CompactString() : length_(0), is_heap_(0) { this->storage_[0] = '\0'; } + CompactString(const char *str, size_t len); + CompactString(const CompactString &other); + CompactString(CompactString &&other) noexcept; + CompactString &operator=(const CompactString &other); + CompactString &operator=(CompactString &&other) noexcept; + ~CompactString(); + + const char *data() const { return this->is_heap_ ? this->get_heap_ptr_() : this->storage_; } + const char *c_str() const { return this->data(); } // Always null-terminated + size_t size() const { return this->length_; } + bool empty() const { return this->length_ == 0; } + + bool operator==(const CompactString &other) const { + return this->size() == other.size() && std::memcmp(this->data(), other.data(), this->size()) == 0; + } + bool operator==(const std::string &other) const { + return this->size() == other.size() && std::memcmp(this->data(), other.data(), this->size()) == 0; + } + bool operator==(const char *other) const { + return this->size() == std::strlen(other) && std::memcmp(this->data(), other, this->size()) == 0; + } + bool operator!=(const CompactString &other) const { return !(*this == other); } + bool operator!=(const std::string &other) const { return !(*this == other); } + bool operator!=(const char *other) const { return !(*this == other); } + + protected: + char *get_heap_ptr_() const { + char *ptr; + std::memcpy(&ptr, this->storage_, sizeof(ptr)); + return ptr; + } + void set_heap_ptr_(char *ptr) { std::memcpy(this->storage_, &ptr, sizeof(ptr)); } + + // Storage for string data. When is_heap_=0, contains the string directly (null-terminated). + // When is_heap_=1, first sizeof(char*) bytes contain pointer to heap allocation. + char storage_[INLINE_CAPACITY + 1]; // 19 bytes: 18 chars + null terminator + uint8_t length_ : 7; // String length (0-127) + uint8_t is_heap_ : 1; // 1 if using heap pointer, 0 if using inline storage + // Total size: 20 bytes (19 bytes storage + 1 byte bitfields) +}; + +static_assert(sizeof(CompactString) == 20, "CompactString must be exactly 20 bytes"); + } // namespace esphome From fca867e18d2ab0a9bc6d53634d5284576457e8d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jan 2026 17:18:13 -1000 Subject: [PATCH 02/23] [wifi] Add CompactString to reduce WiFi scan heap fragmentation --- esphome/components/improv_serial/improv_serial_component.cpp | 2 +- esphome/core/helpers.h | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 1ce490a590..6ba8e1f814 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -267,7 +267,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command for (auto &scan : results) { if (scan.get_is_hidden()) continue; - std::string ssid = scan.get_ssid().c_str(); + std::string ssid = scan.get_ssid(); if (std::find(networks.begin(), networks.end(), ssid) != networks.end()) continue; // Send each ssid separately to avoid overflowing the buffer diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 842f3b885e..1d836f3499 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1769,6 +1769,9 @@ class CompactString { size_t size() const { return this->length_; } bool empty() const { return this->length_ == 0; } + // Implicit conversion to std::string for backwards compatibility + operator std::string() const { return std::string(this->data(), this->size()); } + bool operator==(const CompactString &other) const { return this->size() == other.size() && std::memcmp(this->data(), other.data(), this->size()) == 0; } From 3a2c66171b1ac558d2da3f94ac03b7d5975abeb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jan 2026 17:29:21 -1000 Subject: [PATCH 03/23] use placement new to avoid duplicate code --- esphome/core/helpers.cpp | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 2f1eeb03e2..fc64412ddd 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #ifdef USE_ESP32 #include "rom/crc.h" @@ -894,19 +895,8 @@ CompactString::CompactString(const CompactString &other) : length_(other.length_ CompactString &CompactString::operator=(const CompactString &other) { if (this != &other) { - if (this->is_heap_) { - delete[] this->get_heap_ptr_(); // NOLINT(cppcoreguidelines-owning-memory) - } - this->length_ = other.length_; - this->is_heap_ = other.is_heap_; - if (!other.is_heap_) { - // Copy inline storage including null terminator - std::memcpy(this->storage_, other.storage_, other.length_ + 1); - } else { - char *heap_data = new char[other.length_ + 1]; // NOLINT(cppcoreguidelines-owning-memory) - std::memcpy(heap_data, other.get_heap_ptr_(), other.length_ + 1); - this->set_heap_ptr_(heap_data); - } + this->~CompactString(); + new (this) CompactString(other); } return *this; } @@ -921,15 +911,8 @@ CompactString::CompactString(CompactString &&other) noexcept : length_(other.len CompactString &CompactString::operator=(CompactString &&other) noexcept { if (this != &other) { - if (this->is_heap_) { - delete[] this->get_heap_ptr_(); // NOLINT(cppcoreguidelines-owning-memory) - } - this->length_ = other.length_; - this->is_heap_ = other.is_heap_; - std::memcpy(this->storage_, other.storage_, INLINE_CAPACITY + 1); - other.length_ = 0; - other.is_heap_ = 0; - other.storage_[0] = '\0'; + this->~CompactString(); + new (this) CompactString(std::move(other)); } return *this; } From 73d076c2785c38aaf82f327ee6e2616001fc1175 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jan 2026 17:35:00 -1000 Subject: [PATCH 04/23] reduce some more --- esphome/core/helpers.cpp | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index fc64412ddd..67cda05d33 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -882,16 +882,7 @@ CompactString::CompactString(const char *str, size_t len) { } } -CompactString::CompactString(const CompactString &other) : length_(other.length_), is_heap_(other.is_heap_) { - if (!other.is_heap_) { - // Copy inline storage including null terminator - std::memcpy(this->storage_, other.storage_, other.length_ + 1); - } else { - char *heap_data = new char[other.length_ + 1]; // NOLINT(cppcoreguidelines-owning-memory) - std::memcpy(heap_data, other.get_heap_ptr_(), other.length_ + 1); - this->set_heap_ptr_(heap_data); - } -} +CompactString::CompactString(const CompactString &other) : CompactString(other.data(), other.size()) {} CompactString &CompactString::operator=(const CompactString &other) { if (this != &other) { From 733698575306e5c9337e4e970093bb20ccb4bc6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jan 2026 17:53:50 -1000 Subject: [PATCH 05/23] reduce some more --- .../improv_serial/improv_serial_component.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 6ba8e1f814..33514c9e06 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -267,16 +267,26 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command for (auto &scan : results) { if (scan.get_is_hidden()) continue; - std::string ssid = scan.get_ssid(); - if (std::find(networks.begin(), networks.end(), ssid) != networks.end()) + const char *ssid_cstr = scan.get_ssid().c_str(); + // Check if we've already sent this SSID + bool duplicate = false; + for (const auto &seen : networks) { + if (strcmp(seen.c_str(), ssid_cstr) == 0) { + duplicate = true; + break; + } + } + if (duplicate) continue; + // Only allocate std::string after confirming it's not a duplicate + std::string ssid(ssid_cstr); // Send each ssid separately to avoid overflowing the buffer char rssi_buf[5]; // int8_t: -128 to 127, max 4 chars + null *int8_to_str(rssi_buf, scan.get_rssi()) = '\0'; std::vector data = improv::build_rpc_response(improv::GET_WIFI_NETWORKS, {ssid, rssi_buf, YESNO(scan.get_with_auth())}, false); this->send_response_(data); - networks.push_back(ssid); + networks.push_back(std::move(ssid)); } // Send empty response to signify the end of the list. std::vector data = From c32e4bc65b76eadc2f62761caa7a113166f752b7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 26 Jan 2026 03:52:23 +1100 Subject: [PATCH 06/23] [wifi] Fix watchdog timeout on P4 WiFi scan (#13520) --- esphome/components/esp32_hosted/__init__.py | 2 ++ .../wifi/wifi_component_esp_idf.cpp | 20 ++++++++++++++++--- esphome/core/defines.h | 1 + 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index e40431c851..170f436f02 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( KEY_FRAMEWORK_VERSION, ) from esphome.core import CORE +from esphome.cpp_generator import add_define CODEOWNERS = ["@swoboda1337"] @@ -42,6 +43,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + add_define("USE_ESP32_HOSTED") if config[CONF_ACTIVE_HIGH]: esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH", diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 99474ac2f8..15fd407e3c 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #ifdef USE_WIFI_WPA2_EAP #if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) @@ -828,16 +829,29 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { uint16_t number = it.number; scan_result_.init(number); - - // Process one record at a time to avoid large buffer allocation - wifi_ap_record_t record; +#ifdef USE_ESP32_HOSTED + // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor + // Presumably an upstream bug, work-around by getting all records at once + auto records = std::make_unique(number); + err = esp_wifi_scan_get_ap_records(&number, records.get()); + if (err != ESP_OK) { + esp_wifi_clear_ap_list(); + ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err)); + return; + } for (uint16_t i = 0; i < number; i++) { + wifi_ap_record_t &record = records[i]; +#else + // Process one record at a time to avoid large buffer allocation + for (uint16_t i = 0; i < number; i++) { + wifi_ap_record_t record; err = esp_wifi_scan_get_ap_record(&record); if (err != ESP_OK) { ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err)); esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved break; } +#endif // USE_ESP32_HOSTED bssid_t bssid; std::copy(record.bssid, record.bssid + 6, bssid.begin()); std::string ssid(reinterpret_cast(record.ssid)); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7c13823fba..e98cdd0ba0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -42,6 +42,7 @@ #define USE_DEVICES #define USE_DISPLAY #define USE_ENTITY_ICON +#define USE_ESP32_HOSTED #define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_EVENT #define USE_FAN From bac96086be2a3702c5f01afa317670d6ebb2d27a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jan 2026 07:16:07 -1000 Subject: [PATCH 07/23] [wifi] Fix scan flag race condition causing reconnect failure on ESP8266/LibreTiny (#13514) --- esphome/components/wifi/wifi_component_esp8266.cpp | 4 ++++ esphome/components/wifi/wifi_component_libretiny.cpp | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index de0600cf5b..91db7ae0eb 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -698,6 +698,10 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { if (!this->wifi_mode_(true, {})) return false; + // Reset scan_done_ before starting new scan to prevent stale flag from previous scan + // (e.g., roaming scan completed just before unexpected disconnect) + this->scan_done_ = false; + struct scan_config config {}; memset(&config, 0, sizeof(config)); config.ssid = nullptr; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index cc9f4ec193..20cd32fa8f 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -649,6 +649,10 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { if (!this->wifi_mode_(true, {})) return false; + // Reset scan_done_ before starting new scan to prevent stale flag from previous scan + // (e.g., roaming scan completed just before unexpected disconnect) + this->scan_done_ = false; + // need to use WiFi because of WiFiScanClass allocations :( int16_t err = WiFi.scanNetworks(true, true, passive, 200); if (err != WIFI_SCAN_RUNNING) { From ccbf17d5ab9074f78387c54eef3fecd424ce6c34 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jan 2026 12:42:28 -1000 Subject: [PATCH 08/23] [st7701s] Fix dump_summary deprecation warning (#13462) --- esphome/components/st7701s/st7701s.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 6314c99fb0..221fe39b9d 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -1,5 +1,6 @@ #ifdef USE_ESP32_VARIANT_ESP32S3 #include "st7701s.h" +#include "esphome/core/gpio.h" #include "esphome/core/log.h" namespace esphome { @@ -183,8 +184,11 @@ void ST7701S::dump_config() { LOG_PIN(" DE Pin: ", this->de_pin_); LOG_PIN(" Reset Pin: ", this->reset_pin_); size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]); - for (size_t i = 0; i != data_pin_count; i++) - ESP_LOGCONFIG(TAG, " Data pin %d: %s", i, (this->data_pins_[i])->dump_summary().c_str()); + char pin_summary[GPIO_SUMMARY_MAX_LEN]; + for (size_t i = 0; i != data_pin_count; i++) { + this->data_pins_[i]->dump_summary(pin_summary, sizeof(pin_summary)); + ESP_LOGCONFIG(TAG, " Data pin %d: %s", i, pin_summary); + } ESP_LOGCONFIG(TAG, " SPI Data rate: %dMHz", (unsigned) (this->data_rate_ / 1000000)); } From ab1661ef22760685f9f79bd8137f1d3b727614c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jan 2026 12:53:15 -1000 Subject: [PATCH 09/23] [mipi_rgb] Fix dump_summary deprecation warning (#13463) --- esphome/components/mipi_rgb/mipi_rgb.cpp | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index ef96da8a1c..d0e716bd24 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -1,9 +1,11 @@ #ifdef USE_ESP32_VARIANT_ESP32S3 #include "mipi_rgb.h" +#include "esphome/core/gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/core/hal.h" #include "esp_lcd_panel_rgb.h" +#include namespace esphome { namespace mipi_rgb { @@ -343,19 +345,27 @@ int MipiRgb::get_height() { } } -static std::string get_pin_name(GPIOPin *pin) { +static const char *get_pin_name(GPIOPin *pin, std::span buffer) { if (pin == nullptr) return "None"; - return pin->dump_summary(); + pin->dump_summary(buffer.data(), buffer.size()); + return buffer.data(); } void MipiRgb::dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset) { + char pin_summary[GPIO_SUMMARY_MAX_LEN]; for (uint8_t i = start; i != end; i++) { - ESP_LOGCONFIG(TAG, " %s pin %d: %s", name, offset++, this->data_pins_[i]->dump_summary().c_str()); + this->data_pins_[i]->dump_summary(pin_summary, sizeof(pin_summary)); + ESP_LOGCONFIG(TAG, " %s pin %d: %s", name, offset++, pin_summary); } } void MipiRgb::dump_config() { + char reset_buf[GPIO_SUMMARY_MAX_LEN]; + char de_buf[GPIO_SUMMARY_MAX_LEN]; + char pclk_buf[GPIO_SUMMARY_MAX_LEN]; + char hsync_buf[GPIO_SUMMARY_MAX_LEN]; + char vsync_buf[GPIO_SUMMARY_MAX_LEN]; ESP_LOGCONFIG(TAG, "MIPI_RGB LCD" "\n Model: %s" @@ -379,9 +389,9 @@ void MipiRgb::dump_config() { this->model_, this->width_, this->height_, this->rotation_, YESNO(this->pclk_inverted_), this->hsync_pulse_width_, this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, this->vsync_back_porch_, this->vsync_front_porch_, YESNO(this->invert_colors_), - (unsigned) (this->pclk_frequency_ / 1000000), get_pin_name(this->reset_pin_).c_str(), - get_pin_name(this->de_pin_).c_str(), get_pin_name(this->pclk_pin_).c_str(), - get_pin_name(this->hsync_pin_).c_str(), get_pin_name(this->vsync_pin_).c_str()); + (unsigned) (this->pclk_frequency_ / 1000000), get_pin_name(this->reset_pin_, reset_buf), + get_pin_name(this->de_pin_, de_buf), get_pin_name(this->pclk_pin_, pclk_buf), + get_pin_name(this->hsync_pin_, hsync_buf), get_pin_name(this->vsync_pin_, vsync_buf)); this->dump_pins_(8, 13, "Blue", 0); this->dump_pins_(13, 16, "Green", 0); From c4f7d09553b2c634c4d007f201470307a0068d38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 22 Jan 2026 12:53:38 -1000 Subject: [PATCH 10/23] [rpi_dpi_rgb] Fix dump_summary deprecation warning (#13461) --- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index a81bb17dfc..363f4b63b8 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -1,5 +1,6 @@ #ifdef USE_ESP32_VARIANT_ESP32S3 #include "rpi_dpi_rgb.h" +#include "esphome/core/gpio.h" #include "esphome/core/log.h" namespace esphome { @@ -134,8 +135,11 @@ void RpiDpiRgb::dump_config() { LOG_PIN(" Enable Pin: ", this->enable_pin_); LOG_PIN(" Reset Pin: ", this->reset_pin_); size_t data_pin_count = sizeof(this->data_pins_) / sizeof(this->data_pins_[0]); - for (size_t i = 0; i != data_pin_count; i++) - ESP_LOGCONFIG(TAG, " Data pin %d: %s", i, (this->data_pins_[i])->dump_summary().c_str()); + char pin_summary[GPIO_SUMMARY_MAX_LEN]; + for (size_t i = 0; i != data_pin_count; i++) { + this->data_pins_[i]->dump_summary(pin_summary, sizeof(pin_summary)); + ESP_LOGCONFIG(TAG, " Data pin %d: %s", i, pin_summary); + } } void RpiDpiRgb::reset_display_() const { From 9cc39621a6bdfe505fb668b502ecaac1431dc457 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 22 Jan 2026 20:35:37 -0600 Subject: [PATCH 11/23] [ir_rf_proxy] Remove unnecessary headers, add tests (#13464) --- esphome/components/ir_rf_proxy/ir_rf_proxy.h | 2 - tests/components/ir_rf_proxy/common-rx.yaml | 18 +++++++++ tests/components/ir_rf_proxy/common-tx.yaml | 19 +++++++++ tests/components/ir_rf_proxy/common.yaml | 39 +------------------ .../ir_rf_proxy/test-rx.esp32-idf.yaml | 7 ++++ .../ir_rf_proxy/test-rx.esp8266-ard.yaml | 7 ++++ .../ir_rf_proxy/test-rx.rp2040-ard.yaml | 7 ++++ .../ir_rf_proxy/test-tx.esp32-idf.yaml | 7 ++++ .../ir_rf_proxy/test-tx.esp8266-ard.yaml | 7 ++++ .../ir_rf_proxy/test-tx.rp2040-ard.yaml | 7 ++++ .../ir_rf_proxy/test.bk72xx-ard.yaml | 8 ++++ .../ir_rf_proxy/test.esp32-idf.yaml | 5 ++- .../ir_rf_proxy/test.esp8266-ard.yaml | 5 ++- .../ir_rf_proxy/test.rp2040-ard.yaml | 5 ++- 14 files changed, 101 insertions(+), 42 deletions(-) create mode 100644 tests/components/ir_rf_proxy/common-rx.yaml create mode 100644 tests/components/ir_rf_proxy/common-tx.yaml create mode 100644 tests/components/ir_rf_proxy/test-rx.esp32-idf.yaml create mode 100644 tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml create mode 100644 tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml create mode 100644 tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml create mode 100644 tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml create mode 100644 tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml create mode 100644 tests/components/ir_rf_proxy/test.bk72xx-ard.yaml diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index d7c8919def..f067a6e17a 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -5,8 +5,6 @@ // Once the API is considered stable, this warning will be removed. #include "esphome/components/infrared/infrared.h" -#include "esphome/components/remote_transmitter/remote_transmitter.h" -#include "esphome/components/remote_receiver/remote_receiver.h" namespace esphome::ir_rf_proxy { diff --git a/tests/components/ir_rf_proxy/common-rx.yaml b/tests/components/ir_rf_proxy/common-rx.yaml new file mode 100644 index 0000000000..0f758f832d --- /dev/null +++ b/tests/components/ir_rf_proxy/common-rx.yaml @@ -0,0 +1,18 @@ +remote_receiver: + id: ir_receiver + pin: ${rx_pin} + +# Test various hardware types with transmitter/receiver using infrared platform +infrared: + # Infrared receiver + - platform: ir_rf_proxy + id: ir_rx + name: "IR Receiver" + remote_receiver_id: ir_receiver + + # RF 900MHz receiver + - platform: ir_rf_proxy + id: rf_900_rx + name: "RF 900 Receiver" + frequency: 900 MHz + remote_receiver_id: ir_receiver diff --git a/tests/components/ir_rf_proxy/common-tx.yaml b/tests/components/ir_rf_proxy/common-tx.yaml new file mode 100644 index 0000000000..4af9e2635e --- /dev/null +++ b/tests/components/ir_rf_proxy/common-tx.yaml @@ -0,0 +1,19 @@ +remote_transmitter: + id: ir_transmitter + pin: ${tx_pin} + carrier_duty_percent: 50% + +# Test various hardware types with transmitter/receiver using infrared platform +infrared: + # Infrared transmitter + - platform: ir_rf_proxy + id: ir_tx + name: "IR Transmitter" + remote_transmitter_id: ir_transmitter + + # RF 433MHz transmitter + - platform: ir_rf_proxy + id: rf_433_tx + name: "RF 433 Transmitter" + frequency: 433 MHz + remote_transmitter_id: ir_transmitter diff --git a/tests/components/ir_rf_proxy/common.yaml b/tests/components/ir_rf_proxy/common.yaml index cd2b10d31b..53a0cd379a 100644 --- a/tests/components/ir_rf_proxy/common.yaml +++ b/tests/components/ir_rf_proxy/common.yaml @@ -1,42 +1,7 @@ +network: + wifi: ssid: MySSID password: password1 api: - -remote_transmitter: - id: ir_transmitter - pin: ${tx_pin} - carrier_duty_percent: 50% - -remote_receiver: - id: ir_receiver - pin: ${rx_pin} - -# Test various hardware types with transmitter/receiver using infrared platform -infrared: - # Infrared transmitter - - platform: ir_rf_proxy - id: ir_tx - name: "IR Transmitter" - remote_transmitter_id: ir_transmitter - - # Infrared receiver - - platform: ir_rf_proxy - id: ir_rx - name: "IR Receiver" - remote_receiver_id: ir_receiver - - # RF 433MHz transmitter - - platform: ir_rf_proxy - id: rf_433_tx - name: "RF 433 Transmitter" - frequency: 433 MHz - remote_transmitter_id: ir_transmitter - - # RF 900MHz receiver - - platform: ir_rf_proxy - id: rf_900_rx - name: "RF 900 Receiver" - frequency: 900 MHz - remote_receiver_id: ir_receiver diff --git a/tests/components/ir_rf_proxy/test-rx.esp32-idf.yaml b/tests/components/ir_rf_proxy/test-rx.esp32-idf.yaml new file mode 100644 index 0000000000..8172885b31 --- /dev/null +++ b/tests/components/ir_rf_proxy/test-rx.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml diff --git a/tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml b/tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml new file mode 100644 index 0000000000..8172885b31 --- /dev/null +++ b/tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml diff --git a/tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml b/tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml new file mode 100644 index 0000000000..8172885b31 --- /dev/null +++ b/tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml diff --git a/tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml b/tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml new file mode 100644 index 0000000000..7162f15b2d --- /dev/null +++ b/tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + tx: !include common-tx.yaml diff --git a/tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml b/tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml new file mode 100644 index 0000000000..7162f15b2d --- /dev/null +++ b/tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + tx: !include common-tx.yaml diff --git a/tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml b/tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml new file mode 100644 index 0000000000..7162f15b2d --- /dev/null +++ b/tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + tx: !include common-tx.yaml diff --git a/tests/components/ir_rf_proxy/test.bk72xx-ard.yaml b/tests/components/ir_rf_proxy/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..a0e145f476 --- /dev/null +++ b/tests/components/ir_rf_proxy/test.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/ir_rf_proxy/test.esp32-idf.yaml b/tests/components/ir_rf_proxy/test.esp32-idf.yaml index b516342f3b..a0e145f476 100644 --- a/tests/components/ir_rf_proxy/test.esp32-idf.yaml +++ b/tests/components/ir_rf_proxy/test.esp32-idf.yaml @@ -2,4 +2,7 @@ substitutions: tx_pin: GPIO4 rx_pin: GPIO5 -<<: !include common.yaml +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/ir_rf_proxy/test.esp8266-ard.yaml b/tests/components/ir_rf_proxy/test.esp8266-ard.yaml index b516342f3b..a0e145f476 100644 --- a/tests/components/ir_rf_proxy/test.esp8266-ard.yaml +++ b/tests/components/ir_rf_proxy/test.esp8266-ard.yaml @@ -2,4 +2,7 @@ substitutions: tx_pin: GPIO4 rx_pin: GPIO5 -<<: !include common.yaml +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/ir_rf_proxy/test.rp2040-ard.yaml b/tests/components/ir_rf_proxy/test.rp2040-ard.yaml index b516342f3b..a0e145f476 100644 --- a/tests/components/ir_rf_proxy/test.rp2040-ard.yaml +++ b/tests/components/ir_rf_proxy/test.rp2040-ard.yaml @@ -2,4 +2,7 @@ substitutions: tx_pin: GPIO4 rx_pin: GPIO5 -<<: !include common.yaml +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml From 6870d3dc5086dd8b80a86a7e0131f5843273182b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:02:27 +1100 Subject: [PATCH 12/23] [mipi_rgb] Add software reset command to st7701s init sequence (#13470) --- esphome/components/mipi_rgb/models/st7701s.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index 3c66380d04..990a1ca4f3 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -55,6 +55,7 @@ st7701s = ST7701S( pclk_frequency="16MHz", pclk_inverted=True, initsequence=( + (0x01,), # Software Reset (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), # Page 0 (0xC0, 0x3B, 0x00), (0xC1, 0x0D, 0x02), (0xC2, 0x31, 0x05), (0xB0, 0x00, 0x11, 0x18, 0x0E, 0x11, 0x06, 0x07, 0x08, 0x07, 0x22, 0x04, 0x12, 0x0F, 0xAA, 0x31, 0x18,), From ef469c20dfffd31cfb6a144fbacb4dd2c75b76fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 23 Jan 2026 12:37:06 -1000 Subject: [PATCH 13/23] [slow_pwm] Fix dump_summary deprecation warning (#13460) --- esphome/components/slow_pwm/slow_pwm_output.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/slow_pwm/slow_pwm_output.cpp b/esphome/components/slow_pwm/slow_pwm_output.cpp index 48ded94b3a..033729c407 100644 --- a/esphome/components/slow_pwm/slow_pwm_output.cpp +++ b/esphome/components/slow_pwm/slow_pwm_output.cpp @@ -1,6 +1,7 @@ #include "slow_pwm_output.h" -#include "esphome/core/log.h" #include "esphome/core/application.h" +#include "esphome/core/gpio.h" +#include "esphome/core/log.h" namespace esphome { namespace slow_pwm { @@ -20,7 +21,9 @@ void SlowPWMOutput::set_output_state_(bool new_state) { } if (new_state != current_state_) { if (this->pin_) { - ESP_LOGV(TAG, "Switching output pin %s to %s", this->pin_->dump_summary().c_str(), ONOFF(new_state)); + char pin_summary[GPIO_SUMMARY_MAX_LEN]; + this->pin_->dump_summary(pin_summary, sizeof(pin_summary)); + ESP_LOGV(TAG, "Switching output pin %s to %s", pin_summary, ONOFF(new_state)); } else { ESP_LOGV(TAG, "Switching to %s", ONOFF(new_state)); } From d285706b41d30be068cbf87782f76810cb36a20b Mon Sep 17 00:00:00 2001 From: Big Mike Date: Fri, 23 Jan 2026 17:03:23 -0600 Subject: [PATCH 14/23] [sen5x] Fix store baseline functionality (#13469) --- esphome/components/sen5x/sen5x.cpp | 94 +++++++++++++----------------- esphome/components/sen5x/sen5x.h | 15 ++--- esphome/components/sen5x/sensor.py | 1 + 3 files changed, 45 insertions(+), 65 deletions(-) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index d5c9dfa3ae..09d93a2b2f 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -124,8 +124,8 @@ void SEN5XComponent::setup() { sen5x_type = SEN55; } } - ESP_LOGD(TAG, "Product name: %s", this->product_name_.c_str()); } + ESP_LOGD(TAG, "Product name: %s", this->product_name_.c_str()); if (this->humidity_sensor_ && sen5x_type == SEN50) { ESP_LOGE(TAG, "Relative humidity requires a SEN54 or SEN55"); this->humidity_sensor_ = nullptr; // mark as not used @@ -159,28 +159,14 @@ void SEN5XComponent::setup() { // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict uint32_t hash = fnv1a_hash_extend(App.get_config_version_hash(), combined_serial); - this->pref_ = global_preferences->make_preference(hash, true); - - if (this->pref_.load(&this->voc_baselines_storage_)) { - ESP_LOGI(TAG, "Loaded VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); - } - - // Initialize storage timestamp - this->seconds_since_last_store_ = 0; - - if (this->voc_baselines_storage_.state0 > 0 && this->voc_baselines_storage_.state1 > 0) { - ESP_LOGI(TAG, "Setting VOC baseline from save state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); - uint16_t states[4]; - - states[0] = this->voc_baselines_storage_.state0 >> 16; - states[1] = this->voc_baselines_storage_.state0 & 0xFFFF; - states[2] = this->voc_baselines_storage_.state1 >> 16; - states[3] = this->voc_baselines_storage_.state1 & 0xFFFF; - - if (!this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE, states, 4)) { - ESP_LOGE(TAG, "Failed to set VOC baseline from saved state"); + this->pref_ = global_preferences->make_preference(hash, true); + this->voc_baseline_time_ = App.get_loop_component_start_time(); + if (this->pref_.load(&this->voc_baseline_state_)) { + if (!this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE, this->voc_baseline_state_, 4)) { + ESP_LOGE(TAG, "VOC Baseline State write to sensor failed"); + } else { + ESP_LOGV(TAG, "VOC Baseline State loaded"); + delay(20); } } } @@ -288,6 +274,14 @@ void SEN5XComponent::dump_config() { ESP_LOGCONFIG(TAG, " RH/T acceleration mode: %s", LOG_STR_ARG(rht_accel_mode_to_string(this->acceleration_mode_.value()))); } + if (this->voc_sensor_) { + char hex_buf[5 * 4]; + format_hex_pretty_to(hex_buf, this->voc_baseline_state_, 4, 0); + ESP_LOGCONFIG(TAG, + " Store Baseline: %s\n" + " State: %s\n", + TRUEFALSE(this->store_baseline_), hex_buf); + } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); @@ -304,36 +298,6 @@ void SEN5XComponent::update() { return; } - // Store baselines after defined interval or if the difference between current and stored baseline becomes too - // much - if (this->store_baseline_ && this->seconds_since_last_store_ > SHORTEST_BASELINE_STORE_INTERVAL) { - if (this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE)) { - // run it a bit later to avoid adding a delay here - this->set_timeout(550, [this]() { - uint16_t states[4]; - if (this->read_data(states, 4)) { - uint32_t state0 = states[0] << 16 | states[1]; - uint32_t state1 = states[2] << 16 | states[3]; - if ((uint32_t) std::abs(static_cast(this->voc_baselines_storage_.state0 - state0)) > - MAXIMUM_STORAGE_DIFF || - (uint32_t) std::abs(static_cast(this->voc_baselines_storage_.state1 - state1)) > - MAXIMUM_STORAGE_DIFF) { - this->seconds_since_last_store_ = 0; - this->voc_baselines_storage_.state0 = state0; - this->voc_baselines_storage_.state1 = state1; - - if (this->pref_.save(&this->voc_baselines_storage_)) { - ESP_LOGI(TAG, "Stored VOC baseline state0: 0x%04" PRIX32 ", state1: 0x%04" PRIX32, - this->voc_baselines_storage_.state0, this->voc_baselines_storage_.state1); - } else { - ESP_LOGW(TAG, "Could not store VOC baselines"); - } - } - } - }); - } - } - if (!this->write_command(SEN5X_CMD_READ_MEASUREMENT)) { this->status_set_warning(); ESP_LOGD(TAG, "Write error: read measurement (%d)", this->last_error_); @@ -402,7 +366,29 @@ void SEN5XComponent::update() { if (this->nox_sensor_ != nullptr) { this->nox_sensor_->publish_state(nox); } - this->status_clear_warning(); + + if (!this->voc_sensor_ || !this->store_baseline_ || + (App.get_loop_component_start_time() - this->voc_baseline_time_) < SHORTEST_BASELINE_STORE_INTERVAL) { + this->status_clear_warning(); + } else { + this->voc_baseline_time_ = App.get_loop_component_start_time(); + if (!this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE)) { + this->status_set_warning(); + ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); + } else { + this->set_timeout(20, [this]() { + if (!this->read_data(this->voc_baseline_state_, 4)) { + this->status_set_warning(); + ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); + } else { + if (this->pref_.save(&this->voc_baseline_state_)) { + ESP_LOGD(TAG, "VOC Baseline State saved"); + } + this->status_clear_warning(); + } + }); + } + } }); } diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index 9e5b6bf231..aaa672dbc4 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -24,11 +24,6 @@ enum RhtAccelerationMode : uint16_t { HIGH_ACCELERATION = 2, }; -struct Sen5xBaselines { - int32_t state0; - int32_t state1; -} PACKED; // NOLINT - struct GasTuning { uint16_t index_offset; uint16_t learning_time_offset_hours; @@ -44,11 +39,9 @@ struct TemperatureCompensation { uint16_t time_constant; }; -// Shortest time interval of 3H for storing baseline values. +// Shortest time interval of 2H (in milliseconds) for storing baseline values. // Prevents wear of the flash because of too many write operations -static const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 10800; -// Store anyway if the baseline difference exceeds the max storage diff value -static const uint32_t MAXIMUM_STORAGE_DIFF = 50; +static const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 2 * 60 * 60 * 1000; class SEN5XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: @@ -107,7 +100,8 @@ class SEN5XComponent : public PollingComponent, public sensirion_common::Sensiri bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); bool write_temperature_compensation_(const TemperatureCompensation &compensation); - uint32_t seconds_since_last_store_; + uint16_t voc_baseline_state_[4]{0}; + uint32_t voc_baseline_time_; uint16_t firmware_version_; ERRORCODE error_code_; uint8_t serial_number_[4]; @@ -132,7 +126,6 @@ class SEN5XComponent : public PollingComponent, public sensirion_common::Sensiri optional temperature_compensation_; ESPPreferenceObject pref_; std::string product_name_; - Sen5xBaselines voc_baselines_storage_; }; } // namespace sen5x diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 9c3114b9e2..538a2f5239 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -210,6 +210,7 @@ SENSOR_MAP = { SETTING_MAP = { CONF_AUTO_CLEANING_INTERVAL: "set_auto_cleaning_interval", CONF_ACCELERATION_MODE: "set_acceleration_mode", + CONF_STORE_BASELINE: "set_store_baseline", } From 10cbd0164ad36dfb44d622e39140194151f612ae Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 24 Jan 2026 11:44:34 +1100 Subject: [PATCH 15/23] [lvgl] Fix setting empty text (#13494) --- esphome/components/lvgl/widgets/label.py | 2 +- tests/components/lvgl/lvgl-package.yaml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index 3a3a997737..8afd8d610f 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -32,7 +32,7 @@ class LabelType(WidgetType): async def to_code(self, w: Widget, config): """For a text object, create and set text""" - if value := config.get(CONF_TEXT): + if (value := config.get(CONF_TEXT)) is not None: await w.set_property(CONF_TEXT, await lv_text.process(value)) await w.set_property(CONF_LONG_MODE, config) await w.set_property(CONF_RECOLOR, config) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 65d629bcdf..3635fc710f 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -197,6 +197,9 @@ lvgl: - lvgl.label.update: id: msgbox_label text: Unloaded + - lvgl.label.update: + id: msgbox_label + text: "" # Empty text on_all_events: logger.log: format: "Event %s" From d6841ba33a754f2cb9be6dd08deb15ac6f813839 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 23 Jan 2026 18:53:20 -0600 Subject: [PATCH 16/23] [light] Fix cwww state restore (#13493) --- esphome/components/light/light_call.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 234d641f0d..6d42dd1513 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -391,7 +391,10 @@ void LightCall::transform_parameters_() { min_mireds > 0.0f && max_mireds > 0.0f) { ESP_LOGD(TAG, "'%s': setting cold/warm white channels using white/color temperature values", this->parent_->get_name().c_str()); - if (this->has_color_temperature()) { + // Only compute cold_white/warm_white from color_temperature if they're not already explicitly set. + // This is important for state restoration, where both color_temperature and cold_white/warm_white + // are restored from flash - we want to preserve the saved cold_white/warm_white values. + if (this->has_color_temperature() && !this->has_cold_white() && !this->has_warm_white()) { const float color_temp = clamp(this->color_temperature_, min_mireds, max_mireds); const float range = max_mireds - min_mireds; const float ww_fraction = (color_temp - min_mireds) / range; From 56a2a2269fff7e716c19a79725b11bd4b2b7c761 Mon Sep 17 00:00:00 2001 From: Jas Strong Date: Fri, 23 Jan 2026 19:01:19 -0800 Subject: [PATCH 17/23] [rd03d] Fix speed and resolution field order (#13495) Co-authored-by: jas --- esphome/components/rd03d/rd03d.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index d9b0b59fe9..ba05abe8e0 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -133,14 +133,17 @@ void RD03DComponent::process_frame_() { uint8_t offset = FRAME_HEADER_SIZE + (i * TARGET_DATA_SIZE); // Extract raw bytes for this target + // Note: Despite datasheet Table 5-2 showing order as X, Y, Speed, Resolution, + // actual radar output has Resolution before Speed (verified empirically - + // stationary targets were showing non-zero speed with original field order) uint8_t x_low = this->buffer_[offset + 0]; uint8_t x_high = this->buffer_[offset + 1]; uint8_t y_low = this->buffer_[offset + 2]; uint8_t y_high = this->buffer_[offset + 3]; - uint8_t speed_low = this->buffer_[offset + 4]; - uint8_t speed_high = this->buffer_[offset + 5]; - uint8_t res_low = this->buffer_[offset + 6]; - uint8_t res_high = this->buffer_[offset + 7]; + uint8_t res_low = this->buffer_[offset + 4]; + uint8_t res_high = this->buffer_[offset + 5]; + uint8_t speed_low = this->buffer_[offset + 6]; + uint8_t speed_high = this->buffer_[offset + 7]; // Decode values per RD-03D format int16_t x = decode_value(x_low, x_high); From 70e45706d96eeebc6e577d03323c70f75d79a10e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:01:40 -0500 Subject: [PATCH 18/23] [modbus_controller] Fix YAML serialization error with custom_command (#13482) Co-authored-by: Claude Opus 4.5 --- esphome/components/modbus_controller/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 1c23783ce3..c45c338bb3 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -279,7 +279,7 @@ def modbus_calc_properties(config): if isinstance(value, str): value = value.encode() config[CONF_ADDRESS] = binascii.crc_hqx(value, 0) - config[CONF_REGISTER_TYPE] = ModbusRegisterType.CUSTOM + config[CONF_REGISTER_TYPE] = cv.enum(MODBUS_REGISTER_TYPE)("custom") config[CONF_FORCE_NEW_RANGE] = True return byte_offset, reg_count From 723f67d5e21ad3f52cd6faee6ff1fb0eb6ba1b5f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:13:03 -0500 Subject: [PATCH 19/23] [i2c] Increase ESP-IDF I2C transaction timeout from 20ms to 100ms (#13483) Co-authored-by: Claude Opus 4.5 --- esphome/components/i2c/i2c_bus_esp_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 191c849aa3..7a965ce5ad 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -185,7 +185,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s } jobs[num_jobs++].command = I2C_MASTER_CMD_STOP; ESP_LOGV(TAG, "Sending %zu jobs", num_jobs); - esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num_jobs, 20); + esp_err_t err = i2c_master_execute_defined_operations(this->dev_, jobs, num_jobs, 100); if (err == ESP_ERR_INVALID_STATE) { ESP_LOGV(TAG, "TX to %02X failed: not acked", address); return ERROR_NOT_ACKNOWLEDGED; From cc2f3d85dc4f0394fec3985d0b2f2769a2d1ea67 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 26 Jan 2026 03:52:23 +1100 Subject: [PATCH 20/23] [wifi] Fix watchdog timeout on P4 WiFi scan (#13520) --- esphome/components/esp32_hosted/__init__.py | 2 ++ .../wifi/wifi_component_esp_idf.cpp | 20 ++++++++++++++++--- esphome/core/defines.h | 1 + 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index e40431c851..170f436f02 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( KEY_FRAMEWORK_VERSION, ) from esphome.core import CORE +from esphome.cpp_generator import add_define CODEOWNERS = ["@swoboda1337"] @@ -42,6 +43,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): + add_define("USE_ESP32_HOSTED") if config[CONF_ACTIVE_HIGH]: esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH", diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 99474ac2f8..15fd407e3c 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #ifdef USE_WIFI_WPA2_EAP #if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) @@ -828,16 +829,29 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { uint16_t number = it.number; scan_result_.init(number); - - // Process one record at a time to avoid large buffer allocation - wifi_ap_record_t record; +#ifdef USE_ESP32_HOSTED + // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor + // Presumably an upstream bug, work-around by getting all records at once + auto records = std::make_unique(number); + err = esp_wifi_scan_get_ap_records(&number, records.get()); + if (err != ESP_OK) { + esp_wifi_clear_ap_list(); + ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err)); + return; + } for (uint16_t i = 0; i < number; i++) { + wifi_ap_record_t &record = records[i]; +#else + // Process one record at a time to avoid large buffer allocation + for (uint16_t i = 0; i < number; i++) { + wifi_ap_record_t record; err = esp_wifi_scan_get_ap_record(&record); if (err != ESP_OK) { ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err)); esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved break; } +#endif // USE_ESP32_HOSTED bssid_t bssid; std::copy(record.bssid, record.bssid + 6, bssid.begin()); std::string ssid(reinterpret_cast(record.ssid)); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c229d1df7d..3723d96c79 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -42,6 +42,7 @@ #define USE_DEVICES #define USE_DISPLAY #define USE_ENTITY_ICON +#define USE_ESP32_HOSTED #define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_EVENT #define USE_FAN From 3a7b83ba934f4dc2bf1c2df9495ba048af75fd08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jan 2026 07:16:07 -1000 Subject: [PATCH 21/23] [wifi] Fix scan flag race condition causing reconnect failure on ESP8266/LibreTiny (#13514) --- esphome/components/wifi/wifi_component_esp8266.cpp | 4 ++++ esphome/components/wifi/wifi_component_libretiny.cpp | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 6fb5dd5769..4c204f7cf3 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -698,6 +698,10 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { if (!this->wifi_mode_(true, {})) return false; + // Reset scan_done_ before starting new scan to prevent stale flag from previous scan + // (e.g., roaming scan completed just before unexpected disconnect) + this->scan_done_ = false; + struct scan_config config {}; memset(&config, 0, sizeof(config)); config.ssid = nullptr; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index cc9f4ec193..20cd32fa8f 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -649,6 +649,10 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { if (!this->wifi_mode_(true, {})) return false; + // Reset scan_done_ before starting new scan to prevent stale flag from previous scan + // (e.g., roaming scan completed just before unexpected disconnect) + this->scan_done_ = false; + // need to use WiFi because of WiFiScanClass allocations :( int16_t err = WiFi.scanNetworks(true, true, passive, 200); if (err != WIFI_SCAN_RUNNING) { From 214ce95cf3d05a19413d715566b5dce5165863f4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 25 Jan 2026 12:22:18 -0500 Subject: [PATCH 22/23] Bump version to 2026.1.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 20582c14a6..7fcf0f92f1 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.1.1 +PROJECT_NUMBER = 2026.1.2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 0c6a46e233..36adcbf500 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.1.1" +__version__ = "2026.1.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 4099e944d62c842306a318e959a1dc9c7180716b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jan 2026 17:22:00 -1000 Subject: [PATCH 23/23] tweak --- esphome/components/wifi/wifi_component_esp_idf.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index b156944222..a32232a758 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -840,7 +840,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #ifdef USE_ESP32_HOSTED // getting records one at a time fails on P4 with hosted esp32 WiFi coprocessor // Presumably an upstream bug, work-around by getting all records at once - auto records = std::make_unique(number); + // Use stack buffer (3904 bytes / ~80 bytes per record = ~48 records) with heap fallback + static constexpr size_t SCAN_RECORD_STACK_COUNT = 3904 / sizeof(wifi_ap_record_t); + SmallBufferWithHeapFallback records(number); err = esp_wifi_scan_get_ap_records(&number, records.get()); if (err != ESP_OK) { esp_wifi_clear_ap_list(); @@ -848,7 +850,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { return; } for (uint16_t i = 0; i < number; i++) { - wifi_ap_record_t &record = records[i]; + wifi_ap_record_t &record = records.get()[i]; #else // Process one record at a time to avoid large buffer allocation for (uint16_t i = 0; i < number; i++) {