From da947d060f9e14b9f8b241290e80b5cf2d405aae Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:20:24 -0500 Subject: [PATCH 01/12] [wizard] Use API encryption key instead of deprecated password (#13634) Co-authored-by: Claude Opus 4.5 --- esphome/wizard.py | 66 +++++++++++++++++--------- tests/unit_tests/test_wizard.py | 84 +++++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 25 deletions(-) diff --git a/esphome/wizard.py b/esphome/wizard.py index d77450b04dc..f5e8a1e4629 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,5 +1,7 @@ +import base64 from pathlib import Path import random +import secrets import string from typing import Literal, NotRequired, TypedDict, Unpack import unicodedata @@ -116,7 +118,6 @@ class WizardFileKwargs(TypedDict): board: str ssid: NotRequired[str] psk: NotRequired[str] - password: NotRequired[str] ota_password: NotRequired[str] api_encryption_key: NotRequired[str] friendly_name: NotRequired[str] @@ -144,9 +145,7 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str: config += API_CONFIG - # Configure API - if "password" in kwargs: - config += f' password: "{kwargs["password"]}"\n' + # Configure API encryption if "api_encryption_key" in kwargs: config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n' @@ -155,8 +154,6 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str: config += " - platform: esphome\n" if "ota_password" in kwargs: config += f' password: "{kwargs["ota_password"]}"' - elif "password" in kwargs: - config += f' password: "{kwargs["password"]}"' # Configuring wifi config += "\n\nwifi:\n" @@ -205,7 +202,6 @@ class WizardWriteKwargs(TypedDict): platform: NotRequired[str] ssid: NotRequired[str] psk: NotRequired[str] - password: NotRequired[str] ota_password: NotRequired[str] api_encryption_key: NotRequired[str] friendly_name: NotRequired[str] @@ -232,7 +228,7 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: else: # "basic" board = kwargs["board"] - for key in ("ssid", "psk", "password", "ota_password"): + for key in ("ssid", "psk", "ota_password"): if key in kwargs: kwargs[key] = sanitize_double_quotes(kwargs[key]) if "platform" not in kwargs: @@ -522,26 +518,54 @@ def wizard(path: Path) -> int: "Almost there! ESPHome can automatically upload custom firmwares over WiFi " "(over the air) and integrates into Home Assistant with a native API." ) + safe_print() + sleep(0.5) + + # Generate encryption key (32 bytes, base64 encoded) for secure API communication + noise_psk = secrets.token_bytes(32) + api_encryption_key = base64.b64encode(noise_psk).decode() + safe_print( - f"This can be insecure if you do not trust the WiFi network. Do you want to set a {color(AnsiFore.GREEN, 'password')} for connecting to this ESP?" + "For secure API communication, I've generated a random encryption key." + ) + safe_print() + safe_print( + f"Your {color(AnsiFore.GREEN, 'API encryption key')} is: " + f"{color(AnsiFore.BOLD_WHITE, api_encryption_key)}" + ) + safe_print() + safe_print("You'll need this key when adding the device to Home Assistant.") + sleep(1) + + safe_print() + safe_print( + f"Do you want to set a {color(AnsiFore.GREEN, 'password')} for OTA updates? " + "This can be insecure if you do not trust the WiFi network." ) safe_print() sleep(0.25) safe_print("Press ENTER for no password") - password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): ")) + ota_password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): ")) else: - ssid, password, psk = "", "", "" + ssid, psk = "", "" + api_encryption_key = None + ota_password = "" - if not wizard_write( - path=path, - name=name, - platform=platform, - board=board, - ssid=ssid, - psk=psk, - password=password, - type="basic", - ): + kwargs = { + "path": path, + "name": name, + "platform": platform, + "board": board, + "ssid": ssid, + "psk": psk, + "type": "basic", + } + if api_encryption_key: + kwargs["api_encryption_key"] = api_encryption_key + if ota_password: + kwargs["ota_password"] = ota_password + + if not wizard_write(**kwargs): return 1 safe_print() diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index fd53a0b0b70..eb44c1c20f4 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -25,7 +25,6 @@ def default_config() -> dict[str, Any]: "board": "esp01_1m", "ssid": "test_ssid", "psk": "test_psk", - "password": "", } @@ -37,7 +36,7 @@ def wizard_answers() -> list[str]: "nodemcuv2", # board "SSID", # ssid "psk", # wifi password - "ota_pass", # ota password + "", # ota password (empty for no password) ] @@ -105,16 +104,35 @@ def test_config_file_should_include_ota_when_password_set( default_config: dict[str, Any], ): """ - The Over-The-Air update should be enabled when a password is set + The Over-The-Air update should be enabled when an OTA password is set """ # Given - default_config["password"] = "foo" + default_config["ota_password"] = "foo" # When config = wz.wizard_file(**default_config) # Then assert "ota:" in config + assert 'password: "foo"' in config + + +def test_config_file_should_include_api_encryption_key( + default_config: dict[str, Any], +): + """ + The API encryption key should be included when set + """ + # Given + default_config["api_encryption_key"] = "test_encryption_key_base64==" + + # When + config = wz.wizard_file(**default_config) + + # Then + assert "api:" in config + assert "encryption:" in config + assert 'key: "test_encryption_key_base64=="' in config def test_wizard_write_sets_platform( @@ -556,3 +574,61 @@ def test_wizard_write_protects_existing_config( # Then assert result is False # Should return False when file exists assert config_file.read_text() == original_content + + +def test_wizard_accepts_ota_password( + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): + """ + The wizard should pass ota_password to wizard_write when the user provides one + """ + + # Given + wizard_answers[5] = "my_ota_password" # Set OTA password + config_file = tmp_path / "test.yaml" + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + wizard_write_mock = MagicMock(return_value=True) + monkeypatch.setattr(wz, "wizard_write", wizard_write_mock) + + # When + retval = wz.wizard(config_file) + + # Then + assert retval == 0 + call_kwargs = wizard_write_mock.call_args.kwargs + assert "ota_password" in call_kwargs + assert call_kwargs["ota_password"] == "my_ota_password" + + +def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch): + """ + The wizard should handle rpipico board which doesn't support WiFi. + This tests the branch where api_encryption_key is None. + """ + + # Given + wizard_answers_rp2040 = [ + "test-node", # Name of the node + "RP2040", # platform + "rpipico", # board (no WiFi support) + ] + config_file = tmp_path / "test.yaml" + input_mock = MagicMock(side_effect=wizard_answers_rp2040) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + wizard_write_mock = MagicMock(return_value=True) + monkeypatch.setattr(wz, "wizard_write", wizard_write_mock) + + # When + retval = wz.wizard(config_file) + + # Then + assert retval == 0 + call_kwargs = wizard_write_mock.call_args.kwargs + # rpipico doesn't support WiFi, so no api_encryption_key or ota_password + assert "api_encryption_key" not in call_kwargs + assert "ota_password" not in call_kwargs From a6543d32bd23ae18f2ae49cd6023281e2c912aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kundr=C3=A1t?= Date: Tue, 3 Feb 2026 02:15:18 +0100 Subject: [PATCH 02/12] [sx126x] fix maximal payload_length (#13723) --- esphome/components/sx126x/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index ed878ed0d43..413eb139d65 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -213,7 +213,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), cv.Optional(CONF_PA_POWER, default=17): cv.int_range(min=-3, max=22), cv.Optional(CONF_PA_RAMP, default="40us"): cv.enum(RAMP), - cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(min=0, max=256), + cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(min=0, max=255), cv.Optional(CONF_PREAMBLE_DETECT, default=2): cv.int_range(min=0, max=4), cv.Optional(CONF_PREAMBLE_SIZE, default=8): cv.int_range(min=1, max=65535), cv.Required(CONF_RST_PIN): pins.gpio_output_pin_schema, From 26e4cda610d32c21c980c1fe96eda4b70551d8b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Feb 2026 02:25:54 +0100 Subject: [PATCH 03/12] [logger] Use vsnprintf_P directly for ESP8266 flash format strings (#13716) --- esphome/components/logger/logger.cpp | 51 +++++++--------------------- esphome/components/logger/logger.h | 45 ++++++++++++++---------- 2 files changed, 40 insertions(+), 56 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 3a726d40461..25243ff3f6a 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -128,22 +128,7 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch // Note: USE_STORE_LOG_STR_IN_FLASH is only defined for ESP8266. // // This function handles format strings stored in flash memory (PROGMEM) to save RAM. -// The buffer is used in a special way to avoid allocating extra memory: -// -// Memory layout during execution: -// Step 1: Copy format string from flash to buffer -// tx_buffer_: [format_string][null][.....................] -// tx_buffer_at_: ------------------^ -// msg_start: saved here -----------^ -// -// Step 2: format_log_to_buffer_with_terminator_ reads format string from beginning -// and writes formatted output starting at msg_start position -// tx_buffer_: [format_string][null][formatted_message][null] -// tx_buffer_at_: -------------------------------------^ -// -// Step 3: Output the formatted message (starting at msg_start) -// write_msg_ and callbacks receive: this->tx_buffer_ + msg_start -// which points to: [formatted_message][null] +// Uses vsnprintf_P to read the format string directly from flash without copying to RAM. // void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, va_list args) { // NOLINT @@ -153,35 +138,25 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas RecursionGuard guard(global_recursion_guard_); this->tx_buffer_at_ = 0; - // Copy format string from progmem - auto *format_pgm_p = reinterpret_cast(format); - char ch = '.'; - while (this->tx_buffer_at_ < this->tx_buffer_size_ && ch != '\0') { - this->tx_buffer_[this->tx_buffer_at_++] = ch = (char) progmem_read_byte(format_pgm_p++); - } + // Write header, format body directly from flash, and write footer + this->write_header_to_buffer_(level, tag, line, nullptr, this->tx_buffer_, &this->tx_buffer_at_, + this->tx_buffer_size_); + this->format_body_to_buffer_P_(this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_, + reinterpret_cast(format), args); + this->write_footer_to_buffer_(this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_); - // Buffer full from copying format - RAII guard handles cleanup on return - if (this->tx_buffer_at_ >= this->tx_buffer_size_) { - return; - } - - // Save the offset before calling format_log_to_buffer_with_terminator_ - // since it will increment tx_buffer_at_ to the end of the formatted string - uint16_t msg_start = this->tx_buffer_at_; - this->format_log_to_buffer_with_terminator_(level, tag, line, this->tx_buffer_, args, this->tx_buffer_, - &this->tx_buffer_at_, this->tx_buffer_size_); - - uint16_t msg_length = - this->tx_buffer_at_ - msg_start; // Don't subtract 1 - tx_buffer_at_ is already at the null terminator position + // Ensure null termination + uint16_t null_pos = this->tx_buffer_at_ >= this->tx_buffer_size_ ? this->tx_buffer_size_ - 1 : this->tx_buffer_at_; + this->tx_buffer_[null_pos] = '\0'; // Listeners get message first (before console write) #ifdef USE_LOG_LISTENERS for (auto *listener : this->log_listeners_) - listener->on_log(level, tag, this->tx_buffer_ + msg_start, msg_length); + listener->on_log(level, tag, this->tx_buffer_, this->tx_buffer_at_); #endif - // Write to console starting at the msg_start - this->write_tx_buffer_to_console_(msg_start, &msg_length); + // Write to console + this->write_tx_buffer_to_console_(); } #endif // USE_STORE_LOG_STR_IN_FLASH diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index fe9cab4993f..40ac9a38aa7 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -597,31 +597,40 @@ class Logger : public Component { *buffer_at = pos; } + // Helper to process vsnprintf return value and strip trailing newlines. + // Updates buffer_at with the formatted length, handling truncation: + // - When vsnprintf truncates (ret >= remaining), it writes (remaining - 1) chars + null terminator + // - When it doesn't truncate (ret < remaining), it writes ret chars + null terminator + __attribute__((always_inline)) static inline void process_vsnprintf_result(const char *buffer, uint16_t *buffer_at, + uint16_t remaining, int ret) { + if (ret < 0) + return; // Encoding error, do not increment buffer_at + *buffer_at += (ret >= remaining) ? (remaining - 1) : static_cast(ret); + // Remove all trailing newlines right after formatting + while (*buffer_at > 0 && buffer[*buffer_at - 1] == '\n') + (*buffer_at)--; + } + inline void HOT format_body_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size, const char *format, va_list args) { - // Get remaining capacity in the buffer + // Check remaining capacity in the buffer if (*buffer_at >= buffer_size) return; const uint16_t remaining = buffer_size - *buffer_at; - - const int ret = vsnprintf(buffer + *buffer_at, remaining, format, args); - - if (ret < 0) { - return; // Encoding error, do not increment buffer_at - } - - // Update buffer_at with the formatted length (handle truncation) - // When vsnprintf truncates (ret >= remaining), it writes (remaining - 1) chars + null terminator - // When it doesn't truncate (ret < remaining), it writes ret chars + null terminator - uint16_t formatted_len = (ret >= remaining) ? (remaining - 1) : ret; - *buffer_at += formatted_len; - - // Remove all trailing newlines right after formatting - while (*buffer_at > 0 && buffer[*buffer_at - 1] == '\n') { - (*buffer_at)--; - } + process_vsnprintf_result(buffer, buffer_at, remaining, vsnprintf(buffer + *buffer_at, remaining, format, args)); } +#ifdef USE_STORE_LOG_STR_IN_FLASH + // ESP8266 variant that reads format string directly from flash using vsnprintf_P + inline void HOT format_body_to_buffer_P_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size, PGM_P format, + va_list args) { + if (*buffer_at >= buffer_size) + return; + const uint16_t remaining = buffer_size - *buffer_at; + process_vsnprintf_result(buffer, buffer_at, remaining, vsnprintf_P(buffer + *buffer_at, remaining, format, args)); + } +#endif + inline void HOT write_footer_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size) { static constexpr uint16_t RESET_COLOR_LEN = sizeof(ESPHOME_LOG_RESET_COLOR) - 1; this->write_body_to_buffer_(ESPHOME_LOG_RESET_COLOR, RESET_COLOR_LEN, buffer, buffer_at, buffer_size); From efecea9450462e3cb65d1c361e57aa5b9d0463ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:27:34 +0100 Subject: [PATCH 04/12] Bump github/codeql-action from 4.32.0 to 4.32.1 (#13726) 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 be761cee3d0..817ea1d2be0 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@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + uses: github/codeql-action/init@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1 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@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + uses: github/codeql-action/analyze@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1 with: category: "/language:${{matrix.language}}" From ccf5c1f7e9103a470f9167105ae266af760dd7e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Feb 2026 03:12:12 +0100 Subject: [PATCH 05/12] [esp32] Exclude additional unused IDF components (driver, dac, mcpwm, twai, openthread, ulp) (#13664) --- esphome/components/esp32/__init__.py | 6 ++++++ esphome/components/esp32_can/canbus.py | 5 +++++ esphome/components/esp32_dac/output.py | 8 +++++++- esphome/components/esp32_touch/__init__.py | 2 ++ esphome/components/openthread/__init__.py | 4 ++++ 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aed4ecad909..4c53b42e6f9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -124,10 +124,14 @@ COMPILER_OPTIMIZATIONS = { # - "sdmmc": driver -> esp_driver_sdmmc -> sdmmc dependency chain DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing + "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers "esp_adc", # ADC driver - only needed by adc component + "esp_driver_dac", # DAC driver - only needed by esp32_dac component "esp_driver_i2s", # I2S driver - only needed by i2s_audio component + "esp_driver_mcpwm", # MCPWM driver - ESPHome doesn't use motor control PWM "esp_driver_rmt", # RMT driver - only needed by remote_transmitter/receiver, neopixelbus "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch + "esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component "esp_eth", # Ethernet driver - only needed by ethernet component "esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality "esp_http_client", # HTTP client - only needed by http_request component @@ -138,9 +142,11 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage "mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation + "openthread", # Thread protocol - only needed by openthread component "perfmon", # Xtensa performance monitor - ESPHome has its own debug component "protocomm", # Protocol communication for provisioning - unused by ESPHome "spiffs", # SPIFFS filesystem - ESPHome doesn't use filesystem storage (IDF only) + "ulp", # ULP coprocessor - not currently used by any ESPHome component "unity", # Unit testing framework - ESPHome doesn't use IDF's testing "wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused "wifi_provisioning", # WiFi provisioning - ESPHome uses its own improv implementation diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 0768b355071..7245ba75133 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -15,6 +15,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S2, VARIANT_ESP32S3, get_esp32_variant, + include_builtin_idf_component, ) import esphome.config_validation as cv from esphome.const import ( @@ -121,6 +122,10 @@ def get_default_tx_enqueue_timeout(bit_rate): async def to_code(config): + # Legacy driver component provides driver/twai.h header + include_builtin_idf_component("driver") + # Also enable esp_driver_twai for future migration to new API + include_builtin_idf_component("esp_driver_twai") var = cg.new_Pvariable(config[CONF_ID]) await canbus.register_canbus(var, config) diff --git a/esphome/components/esp32_dac/output.py b/esphome/components/esp32_dac/output.py index daace596d31..7c63d7bd11c 100644 --- a/esphome/components/esp32_dac/output.py +++ b/esphome/components/esp32_dac/output.py @@ -1,7 +1,12 @@ from esphome import pins import esphome.codegen as cg from esphome.components import output -from esphome.components.esp32 import VARIANT_ESP32, VARIANT_ESP32S2, get_esp32_variant +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32S2, + get_esp32_variant, + include_builtin_idf_component, +) import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_NUMBER, CONF_PIN @@ -38,6 +43,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( async def to_code(config): + include_builtin_idf_component("esp_driver_dac") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index 6accb89c357..a02370a3434 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -269,6 +269,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): # Re-enable ESP-IDF's touch sensor driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_touch_sens") + # Legacy driver component provides driver/touch_sensor.h header + include_builtin_idf_component("driver") touch = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(touch, config) diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 26c05a0a862..89d335c574e 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -4,6 +4,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32H2, add_idf_sdkconfig_option, + include_builtin_idf_component, only_on_variant, require_vfs_select, ) @@ -172,6 +173,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): + # Re-enable openthread IDF component (excluded by default) + include_builtin_idf_component("openthread") + cg.add_define("USE_OPENTHREAD") # OpenThread SRP needs access to mDNS services after setup From ae71f07abb34b9b053eb5233f1ee39fd1936de7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Feb 2026 03:19:38 +0100 Subject: [PATCH 06/12] [http_request] Fix requests taking full timeout when response is already complete (#13649) --- .../update/esp32_hosted_update.cpp | 14 ++++- .../components/http_request/http_request.h | 55 ++++++++++++++----- .../http_request/http_request_arduino.cpp | 20 ++++++- .../http_request/http_request_idf.cpp | 9 +-- .../http_request/ota/ota_http_request.cpp | 6 +- 5 files changed, 78 insertions(+), 26 deletions(-) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index dac2b014256..a7d5f7e3d51 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -211,11 +211,14 @@ bool Esp32HostedUpdate::fetch_manifest_() { int read_or_error = container->read(buf, sizeof(buf)); App.feed_wdt(); yield(); - auto result = http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout); + auto result = + http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete()); if (result == http_request::HttpReadLoopResult::RETRY) continue; + // Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length, + // but this is defensive code in case chunked transfer encoding support is added in the future. if (result != http_request::HttpReadLoopResult::DATA) - break; // ERROR or TIMEOUT + break; // COMPLETE, ERROR, or TIMEOUT json_str.append(reinterpret_cast(buf), read_or_error); } container->end(); @@ -336,9 +339,14 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { App.feed_wdt(); yield(); - auto result = http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout); + auto result = + http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete()); if (result == http_request::HttpReadLoopResult::RETRY) continue; + // Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length, + // but this is defensive code in case chunked transfer encoding support is added in the future. + if (result == http_request::HttpReadLoopResult::COMPLETE) + break; if (result != http_request::HttpReadLoopResult::DATA) { if (result == http_request::HttpReadLoopResult::TIMEOUT) { ESP_LOGE(TAG, "Timeout reading firmware data"); diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 79098a6b729..c88360ca786 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -26,6 +26,7 @@ struct Header { enum HttpStatus { HTTP_STATUS_OK = 200, HTTP_STATUS_NO_CONTENT = 204, + HTTP_STATUS_RESET_CONTENT = 205, HTTP_STATUS_PARTIAL_CONTENT = 206, /* 3xx - Redirection */ @@ -126,19 +127,21 @@ struct HttpReadResult { /// Result of processing a non-blocking read with timeout (for manual loops) enum class HttpReadLoopResult : uint8_t { - DATA, ///< Data was read, process it - RETRY, ///< No data yet, already delayed, caller should continue loop - ERROR, ///< Read error, caller should exit loop - TIMEOUT, ///< Timeout waiting for data, caller should exit loop + DATA, ///< Data was read, process it + COMPLETE, ///< All content has been read, caller should exit loop + RETRY, ///< No data yet, already delayed, caller should continue loop + ERROR, ///< Read error, caller should exit loop + TIMEOUT, ///< Timeout waiting for data, caller should exit loop }; /// Process a read result with timeout tracking and delay handling /// @param bytes_read_or_error Return value from read() - positive for bytes read, negative for error /// @param last_data_time Time of last successful read, updated when data received /// @param timeout_ms Maximum time to wait for data -/// @return DATA if data received, RETRY if should continue loop, ERROR/TIMEOUT if should exit -inline HttpReadLoopResult http_read_loop_result(int bytes_read_or_error, uint32_t &last_data_time, - uint32_t timeout_ms) { +/// @param is_read_complete Whether all expected content has been read (from HttpContainer::is_read_complete()) +/// @return How the caller should proceed - see HttpReadLoopResult enum +inline HttpReadLoopResult http_read_loop_result(int bytes_read_or_error, uint32_t &last_data_time, uint32_t timeout_ms, + bool is_read_complete) { if (bytes_read_or_error > 0) { last_data_time = millis(); return HttpReadLoopResult::DATA; @@ -146,7 +149,10 @@ inline HttpReadLoopResult http_read_loop_result(int bytes_read_or_error, uint32_ if (bytes_read_or_error < 0) { return HttpReadLoopResult::ERROR; } - // bytes_read_or_error == 0: no data available yet + // bytes_read_or_error == 0: either "no data yet" or "all content read" + if (is_read_complete) { + return HttpReadLoopResult::COMPLETE; + } if (millis() - last_data_time >= timeout_ms) { return HttpReadLoopResult::TIMEOUT; } @@ -159,9 +165,9 @@ class HttpRequestComponent; class HttpContainer : public Parented { public: virtual ~HttpContainer() = default; - size_t content_length; - int status_code; - uint32_t duration_ms; + size_t content_length{0}; + int status_code{-1}; ///< -1 indicates no response received yet + uint32_t duration_ms{0}; /** * @brief Read data from the HTTP response body. @@ -194,9 +200,24 @@ class HttpContainer : public Parented { virtual void end() = 0; void set_secure(bool secure) { this->secure_ = secure; } + void set_chunked(bool chunked) { this->is_chunked_ = chunked; } size_t get_bytes_read() const { return this->bytes_read_; } + /// Check if all expected content has been read + /// For chunked responses, returns false (completion detected via read() returning error/EOF) + bool is_read_complete() const { + // Per RFC 9112, these responses have no body: + // - 1xx (Informational), 204 No Content, 205 Reset Content, 304 Not Modified + if ((this->status_code >= 100 && this->status_code < 200) || this->status_code == HTTP_STATUS_NO_CONTENT || + this->status_code == HTTP_STATUS_RESET_CONTENT || this->status_code == HTTP_STATUS_NOT_MODIFIED) { + return true; + } + // For non-chunked responses, complete when bytes_read >= content_length + // This handles both Content-Length: 0 and Content-Length: N cases + return !this->is_chunked_ && this->bytes_read_ >= this->content_length; + } + /** * @brief Get response headers. * @@ -209,6 +230,7 @@ class HttpContainer : public Parented { protected: size_t bytes_read_{0}; bool secure_{false}; + bool is_chunked_{false}; ///< True if response uses chunked transfer encoding std::map> response_headers_{}; }; @@ -219,7 +241,7 @@ class HttpContainer : public Parented { /// @param total_size Total bytes to read /// @param chunk_size Maximum bytes per read call /// @param timeout_ms Read timeout in milliseconds -/// @return HttpReadResult with status and error_code on failure +/// @return HttpReadResult with status and error_code on failure; use container->get_bytes_read() for total bytes read inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer, size_t total_size, size_t chunk_size, uint32_t timeout_ms) { size_t read_index = 0; @@ -231,9 +253,11 @@ inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer, App.feed_wdt(); yield(); - auto result = http_read_loop_result(read_bytes_or_error, last_data_time, timeout_ms); + auto result = http_read_loop_result(read_bytes_or_error, last_data_time, timeout_ms, container->is_read_complete()); if (result == HttpReadLoopResult::RETRY) continue; + if (result == HttpReadLoopResult::COMPLETE) + break; // Server sent less data than requested, but transfer is complete if (result == HttpReadLoopResult::ERROR) return {HttpReadStatus::ERROR, read_bytes_or_error}; if (result == HttpReadLoopResult::TIMEOUT) @@ -393,11 +417,12 @@ template class HttpRequestSendAction : public Action { int read_or_error = container->read(buf + read_index, std::min(max_length - read_index, 512)); App.feed_wdt(); yield(); - auto result = http_read_loop_result(read_or_error, last_data_time, read_timeout); + auto result = + http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete()); if (result == HttpReadLoopResult::RETRY) continue; if (result != HttpReadLoopResult::DATA) - break; // ERROR or TIMEOUT + break; // COMPLETE, ERROR, or TIMEOUT read_index += read_or_error; } response_body.reserve(read_index); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 82538b2cb39..2f12b58766b 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -135,9 +135,23 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur // When cast to size_t, -1 becomes SIZE_MAX (4294967295 on 32-bit). // The read() method handles this: bytes_read_ can never reach SIZE_MAX, so the // early return check (bytes_read_ >= content_length) will never trigger. + // + // TODO: Chunked transfer encoding is NOT properly supported on Arduino. + // The implementation in #7884 was incomplete - it only works correctly on ESP-IDF where + // esp_http_client_read() decodes chunks internally. On Arduino, using getStreamPtr() + // returns raw TCP data with chunk framing (e.g., "12a\r\n{json}\r\n0\r\n\r\n") instead + // of decoded content. This wasn't noticed because requests would complete and payloads + // were only examined on IDF. The long transfer times were also masked by the misleading + // "HTTP on Arduino version >= 3.1 is **very** slow" warning above. This causes two issues: + // 1. Response body is corrupted - contains chunk size headers mixed with data + // 2. Cannot detect end of transfer - connection stays open (keep-alive), causing timeout + // The proper fix would be to use getString() for chunked responses, which decodes chunks + // internally, but this buffers the entire response in memory. int content_length = container->client_.getSize(); ESP_LOGD(TAG, "Content-Length: %d", content_length); container->content_length = (size_t) content_length; + // -1 (SIZE_MAX when cast to size_t) means chunked transfer encoding + container->set_chunked(content_length == -1); container->duration_ms = millis() - start; return container; @@ -178,9 +192,9 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) { if (bufsize == 0) { this->duration_ms += (millis() - start); - // Check if we've read all expected content (only valid when content_length is known and not SIZE_MAX) - // For chunked encoding (content_length == SIZE_MAX), we can't use this check - if (this->content_length > 0 && this->bytes_read_ >= this->content_length) { + // Check if we've read all expected content (non-chunked only) + // For chunked encoding (content_length == SIZE_MAX), is_read_complete() returns false + if (this->is_read_complete()) { return 0; // All content read successfully } // No data available - check if connection is still open diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 2b4dee953a3..bd12b7d1231 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -160,6 +160,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c // esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header). // The read() method handles content_length == 0 specially to support chunked responses. container->content_length = esp_http_client_fetch_headers(client); + container->set_chunked(esp_http_client_is_chunked_response(client)); container->feed_wdt(); container->status_code = esp_http_client_get_status_code(client); container->feed_wdt(); @@ -195,6 +196,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c container->feed_wdt(); container->content_length = esp_http_client_fetch_headers(client); + container->set_chunked(esp_http_client_is_chunked_response(client)); container->feed_wdt(); container->status_code = esp_http_client_get_status_code(client); container->feed_wdt(); @@ -239,10 +241,9 @@ int HttpContainerIDF::read(uint8_t *buf, size_t max_len) { const uint32_t start = millis(); watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); - // Check if we've already read all expected content - // Skip this check when content_length is 0 (chunked transfer encoding or unknown length) - // For chunked responses, esp_http_client_read() will return 0 when all data is received - if (this->content_length > 0 && this->bytes_read_ >= this->content_length) { + // Check if we've already read all expected content (non-chunked only) + // For chunked responses (content_length == 0), esp_http_client_read() handles EOF + if (this->is_read_complete()) { return 0; // All content read successfully } diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 8a4b3684cf9..8f4ecfab2db 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -130,9 +130,13 @@ uint8_t OtaHttpRequestComponent::do_ota_() { App.feed_wdt(); yield(); - auto result = http_read_loop_result(bufsize_or_error, last_data_time, read_timeout); + auto result = http_read_loop_result(bufsize_or_error, last_data_time, read_timeout, container->is_read_complete()); if (result == HttpReadLoopResult::RETRY) continue; + // Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length, + // but this is defensive code in case chunked transfer encoding support is added for OTA in the future. + if (result == HttpReadLoopResult::COMPLETE) + break; if (result != HttpReadLoopResult::DATA) { if (result == HttpReadLoopResult::TIMEOUT) { ESP_LOGE(TAG, "Timeout reading data"); From 9f1a427ce235aa6ab3f1206cfd7940fa7f338b04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Feb 2026 04:03:52 +0100 Subject: [PATCH 07/12] [preferences] Use static storage for singletons and flash buffer (#13727) --- esphome/components/esp32/preferences.cpp | 7 ++++--- esphome/components/esp8266/preferences.cpp | 17 +++++++++-------- esphome/components/host/preferences.cpp | 7 ++++--- esphome/components/libretiny/preferences.cpp | 7 ++++--- esphome/components/rp2040/preferences.cpp | 17 +++++++++-------- esphome/components/zephyr/preferences.cpp | 7 ++++--- 6 files changed, 34 insertions(+), 28 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 4e0bb68133f..7d5af023b48 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -203,10 +203,11 @@ class ESP32Preferences : public ESPPreferences { } }; +static ESP32Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void setup_preferences() { - auto *prefs = new ESP32Preferences(); // NOLINT(cppcoreguidelines-owning-memory) - prefs->open(); - global_preferences = prefs; + s_preferences.open(); + global_preferences = &s_preferences; } } // namespace esp32 diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index 35d1cd07f76..f037b881a8f 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -17,10 +17,6 @@ namespace esphome::esp8266 { static const char *const TAG = "esp8266.preferences"; -static uint32_t *s_flash_storage = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; @@ -43,6 +39,11 @@ static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; #endif +static uint32_t + s_flash_storage[ESP8266_FLASH_STORAGE_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static inline bool esp_rtc_user_mem_read(uint32_t index, uint32_t *dest) { if (index >= ESP_RTC_USER_MEM_SIZE_WORDS) { return false; @@ -180,7 +181,6 @@ class ESP8266Preferences : public ESPPreferences { uint32_t current_flash_offset = 0; // in words void setup() { - s_flash_storage = new uint32_t[ESP8266_FLASH_STORAGE_SIZE]; // NOLINT ESP_LOGVV(TAG, "Loading preferences from flash"); { @@ -283,10 +283,11 @@ class ESP8266Preferences : public ESPPreferences { } }; +static ESP8266Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void setup_preferences() { - auto *pref = new ESP8266Preferences(); // NOLINT(cppcoreguidelines-owning-memory) - pref->setup(); - global_preferences = pref; + s_preferences.setup(); + global_preferences = &s_preferences; } void preferences_prevent_write(bool prevent) { s_prevent_write = prevent; } diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index 7b939cdebb3..5ad87c1f2a0 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -66,10 +66,11 @@ ESPPreferenceObject HostPreferences::make_preference(size_t length, uint32_t typ return ESPPreferenceObject(backend); }; +static HostPreferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void setup_preferences() { - auto *pref = new HostPreferences(); // NOLINT(cppcoreguidelines-owning-memory) - host_preferences = pref; - global_preferences = pref; + host_preferences = &s_preferences; + global_preferences = &s_preferences; } bool HostPreferenceBackend::save(const uint8_t *data, size_t len) { diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 978dcce3fa9..5a60b535da3 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -189,10 +189,11 @@ class LibreTinyPreferences : public ESPPreferences { } }; +static LibreTinyPreferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void setup_preferences() { - auto *prefs = new LibreTinyPreferences(); // NOLINT(cppcoreguidelines-owning-memory) - prefs->open(); - global_preferences = prefs; + s_preferences.open(); + global_preferences = &s_preferences; } } // namespace libretiny diff --git a/esphome/components/rp2040/preferences.cpp b/esphome/components/rp2040/preferences.cpp index e84033bc522..172da32adc3 100644 --- a/esphome/components/rp2040/preferences.cpp +++ b/esphome/components/rp2040/preferences.cpp @@ -18,11 +18,12 @@ namespace rp2040 { static const char *const TAG = "rp2040.preferences"; -static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static uint8_t *s_flash_storage = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static constexpr uint32_t RP2040_FLASH_STORAGE_SIZE = 512; -static const uint32_t RP2040_FLASH_STORAGE_SIZE = 512; +static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static uint8_t + s_flash_storage[RP2040_FLASH_STORAGE_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) // Stack buffer size for preferences - covers virtually all real-world preferences without heap allocation static constexpr size_t PREF_BUFFER_SIZE = 64; @@ -91,7 +92,6 @@ class RP2040Preferences : public ESPPreferences { RP2040Preferences() : eeprom_sector_(&_EEPROM_start) {} void setup() { - s_flash_storage = new uint8_t[RP2040_FLASH_STORAGE_SIZE]; // NOLINT ESP_LOGVV(TAG, "Loading preferences from flash"); memcpy(s_flash_storage, this->eeprom_sector_, RP2040_FLASH_STORAGE_SIZE); } @@ -149,10 +149,11 @@ class RP2040Preferences : public ESPPreferences { uint8_t *eeprom_sector_; }; +static RP2040Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void setup_preferences() { - auto *prefs = new RP2040Preferences(); // NOLINT(cppcoreguidelines-owning-memory) - prefs->setup(); - global_preferences = prefs; + s_preferences.setup(); + global_preferences = &s_preferences; } void preferences_prevent_write(bool prevent) { s_prevent_write = prevent; } diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp index 311133a813b..f02fa16326c 100644 --- a/esphome/components/zephyr/preferences.cpp +++ b/esphome/components/zephyr/preferences.cpp @@ -152,10 +152,11 @@ class ZephyrPreferences : public ESPPreferences { } }; +static ZephyrPreferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + void setup_preferences() { - auto *prefs = new ZephyrPreferences(); // NOLINT(cppcoreguidelines-owning-memory) - global_preferences = prefs; - prefs->open(); + global_preferences = &s_preferences; + s_preferences.open(); } } // namespace zephyr From d41c84d624165984d1e1eaf6cde6312b4bb6f9a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Feb 2026 04:29:18 +0100 Subject: [PATCH 08/12] [wifi] Conditionally compile on_connect/on_disconnect triggers (#13684) --- esphome/components/wifi/__init__.py | 2 ++ esphome/components/wifi/automation.h | 16 ++++++++-------- esphome/components/wifi/wifi_component.cpp | 13 ++++++++++--- esphome/components/wifi/wifi_component.h | 19 ++++++++++++++----- esphome/core/defines.h | 2 ++ tests/components/wifi/common.yaml | 4 ++++ 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 98266eb589e..6863e6fb62d 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -585,11 +585,13 @@ async def to_code(config): await cg.past_safe_mode() if on_connect_config := config.get(CONF_ON_CONNECT): + cg.add_define("USE_WIFI_CONNECT_TRIGGER") await automation.build_automation( var.get_connect_trigger(), [], on_connect_config ) if on_disconnect_config := config.get(CONF_ON_DISCONNECT): + cg.add_define("USE_WIFI_DISCONNECT_TRIGGER") await automation.build_automation( var.get_disconnect_trigger(), [], on_disconnect_config ) diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index fb0e71bcf66..1ad69b39925 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -48,7 +48,7 @@ template class WiFiConfigureAction : public Action, publi char ssid_buf[SSID_BUFFER_SIZE]; if (strcmp(global_wifi_component->wifi_ssid_to(ssid_buf), ssid.c_str()) == 0) { // Callback to notify the user that the connection was successful - this->connect_trigger_->trigger(); + this->connect_trigger_.trigger(); return; } // Create a new WiFiAP object with the new SSID and password @@ -79,13 +79,13 @@ template class WiFiConfigureAction : public Action, publi // Start a timeout for the fallback if the connection to the old AP fails this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { this->connecting_ = false; - this->error_trigger_->trigger(); + this->error_trigger_.trigger(); }); }); } - Trigger<> *get_connect_trigger() const { return this->connect_trigger_; } - Trigger<> *get_error_trigger() const { return this->error_trigger_; } + Trigger<> *get_connect_trigger() { return &this->connect_trigger_; } + Trigger<> *get_error_trigger() { return &this->error_trigger_; } void loop() override { if (!this->connecting_) @@ -98,10 +98,10 @@ template class WiFiConfigureAction : public Action, publi char ssid_buf[SSID_BUFFER_SIZE]; if (strcmp(global_wifi_component->wifi_ssid_to(ssid_buf), this->new_sta_.get_ssid().c_str()) == 0) { // Callback to notify the user that the connection was successful - this->connect_trigger_->trigger(); + this->connect_trigger_.trigger(); } else { // Callback to notify the user that the connection failed - this->error_trigger_->trigger(); + this->error_trigger_.trigger(); } } } @@ -110,8 +110,8 @@ template class WiFiConfigureAction : public Action, publi bool connecting_{false}; WiFiAP new_sta_; WiFiAP old_sta_; - Trigger<> *connect_trigger_{new Trigger<>()}; - Trigger<> *error_trigger_{new Trigger<>()}; + Trigger<> connect_trigger_; + Trigger<> error_trigger_; }; } // namespace esphome::wifi diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 65c653a62a5..c4bfdf3c42a 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -651,14 +651,21 @@ void WiFiComponent::loop() { const uint32_t now = App.get_loop_component_start_time(); if (this->has_sta()) { +#if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER) if (this->is_connected() != this->handled_connected_state_) { +#ifdef USE_WIFI_DISCONNECT_TRIGGER if (this->handled_connected_state_) { - this->disconnect_trigger_->trigger(); - } else { - this->connect_trigger_->trigger(); + this->disconnect_trigger_.trigger(); } +#endif +#ifdef USE_WIFI_CONNECT_TRIGGER + if (!this->handled_connected_state_) { + this->connect_trigger_.trigger(); + } +#endif this->handled_connected_state_ = this->is_connected(); } +#endif // USE_WIFI_CONNECT_TRIGGER || USE_WIFI_DISCONNECT_TRIGGER switch (this->state_) { case WIFI_COMPONENT_STATE_COOLDOWN: { diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index f27c522a1bf..4bdc253f666 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -454,8 +454,12 @@ class WiFiComponent : public Component { void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; } void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; } - Trigger<> *get_connect_trigger() const { return this->connect_trigger_; }; - Trigger<> *get_disconnect_trigger() const { return this->disconnect_trigger_; }; +#ifdef USE_WIFI_CONNECT_TRIGGER + Trigger<> *get_connect_trigger() { return &this->connect_trigger_; } +#endif +#ifdef USE_WIFI_DISCONNECT_TRIGGER + Trigger<> *get_disconnect_trigger() { return &this->disconnect_trigger_; } +#endif int32_t get_wifi_channel(); @@ -706,7 +710,9 @@ class WiFiComponent : public Component { // Group all boolean values together bool has_ap_{false}; +#if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER) bool handled_connected_state_{false}; +#endif bool error_from_callback_{false}; bool scan_done_{false}; bool ap_setup_{false}; @@ -733,9 +739,12 @@ class WiFiComponent : public Component { SemaphoreHandle_t high_performance_semaphore_{nullptr}; #endif - // Pointers at the end (naturally aligned) - Trigger<> *connect_trigger_{new Trigger<>()}; - Trigger<> *disconnect_trigger_{new Trigger<>()}; +#ifdef USE_WIFI_CONNECT_TRIGGER + Trigger<> connect_trigger_; +#endif +#ifdef USE_WIFI_DISCONNECT_TRIGGER + Trigger<> disconnect_trigger_; +#endif private: // Stores a pointer to a string literal (static storage duration). diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1edc648084d..ee865a7e65a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -227,6 +227,8 @@ #define USE_WIFI_SCAN_RESULTS_LISTENERS #define USE_WIFI_CONNECT_STATE_LISTENERS #define USE_WIFI_POWER_SAVE_LISTENERS +#define USE_WIFI_CONNECT_TRIGGER +#define USE_WIFI_DISCONNECT_TRIGGER #define ESPHOME_WIFI_IP_STATE_LISTENERS 2 #define ESPHOME_WIFI_SCAN_RESULTS_LISTENERS 2 #define ESPHOME_WIFI_CONNECT_STATE_LISTENERS 2 diff --git a/tests/components/wifi/common.yaml b/tests/components/wifi/common.yaml index 7ce74ab00d3..10b68347eb6 100644 --- a/tests/components/wifi/common.yaml +++ b/tests/components/wifi/common.yaml @@ -26,3 +26,7 @@ wifi: - ssid: MySSID3 password: password3 priority: 0 + on_connect: + - logger.log: "WiFi connected!" + on_disconnect: + - logger.log: "WiFi disconnected!" From 8cb701e4128abdf0508ad90e97bffda0263accda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Feb 2026 04:29:31 +0100 Subject: [PATCH 09/12] [water_heater] Store mode strings in flash and avoid heap allocation in set_mode (#13728) --- .../components/water_heater/water_heater.cpp | 19 ++++++++++--------- .../components/water_heater/water_heater.h | 3 ++- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 7266294d848..286addf7dbc 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -2,6 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" #include "esphome/core/controller_registry.h" +#include "esphome/core/progmem.h" #include @@ -22,23 +23,23 @@ WaterHeaterCall &WaterHeaterCall::set_mode(WaterHeaterMode mode) { return *this; } -WaterHeaterCall &WaterHeaterCall::set_mode(const std::string &mode) { - if (str_equals_case_insensitive(mode, "OFF")) { +WaterHeaterCall &WaterHeaterCall::set_mode(const char *mode) { + if (ESPHOME_strcasecmp_P(mode, ESPHOME_PSTR("OFF")) == 0) { this->set_mode(WATER_HEATER_MODE_OFF); - } else if (str_equals_case_insensitive(mode, "ECO")) { + } else if (ESPHOME_strcasecmp_P(mode, ESPHOME_PSTR("ECO")) == 0) { this->set_mode(WATER_HEATER_MODE_ECO); - } else if (str_equals_case_insensitive(mode, "ELECTRIC")) { + } else if (ESPHOME_strcasecmp_P(mode, ESPHOME_PSTR("ELECTRIC")) == 0) { this->set_mode(WATER_HEATER_MODE_ELECTRIC); - } else if (str_equals_case_insensitive(mode, "PERFORMANCE")) { + } else if (ESPHOME_strcasecmp_P(mode, ESPHOME_PSTR("PERFORMANCE")) == 0) { this->set_mode(WATER_HEATER_MODE_PERFORMANCE); - } else if (str_equals_case_insensitive(mode, "HIGH_DEMAND")) { + } else if (ESPHOME_strcasecmp_P(mode, ESPHOME_PSTR("HIGH_DEMAND")) == 0) { this->set_mode(WATER_HEATER_MODE_HIGH_DEMAND); - } else if (str_equals_case_insensitive(mode, "HEAT_PUMP")) { + } else if (ESPHOME_strcasecmp_P(mode, ESPHOME_PSTR("HEAT_PUMP")) == 0) { this->set_mode(WATER_HEATER_MODE_HEAT_PUMP); - } else if (str_equals_case_insensitive(mode, "GAS")) { + } else if (ESPHOME_strcasecmp_P(mode, ESPHOME_PSTR("GAS")) == 0) { this->set_mode(WATER_HEATER_MODE_GAS); } else { - ESP_LOGW(TAG, "'%s' - Unrecognized mode %s", this->parent_->get_name().c_str(), mode.c_str()); + ESP_LOGW(TAG, "'%s' - Unrecognized mode %s", this->parent_->get_name().c_str(), mode); } return *this; } diff --git a/esphome/components/water_heater/water_heater.h b/esphome/components/water_heater/water_heater.h index 84fc46d2084..7bd05ba7f5d 100644 --- a/esphome/components/water_heater/water_heater.h +++ b/esphome/components/water_heater/water_heater.h @@ -75,7 +75,8 @@ class WaterHeaterCall { WaterHeaterCall(WaterHeater *parent); WaterHeaterCall &set_mode(WaterHeaterMode mode); - WaterHeaterCall &set_mode(const std::string &mode); + WaterHeaterCall &set_mode(const char *mode); + WaterHeaterCall &set_mode(const std::string &mode) { return this->set_mode(mode.c_str()); } WaterHeaterCall &set_target_temperature(float temperature); WaterHeaterCall &set_target_temperature_low(float temperature); WaterHeaterCall &set_target_temperature_high(float temperature); From 9d63642bdb16598ab8c0ec3eafbb00178df96955 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Feb 2026 04:29:43 +0100 Subject: [PATCH 10/12] [media_player] Store command strings in flash and avoid heap allocation in set_command (#13731) --- .../components/media_player/media_player.cpp | 21 ++++++++++--------- .../components/media_player/media_player.h | 3 ++- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/esphome/components/media_player/media_player.cpp b/esphome/components/media_player/media_player.cpp index b46ec39d302..17d9b054dad 100644 --- a/esphome/components/media_player/media_player.cpp +++ b/esphome/components/media_player/media_player.cpp @@ -2,6 +2,7 @@ #include "esphome/core/defines.h" #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome { namespace media_player { @@ -107,25 +108,25 @@ MediaPlayerCall &MediaPlayerCall::set_command(optional comma this->command_ = command; return *this; } -MediaPlayerCall &MediaPlayerCall::set_command(const std::string &command) { - if (str_equals_case_insensitive(command, "PLAY")) { +MediaPlayerCall &MediaPlayerCall::set_command(const char *command) { + if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("PLAY")) == 0) { this->set_command(MEDIA_PLAYER_COMMAND_PLAY); - } else if (str_equals_case_insensitive(command, "PAUSE")) { + } else if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("PAUSE")) == 0) { this->set_command(MEDIA_PLAYER_COMMAND_PAUSE); - } else if (str_equals_case_insensitive(command, "STOP")) { + } else if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("STOP")) == 0) { this->set_command(MEDIA_PLAYER_COMMAND_STOP); - } else if (str_equals_case_insensitive(command, "MUTE")) { + } else if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("MUTE")) == 0) { this->set_command(MEDIA_PLAYER_COMMAND_MUTE); - } else if (str_equals_case_insensitive(command, "UNMUTE")) { + } else if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("UNMUTE")) == 0) { this->set_command(MEDIA_PLAYER_COMMAND_UNMUTE); - } else if (str_equals_case_insensitive(command, "TOGGLE")) { + } else if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("TOGGLE")) == 0) { this->set_command(MEDIA_PLAYER_COMMAND_TOGGLE); - } else if (str_equals_case_insensitive(command, "TURN_ON")) { + } else if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("TURN_ON")) == 0) { this->set_command(MEDIA_PLAYER_COMMAND_TURN_ON); - } else if (str_equals_case_insensitive(command, "TURN_OFF")) { + } else if (ESPHOME_strcasecmp_P(command, ESPHOME_PSTR("TURN_OFF")) == 0) { this->set_command(MEDIA_PLAYER_COMMAND_TURN_OFF); } else { - ESP_LOGW(TAG, "'%s' - Unrecognized command %s", this->parent_->get_name().c_str(), command.c_str()); + ESP_LOGW(TAG, "'%s' - Unrecognized command %s", this->parent_->get_name().c_str(), command); } return *this; } diff --git a/esphome/components/media_player/media_player.h b/esphome/components/media_player/media_player.h index b753e2d0880..f75a68dd853 100644 --- a/esphome/components/media_player/media_player.h +++ b/esphome/components/media_player/media_player.h @@ -114,7 +114,8 @@ class MediaPlayerCall { MediaPlayerCall &set_command(MediaPlayerCommand command); MediaPlayerCall &set_command(optional command); - MediaPlayerCall &set_command(const std::string &command); + MediaPlayerCall &set_command(const char *command); + MediaPlayerCall &set_command(const std::string &command) { return this->set_command(command.c_str()); } MediaPlayerCall &set_media_url(const std::string &url); From fbeb0e8e542af3b8b4f2f86a1fc8d46e2bb915ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Feb 2026 04:40:44 +0100 Subject: [PATCH 11/12] [opentherm] Fix ESP-IDF build by re-enabling legacy driver component (#13732) --- esphome/components/opentherm/__init__.py | 8 ++++++++ esphome/components/opentherm/opentherm.cpp | 2 ++ esphome/components/opentherm/opentherm.h | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 8cbee1eed24..dddb9dc8918 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -4,8 +4,10 @@ from typing import Any from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor +from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TRIGGER_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +from esphome.core import CORE from . import const, generate, schema, validate @@ -83,6 +85,12 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: dict[str, Any]) -> None: + if CORE.is_esp32: + # Re-enable ESP-IDF's legacy driver component (excluded by default to save compile time) + # Provides driver/timer.h header for hardware timer API + # TODO: Remove this once opentherm migrates to GPTimer API (driver/gptimer.h) + include_builtin_idf_component("driver") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 2bf438a52fb..cdf89207bcf 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -8,6 +8,8 @@ #include "opentherm.h" #include "esphome/core/helpers.h" #include +// TODO: Migrate from legacy timer API (driver/timer.h) to GPTimer API (driver/gptimer.h) +// The legacy timer API is deprecated in ESP-IDF 5.x. See opentherm.h for details. #ifdef USE_ESP32 #include "driver/timer.h" #include "esp_err.h" diff --git a/esphome/components/opentherm/opentherm.h b/esphome/components/opentherm/opentherm.h index 3996481760a..a2c347d0d8e 100644 --- a/esphome/components/opentherm/opentherm.h +++ b/esphome/components/opentherm/opentherm.h @@ -12,6 +12,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +// TODO: Migrate from legacy timer API (driver/timer.h) to GPTimer API (driver/gptimer.h) +// The legacy timer API is deprecated in ESP-IDF 5.x. Migration would allow removing the +// "driver" IDF component dependency. See: +// https://docs.espressif.com/projects/esp-idf/en/latest/esp32/migration-guides/release-5.x/5.0/peripherals.html#id4 #ifdef USE_ESP32 #include "driver/timer.h" #endif From a430b3a42606696bda318af4314c1be26103a0b5 Mon Sep 17 00:00:00 2001 From: Roger Fachini Date: Mon, 2 Feb 2026 19:46:46 -0800 Subject: [PATCH 12/12] [speaker.media_player]: Add verbose error message for puremagic parsing (#13725) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/speaker/media_player/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 370b4576a7c..034312236c6 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -157,8 +157,14 @@ def _read_audio_file_and_type(file_config): import puremagic - file_type: str = puremagic.from_string(data) - file_type = file_type.removeprefix(".") + try: + file_type: str = puremagic.from_string(data) + file_type = file_type.removeprefix(".") + except puremagic.PureError as e: + raise cv.Invalid( + f"Unable to determine audio file type of '{path}'. " + f"Try re-encoding the file into a supported format. Details: {e}" + ) media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type in ("wav"):