From 6dafc5137e074fe09484fa7df821d125e117ed41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Dec 2025 21:24:08 -0600 Subject: [PATCH 1/7] [esp32] Place FreeRTOS functions in flash by default (prep for IDF 6.0) (#12182) --- esphome/components/esp32/__init__.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 35ef76634b1..c49fc89fbd5 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -584,6 +584,7 @@ CONF_DISABLE_LIBC_LOCKS_IN_IRAM = "disable_libc_locks_in_iram" CONF_DISABLE_VFS_SUPPORT_TERMIOS = "disable_vfs_support_termios" CONF_DISABLE_VFS_SUPPORT_SELECT = "disable_vfs_support_select" CONF_DISABLE_VFS_SUPPORT_DIR = "disable_vfs_support_dir" +CONF_FREERTOS_IN_IRAM = "freertos_in_iram" CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size" # VFS requirement tracking @@ -677,6 +678,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_VFS_SUPPORT_SELECT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, + cv.Optional(CONF_FREERTOS_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean, cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 @@ -1003,6 +1005,22 @@ async def to_code(config): # Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000) + # Place non-ISR FreeRTOS functions into flash instead of IRAM + # This saves up to 8KB of IRAM. ISR-safe functions (FromISR variants) stay in IRAM. + # In ESP-IDF 6.0 this becomes the default and CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH + # is removed (replaced by CONFIG_FREERTOS_IN_IRAM to restore old behavior). + # We enable this now to match IDF 6.0 behavior and catch any issues early. + # Users can set freertos_in_iram: true as an escape hatch if they encounter problems + # with code that incorrectly calls FreeRTOS functions from ISRs with cache disabled. + if conf[CONF_ADVANCED][CONF_FREERTOS_IN_IRAM]: + # IDF 5.x: don't set the flash option (keeps functions in IRAM) + # IDF 6.0+: will need CONFIG_FREERTOS_IN_IRAM=y to restore IRAM placement + add_idf_sdkconfig_option("CONFIG_FREERTOS_IN_IRAM", True) + else: + # IDF 5.x: explicitly place functions in flash + # IDF 6.0+: this is the default, option no longer exists + add_idf_sdkconfig_option("CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH", True) + # Setup watchdog add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) From 69438031761225923e6e31546b18c856cec3864d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Dec 2025 21:26:13 -0600 Subject: [PATCH 2/7] [cover] Store cover state strings in flash on ESP8266 (#12196) --- esphome/components/cover/cover.cpp | 22 ++++++++++---------- esphome/components/cover/cover.h | 3 ++- esphome/components/web_server/web_server.cpp | 10 +++++---- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 8f735982f16..feac9823b97 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -13,25 +13,25 @@ static const char *const TAG = "cover"; const float COVER_OPEN = 1.0f; const float COVER_CLOSED = 0.0f; -const char *cover_command_to_str(float pos) { +const LogString *cover_command_to_str(float pos) { if (pos == COVER_OPEN) { - return "OPEN"; + return LOG_STR("OPEN"); } else if (pos == COVER_CLOSED) { - return "CLOSE"; + return LOG_STR("CLOSE"); } else { - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } -const char *cover_operation_to_str(CoverOperation op) { +const LogString *cover_operation_to_str(CoverOperation op) { switch (op) { case COVER_OPERATION_IDLE: - return "IDLE"; + return LOG_STR("IDLE"); case COVER_OPERATION_OPENING: - return "OPENING"; + return LOG_STR("OPENING"); case COVER_OPERATION_CLOSING: - return "CLOSING"; + return LOG_STR("CLOSING"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -87,7 +87,7 @@ void CoverCall::perform() { if (traits.get_supports_position()) { ESP_LOGD(TAG, " Position: %.0f%%", *this->position_ * 100.0f); } else { - ESP_LOGD(TAG, " Command: %s", cover_command_to_str(*this->position_)); + ESP_LOGD(TAG, " Command: %s", LOG_STR_ARG(cover_command_to_str(*this->position_))); } } if (this->tilt_.has_value()) { @@ -169,7 +169,7 @@ void Cover::publish_state(bool save) { if (traits.get_supports_tilt()) { ESP_LOGD(TAG, " Tilt: %.0f%%", this->tilt * 100.0f); } - ESP_LOGD(TAG, " Current Operation: %s", cover_operation_to_str(this->current_operation)); + ESP_LOGD(TAG, " Current Operation: %s", LOG_STR_ARG(cover_operation_to_str(this->current_operation))); this->state_callback_.call(); #if defined(USE_COVER) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 6c69c05e710..d8c45ab2bda 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/preferences.h" #include "cover_traits.h" @@ -86,7 +87,7 @@ enum CoverOperation : uint8_t { COVER_OPERATION_CLOSING, }; -const char *cover_operation_to_str(CoverOperation op); +const LogString *cover_operation_to_str(CoverOperation op); /** Base class for all cover devices. * diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index bc48793ba2f..c752c00899d 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -41,6 +41,10 @@ namespace web_server { static const char *const TAG = "web_server"; +// Longest: HORIZONTAL (10 chars + null terminator, rounded up) +static constexpr size_t PSTR_LOCAL_SIZE = 16; +#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1) + #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS static const char *const HEADER_PNA_NAME = "Private-Network-Access-Name"; static const char *const HEADER_PNA_ID = "Private-Network-Access-ID"; @@ -908,7 +912,8 @@ std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - root["current_operation"] = cover::cover_operation_to_str(obj->current_operation); + char buf[PSTR_LOCAL_SIZE]; + root["current_operation"] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root["position"] = obj->position; @@ -1272,9 +1277,6 @@ std::string WebServer::select_json(select::Select *obj, const char *value, JsonD } #endif -// Longest: HORIZONTAL -#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), 15) - #ifdef USE_CLIMATE void WebServer::on_climate_update(climate::Climate *obj) { if (!this->include_internal_ && obj->is_internal()) From 2903a4aa92fa8c81ba79ae5eb96a484de4457da6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:41:34 -0500 Subject: [PATCH 3/7] [ota] Use ESP-IDF OTA backend for all ESP32 builds (#12244) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- .../components/esphome/ota/ota_esphome.cpp | 1 - .../http_request/ota/ota_http_request.cpp | 1 - esphome/components/ota/__init__.py | 9 ++- .../ota/ota_backend_arduino_esp32.cpp | 72 ------------------- .../ota/ota_backend_arduino_esp32.h | 27 ------- .../components/ota/ota_backend_esp_idf.cpp | 4 +- esphome/components/ota/ota_backend_esp_idf.h | 4 +- 7 files changed, 8 insertions(+), 110 deletions(-) delete mode 100644 esphome/components/ota/ota_backend_arduino_esp32.cpp delete mode 100644 esphome/components/ota/ota_backend_arduino_esp32.h diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 852a50cc228..6cfd5435531 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -10,7 +10,6 @@ #endif #include "esphome/components/network/util.h" #include "esphome/components/ota/ota_backend.h" -#include "esphome/components/ota/ota_backend_arduino_esp32.h" #include "esphome/components/ota/ota_backend_arduino_esp8266.h" #include "esphome/components/ota/ota_backend_arduino_libretiny.h" #include "esphome/components/ota/ota_backend_arduino_rp2040.h" diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 4d9e868c74c..4552fcc9df2 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -7,7 +7,6 @@ #include "esphome/components/md5/md5.h" #include "esphome/components/watchdog/watchdog.h" #include "esphome/components/ota/ota_backend.h" -#include "esphome/components/ota/ota_backend_arduino_esp32.h" #include "esphome/components/ota/ota_backend_arduino_esp8266.h" #include "esphome/components/ota/ota_backend_arduino_rp2040.h" #include "esphome/components/ota/ota_backend_esp_idf.h" diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index eec39668db6..be1b6da2410 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -87,9 +87,6 @@ BASE_OTA_SCHEMA = cv.Schema( async def to_code(config): cg.add_define("USE_OTA") - if CORE.is_esp32 and CORE.using_arduino: - cg.add_library("Update", None) - if CORE.is_rp2040 and CORE.using_arduino: cg.add_library("Updater", None) @@ -127,8 +124,10 @@ async def ota_to_code(var, config): FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "ota_backend_arduino_esp32.cpp": {PlatformFramework.ESP32_ARDUINO}, - "ota_backend_esp_idf.cpp": {PlatformFramework.ESP32_IDF}, + "ota_backend_esp_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, "ota_backend_arduino_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "ota_backend_arduino_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, "ota_backend_arduino_libretiny.cpp": { diff --git a/esphome/components/ota/ota_backend_arduino_esp32.cpp b/esphome/components/ota/ota_backend_arduino_esp32.cpp deleted file mode 100644 index 5c6230f2ceb..00000000000 --- a/esphome/components/ota/ota_backend_arduino_esp32.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#ifdef USE_ESP32_FRAMEWORK_ARDUINO -#include "esphome/core/defines.h" -#include "esphome/core/log.h" - -#include "ota_backend.h" -#include "ota_backend_arduino_esp32.h" - -#include - -namespace esphome { -namespace ota { - -static const char *const TAG = "ota.arduino_esp32"; - -std::unique_ptr make_ota_backend() { return make_unique(); } - -OTAResponseTypes ArduinoESP32OTABackend::begin(size_t image_size) { - // Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA - // where the exact firmware size is unknown due to multipart encoding - if (image_size == 0) { - image_size = UPDATE_SIZE_UNKNOWN; - } - bool ret = Update.begin(image_size, U_FLASH); - if (ret) { - return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - if (error == UPDATE_ERROR_SIZE) - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; - - ESP_LOGE(TAG, "Begin error: %d", error); - - return OTA_RESPONSE_ERROR_UNKNOWN; -} - -void ArduinoESP32OTABackend::set_update_md5(const char *md5) { - Update.setMD5(md5); - this->md5_set_ = true; -} - -OTAResponseTypes ArduinoESP32OTABackend::write(uint8_t *data, size_t len) { - size_t written = Update.write(data, len); - if (written == len) { - return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - ESP_LOGE(TAG, "Write error: %d", error); - - return OTA_RESPONSE_ERROR_WRITING_FLASH; -} - -OTAResponseTypes ArduinoESP32OTABackend::end() { - // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 - // This matches the behavior of the old web_server OTA implementation - if (Update.end(!this->md5_set_)) { - return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - ESP_LOGE(TAG, "End error: %d", error); - - return OTA_RESPONSE_ERROR_UPDATE_END; -} - -void ArduinoESP32OTABackend::abort() { Update.abort(); } - -} // namespace ota -} // namespace esphome - -#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/esphome/components/ota/ota_backend_arduino_esp32.h b/esphome/components/ota/ota_backend_arduino_esp32.h deleted file mode 100644 index 6615cf3dc0f..00000000000 --- a/esphome/components/ota/ota_backend_arduino_esp32.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#ifdef USE_ESP32_FRAMEWORK_ARDUINO -#include "ota_backend.h" - -#include "esphome/core/defines.h" -#include "esphome/core/helpers.h" - -namespace esphome { -namespace ota { - -class ArduinoESP32OTABackend : public OTABackend { - public: - OTAResponseTypes begin(size_t image_size) override; - void set_update_md5(const char *md5) override; - OTAResponseTypes write(uint8_t *data, size_t len) override; - OTAResponseTypes end() override; - void abort() override; - bool supports_compression() override { return false; } - - private: - bool md5_set_{false}; -}; - -} // namespace ota -} // namespace esphome - -#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 97aae09bd9f..f278c3741f3 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "ota_backend_esp_idf.h" #include "esphome/components/md5/md5.h" @@ -107,4 +107,4 @@ void IDFOTABackend::abort() { } // namespace ota } // namespace esphome -#endif +#endif // USE_ESP32 diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 6e939821311..764010e6142 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -1,5 +1,5 @@ #pragma once -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "ota_backend.h" #include "esphome/components/md5/md5.h" @@ -29,4 +29,4 @@ class IDFOTABackend : public OTABackend { } // namespace ota } // namespace esphome -#endif +#endif // USE_ESP32 From c45cd44bb897d6926cbf97d3e615a84ee76ee28a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:49:25 -0600 Subject: [PATCH 4/7] Bump github/codeql-action from 4.31.5 to 4.31.6 (#12234) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d10c8bf2672..33f587a7487 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 + uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 + uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 with: category: "/language:${{matrix.language}}" From 82a06c697e705199073568e5952dc75fe5c91460 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Dec 2025 21:57:41 -0600 Subject: [PATCH 5/7] [esp32] Place ring buffer functions in flash by default (prep for IDF 6.0) (#12184) --- esphome/components/esp32/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index c49fc89fbd5..14db25fd464 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -585,6 +585,7 @@ CONF_DISABLE_VFS_SUPPORT_TERMIOS = "disable_vfs_support_termios" CONF_DISABLE_VFS_SUPPORT_SELECT = "disable_vfs_support_select" CONF_DISABLE_VFS_SUPPORT_DIR = "disable_vfs_support_dir" CONF_FREERTOS_IN_IRAM = "freertos_in_iram" +CONF_RINGBUF_IN_IRAM = "ringbuf_in_iram" CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size" # VFS requirement tracking @@ -679,6 +680,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_VFS_SUPPORT_SELECT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, cv.Optional(CONF_FREERTOS_IN_IRAM, default=False): cv.boolean, + cv.Optional(CONF_RINGBUF_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean, cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 @@ -1021,6 +1023,17 @@ async def to_code(config): # IDF 6.0+: this is the default, option no longer exists add_idf_sdkconfig_option("CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH", True) + # Place ring buffer functions into flash instead of IRAM by default + # This saves IRAM. In ESP-IDF 6.0 flash placement becomes the default. + # Users can set ringbuf_in_iram: true as an escape hatch if they encounter issues. + if conf[CONF_ADVANCED][CONF_RINGBUF_IN_IRAM]: + # User requests ring buffer in IRAM + # IDF 6.0+: will need CONFIG_RINGBUF_PLACE_ISR_FUNCTIONS_INTO_FLASH=n + add_idf_sdkconfig_option("CONFIG_RINGBUF_PLACE_ISR_FUNCTIONS_INTO_FLASH", False) + else: + # Place in flash to save IRAM (default) + add_idf_sdkconfig_option("CONFIG_RINGBUF_PLACE_FUNCTIONS_INTO_FLASH", True) + # Setup watchdog add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) From 9a0731437a94c3fb9122249fa7e9154a2a3d2c28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:11:33 -0600 Subject: [PATCH 6/7] Bump aioesphomeapi from 42.9.0 to 42.10.0 (#12245) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 45ae3d5925c..5d824a68599 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==42.9.0 +aioesphomeapi==42.10.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import From 10ddebc737644c44124a9164953dcdaa190c07ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Dec 2025 22:17:31 -0600 Subject: [PATCH 7/7] [text_sensor] Avoid duplicate string storage when no filters configured (#12205) --- .../components/text_sensor/text_sensor.cpp | 12 +- esphome/components/text_sensor/text_sensor.h | 5 +- .../fixtures/text_sensor_raw_state.yaml | 54 +++++++++ .../integration/test_text_sensor_raw_state.py | 114 ++++++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 tests/integration/fixtures/text_sensor_raw_state.yaml create mode 100644 tests/integration/test_text_sensor_raw_state.py diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index a7bcf199672..d984e78b2af 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -25,7 +25,11 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text } void TextSensor::publish_state(const std::string &state) { - this->raw_state = state; + // Only store raw_state_ separately when filters exist + // When no filters, raw_state == state, so we avoid the duplicate storage + if (this->filter_list_ != nullptr) { + this->raw_state_ = state; + } if (this->raw_callback_) { this->raw_callback_->call(state); } @@ -80,7 +84,11 @@ void TextSensor::add_on_raw_state_callback(std::function call } std::string TextSensor::get_state() const { return this->state; } -std::string TextSensor::get_raw_state() const { return this->raw_state; } +std::string TextSensor::get_raw_state() const { + // When no filters exist, raw_state == state, so return state to avoid + // requiring separate storage + return this->filter_list_ != nullptr ? this->raw_state_ : this->state; +} void TextSensor::internal_send_state_to_frontend(const std::string &state) { this->state = state; this->set_has_state(true); diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index db2e857ae37..fcfbed2fbc0 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -50,7 +50,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { void add_on_raw_state_callback(std::function callback); std::string state; - std::string raw_state; // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -63,6 +62,10 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { CallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. + + /// Raw state (before filters). Only populated when filters are configured. + /// When no filters exist, get_raw_state() returns state directly. + std::string raw_state_; }; } // namespace text_sensor diff --git a/tests/integration/fixtures/text_sensor_raw_state.yaml b/tests/integration/fixtures/text_sensor_raw_state.yaml new file mode 100644 index 00000000000..03aece0a04e --- /dev/null +++ b/tests/integration/fixtures/text_sensor_raw_state.yaml @@ -0,0 +1,54 @@ +esphome: + name: test-text-sensor-raw-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Text sensor WITHOUT filters - get_raw_state() should return same as state +text_sensor: + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + + # Text sensor WITH filter - get_raw_state() should return original value + - platform: template + name: "With Filter Sensor" + id: with_filter_sensor + filters: + - to_upper + +# Button to publish values and log raw_state vs state +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - text_sensor.template.publish: + id: no_filter_sensor + state: "hello world" + - delay: 50ms + # Log both state and get_raw_state() to verify they match + - logger.log: + format: "NO_FILTER: state='%s' raw_state='%s'" + args: + - id(no_filter_sensor).state.c_str() + - id(no_filter_sensor).get_raw_state().c_str() + + - platform: template + name: "Test With Filter Button" + id: test_with_filter_button + on_press: + - text_sensor.template.publish: + id: with_filter_sensor + state: "hello world" + - delay: 50ms + # Log both state and get_raw_state() to verify filter works + # state should be "HELLO WORLD" (filtered), raw_state should be "hello world" (original) + - logger.log: + format: "WITH_FILTER: state='%s' raw_state='%s'" + args: + - id(with_filter_sensor).state.c_str() + - id(with_filter_sensor).get_raw_state().c_str() diff --git a/tests/integration/test_text_sensor_raw_state.py b/tests/integration/test_text_sensor_raw_state.py new file mode 100644 index 00000000000..a53ec8c963d --- /dev/null +++ b/tests/integration/test_text_sensor_raw_state.py @@ -0,0 +1,114 @@ +"""Integration test for TextSensor get_raw_state() functionality. + +This tests the optimization in PR #12205 where raw_state is only stored +when filters are configured. When no filters exist, get_raw_state() should +return state directly. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_text_sensor_raw_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that get_raw_state() works correctly with and without filters. + + Without filters: get_raw_state() should return the same value as state + With filters: get_raw_state() should return the original (unfiltered) value + """ + loop = asyncio.get_running_loop() + + # Futures to track log messages + no_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() + with_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() + + # Patterns to match log output + # NO_FILTER: state='hello world' raw_state='hello world' + no_filter_pattern = re.compile(r"NO_FILTER: state='([^']*)' raw_state='([^']*)'") + # WITH_FILTER: state='HELLO WORLD' raw_state='hello world' + with_filter_pattern = re.compile( + r"WITH_FILTER: state='([^']*)' raw_state='([^']*)'" + ) + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not no_filter_future.done(): + match = no_filter_pattern.search(line) + if match: + no_filter_future.set_result((match.group(1), match.group(2))) + + if not with_filter_future.done(): + match = with_filter_pattern.search(line) + if match: + with_filter_future.set_result((match.group(1), match.group(2))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-text-sensor-raw-state" + + # Get entities to find our buttons + entities, _ = await client.list_entities_services() + + # Find the test buttons + no_filter_button = next( + (e for e in entities if "test_no_filter_button" in e.object_id.lower()), + None, + ) + assert no_filter_button is not None, "Test No Filter Button not found" + + with_filter_button = next( + (e for e in entities if "test_with_filter_button" in e.object_id.lower()), + None, + ) + assert with_filter_button is not None, "Test With Filter Button not found" + + # Test 1: Text sensor without filters + # get_raw_state() should return the same as state + client.button_command(no_filter_button.key) + + try: + state, raw_state = await asyncio.wait_for(no_filter_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for NO_FILTER log message") + + assert state == "hello world", f"Expected state='hello world', got '{state}'" + assert raw_state == "hello world", ( + f"Expected raw_state='hello world', got '{raw_state}'" + ) + assert state == raw_state, ( + f"Without filters, state and raw_state should be equal. " + f"state='{state}', raw_state='{raw_state}'" + ) + + # Test 2: Text sensor with to_upper filter + # state should be filtered (uppercase), raw_state should be original + client.button_command(with_filter_button.key) + + try: + state, raw_state = await asyncio.wait_for(with_filter_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for WITH_FILTER log message") + + assert state == "HELLO WORLD", f"Expected state='HELLO WORLD', got '{state}'" + assert raw_state == "hello world", ( + f"Expected raw_state='hello world', got '{raw_state}'" + ) + assert state != raw_state, ( + f"With filters, state and raw_state should differ. " + f"state='{state}', raw_state='{raw_state}'" + )