From b9361b0868500ce4eec9b7ae0b71308185d02819 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 15:08:55 -0600 Subject: [PATCH 01/16] [esp32_improv] Disable loop by default until provisioning needed (#10764) --- esphome/components/esp32_improv/esp32_improv_component.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index d41094fda1..d47cc50a00 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -31,6 +31,9 @@ void ESP32ImprovComponent::setup() { #endif global_ble_server->on(BLEServerEvt::EmptyEvt::ON_DISCONNECT, [this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); + + // Start with loop disabled - will be enabled by start() when needed + this->disable_loop(); } void ESP32ImprovComponent::setup_characteristics() { From 6d9fc672d5734908b3ce4ac37bf528096ef64bf2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:21:19 -0400 Subject: [PATCH 02/16] [libretiny] Fix lib_ignore handling and ignore incompatible libraries (#10846) --- esphome/components/heatpumpir/climate.py | 2 +- esphome/components/web_server_base/__init__.py | 2 ++ esphome/core/config.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 4f83bf2435..ec6eac670f 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -128,4 +128,4 @@ async def to_code(config): cg.add_library("tonia/HeatpumpIR", "1.0.37") if CORE.is_libretiny or CORE.is_esp32: - CORE.add_platformio_option("lib_ignore", "IRremoteESP8266") + CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 2aff405036..a82ec462d9 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -40,5 +40,7 @@ async def to_code(config): cg.add_library("Update", None) if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) + if CORE.is_libretiny: + CORE.add_platformio_option("lib_ignore", ["ESPAsyncTCP", "RPAsyncTCP"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.7.10") diff --git a/esphome/core/config.py b/esphome/core/config.py index 96b9e23861..87e529143d 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -396,7 +396,7 @@ async def add_includes(includes): async def _add_platformio_options(pio_options): # Add includes at the very end, so that they override everything for key, val in pio_options.items(): - if key == "build_flags" and not isinstance(val, list): + if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): val = [val] cg.add_platformio_option(key, val) From 1ecd26adb5b87515d0eae1b74b9286f1636f17c0 Mon Sep 17 00:00:00 2001 From: Stuart Parmenter Date: Wed, 24 Sep 2025 06:59:16 -0700 Subject: [PATCH 03/16] Set color_order to RGB for the Waveshare ESP32-S3-TOUCH-LCD-4.3 and ESP32-S3-TOUCH-LCD-7-800X480 (#10835) --- esphome/components/mipi_rgb/models/waveshare.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index 49a75da232..a38493e816 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -7,6 +7,7 @@ wave_4_3 = DriverChip( "ESP32-S3-TOUCH-LCD-4.3", swap_xy=UNDEFINED, initsequence=(), + color_order="RGB", width=800, height=480, pclk_frequency="16MHz", From f2a9e9265ee7cf32215507aa6fdcf0c5b94121a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Sep 2025 09:24:17 -0500 Subject: [PATCH 04/16] [esp32_improv] Fix crashes from uninitialized pointers and missing null checks (#10902) --- .../esp32_improv/esp32_improv_component.cpp | 74 ++++++++++++++----- .../esp32_improv/esp32_improv_component.h | 15 ++-- 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index d47cc50a00..c5a0b89f99 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -15,6 +15,8 @@ using namespace bytebuffer; static const char *const TAG = "esp32_improv.component"; static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; +static constexpr uint16_t STOP_ADVERTISING_DELAY = + 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } @@ -193,6 +195,25 @@ void ESP32ImprovComponent::set_status_indicator_state_(bool state) { #endif } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG +const char *ESP32ImprovComponent::state_to_string_(improv::State state) { + switch (state) { + case improv::STATE_STOPPED: + return "STOPPED"; + case improv::STATE_AWAITING_AUTHORIZATION: + return "AWAITING_AUTHORIZATION"; + case improv::STATE_AUTHORIZED: + return "AUTHORIZED"; + case improv::STATE_PROVISIONING: + return "PROVISIONING"; + case improv::STATE_PROVISIONED: + return "PROVISIONED"; + default: + return "UNKNOWN"; + } +} +#endif + bool ESP32ImprovComponent::check_identify_() { uint32_t now = millis(); @@ -206,31 +227,42 @@ bool ESP32ImprovComponent::check_identify_() { } void ESP32ImprovComponent::set_state_(improv::State state) { - ESP_LOGV(TAG, "Setting state: %d", state); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + if (this->state_ != state) { + ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_, + this->state_to_string_(state), state); + } +#endif this->state_ = state; - if (this->status_->get_value().empty() || this->status_->get_value()[0] != state) { + if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) { this->status_->set_value(ByteBuffer::wrap(static_cast(state))); if (state != improv::STATE_STOPPED) this->status_->notify(); } - std::vector service_data(8, 0); - service_data[0] = 0x77; // PR - service_data[1] = 0x46; // IM - service_data[2] = static_cast(state); + // Only advertise valid Improv states (0x01-0x04). + // STATE_STOPPED (0x00) is internal only and not part of the Improv spec. + // Advertising 0x00 causes undefined behavior in some clients and makes them + // repeatedly connect trying to determine the actual state. + if (state != improv::STATE_STOPPED) { + std::vector service_data(8, 0); + service_data[0] = 0x77; // PR + service_data[1] = 0x46; // IM + service_data[2] = static_cast(state); - uint8_t capabilities = 0x00; + uint8_t capabilities = 0x00; #ifdef USE_OUTPUT - if (this->status_indicator_ != nullptr) - capabilities |= improv::CAPABILITY_IDENTIFY; + if (this->status_indicator_ != nullptr) + capabilities |= improv::CAPABILITY_IDENTIFY; #endif - service_data[3] = capabilities; - service_data[4] = 0x00; // Reserved - service_data[5] = 0x00; // Reserved - service_data[6] = 0x00; // Reserved - service_data[7] = 0x00; // Reserved + service_data[3] = capabilities; + service_data[4] = 0x00; // Reserved + service_data[5] = 0x00; // Reserved + service_data[6] = 0x00; // Reserved + service_data[7] = 0x00; // Reserved - esp32_ble::global_ble->advertising_set_service_data(service_data); + esp32_ble::global_ble->advertising_set_service_data(service_data); + } #ifdef USE_ESP32_IMPROV_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif @@ -240,7 +272,12 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { if (error != improv::ERROR_NONE) { ESP_LOGE(TAG, "Error: %d", error); } - if (this->error_->get_value().empty() || this->error_->get_value()[0] != error) { + // The error_ characteristic is initialized in setup_characteristics() which is called + // from the loop, while the BLE disconnect callback is registered in setup(). + // error_ can be nullptr if: + // 1. A client connects/disconnects before setup_characteristics() is called + // 2. The device is already provisioned so the service never starts (should_start_ is false) + if (this->error_ != nullptr && (this->error_->get_value().empty() || this->error_->get_value()[0] != error)) { this->error_->set_value(ByteBuffer::wrap(static_cast(error))); if (this->state_ != improv::STATE_STOPPED) this->error_->notify(); @@ -264,7 +301,10 @@ void ESP32ImprovComponent::start() { void ESP32ImprovComponent::stop() { this->should_start_ = false; - this->set_timeout("end-service", 1000, [this] { + // Wait before stopping the service to ensure all BLE clients see the state change. + // This prevents clients from repeatedly reconnecting and wasting resources by allowing + // them to observe that the device is provisioned before the service disappears. + this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] { if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr) return; this->service_->stop(); diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 87cec23876..686da08111 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -79,12 +79,12 @@ class ESP32ImprovComponent : public Component { std::vector incoming_data_; wifi::WiFiAP connecting_sta_; - BLEService *service_ = nullptr; - BLECharacteristic *status_; - BLECharacteristic *error_; - BLECharacteristic *rpc_; - BLECharacteristic *rpc_response_; - BLECharacteristic *capabilities_; + BLEService *service_{nullptr}; + BLECharacteristic *status_{nullptr}; + BLECharacteristic *error_{nullptr}; + BLECharacteristic *rpc_{nullptr}; + BLECharacteristic *rpc_response_{nullptr}; + BLECharacteristic *capabilities_{nullptr}; #ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *authorizer_{nullptr}; @@ -108,6 +108,9 @@ class ESP32ImprovComponent : public Component { void process_incoming_data_(); void on_wifi_connect_timeout_(); bool check_identify_(); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + const char *state_to_string_(improv::State state); +#endif }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) From 57f7a709cfdc8ac11909f92b247145a5e40fb34a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 27 Sep 2025 15:55:14 -0400 Subject: [PATCH 05/16] [sx126x] Fix issues with variable length FSK packets (#10911) --- esphome/components/sx126x/sx126x.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index cae047d168..f5393c478a 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -217,7 +217,7 @@ void SX126x::configure() { this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 4); // set packet params and sync word - this->set_packet_params_(this->payload_length_); + this->set_packet_params_(this->get_max_packet_size()); if (this->sync_value_.size() == 2) { this->write_register_(REG_LORA_SYNCWORD, this->sync_value_.data(), this->sync_value_.size()); } @@ -236,7 +236,7 @@ void SX126x::configure() { this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 8); // set packet params and sync word - this->set_packet_params_(this->payload_length_); + this->set_packet_params_(this->get_max_packet_size()); if (!this->sync_value_.empty()) { this->write_register_(REG_GFSK_SYNCWORD, this->sync_value_.data(), this->sync_value_.size()); } @@ -274,7 +274,7 @@ void SX126x::set_packet_params_(uint8_t payload_length) { buf[2] = (this->preamble_detect_ > 0) ? ((this->preamble_detect_ - 1) | 0x04) : 0x00; buf[3] = this->sync_value_.size() * 8; buf[4] = 0x00; - buf[5] = 0x00; + buf[5] = (this->payload_length_ > 0) ? 0x00 : 0x01; buf[6] = payload_length; buf[7] = this->crc_enable_ ? 0x06 : 0x01; buf[8] = 0x00; @@ -314,6 +314,9 @@ SX126xError SX126x::transmit_packet(const std::vector &packet) { buf[0] = 0xFF; buf[1] = 0xFF; this->write_opcode_(RADIO_CLR_IRQSTATUS, buf, 2); + if (this->payload_length_ == 0) { + this->set_packet_params_(this->get_max_packet_size()); + } if (this->rx_start_) { this->set_mode_rx(); } else { From 127058e70024e4675dff9b3cf174f2eebd558c47 Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Sun, 28 Sep 2025 11:35:40 +0200 Subject: [PATCH 06/16] [usb_uart] Disable flow control on ch34x --- esphome/components/usb_uart/ch34x.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 74e7933824..37cd33f841 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -72,6 +72,7 @@ void USBUartTypeCH34X::enable_channels() { if (channel->index_ >= 2) cmd += 0xE; this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); + this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); } USBUartTypeCdcAcm::enable_channels(); } From 345fc0b6caaa4c84d3cd79f2343608a858d3d783 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 29 Sep 2025 09:12:06 +1000 Subject: [PATCH 07/16] [mipi_spi] Fix t-display-amoled (#10922) --- esphome/components/mipi/__init__.py | 10 +++++++++- esphome/components/mipi_spi/models/amoled.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 8b1ca899df..f670a5913d 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -401,6 +401,12 @@ class DriverChip: sequence.append((MADCTL, madctl)) return madctl + def skip_command(self, command: str): + """ + Allow suppressing a standard command in the init sequence. + """ + return self.get_default(f"no_{command.lower()}", False) + def get_sequence(self, config) -> tuple[tuple[int, ...], int]: """ Create the init sequence for the display. @@ -432,7 +438,9 @@ class DriverChip: sequence.append((INVOFF,)) if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): sequence.append((BRIGHTNESS, brightness)) - sequence.append((SLPOUT,)) + # Add a SLPOUT command if required. + if not self.skip_command("SLPOUT"): + sequence.append((SLPOUT,)) sequence.append((DISPON,)) # Flatten the sequence into a list of bytes, with the length of each command diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index bc95fc7f71..4d6c8da4b0 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -27,7 +27,8 @@ DriverChip( bus_mode=TYPE_QUAD, brightness=0xD0, color_order=MODE_RGB, - initsequence=(SLPOUT,), # Requires early SLPOUT + no_slpout=True, # SLPOUT is in the init sequence, early + initsequence=(SLPOUT,), ) DriverChip( @@ -95,6 +96,7 @@ CO5300 = DriverChip( brightness=0xD0, color_order=MODE_RGB, bus_mode=TYPE_QUAD, + no_slpout=True, initsequence=( (SLPOUT,), # Requires early SLPOUT (PAGESEL, 0x00), From 58166b3e71cb56eecaa67755a5aa14f5b4d16451 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 29 Sep 2025 21:39:17 +1300 Subject: [PATCH 08/16] Bump version to 2025.9.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 6ef1d86c74..b0b92dfd63 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 = 2025.9.1 +PROJECT_NUMBER = 2025.9.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 0b36e26ec0..dafd49c066 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.1" +__version__ = "2025.9.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From e3e98e2568fa461e63649d37d33d29b0b209e226 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 29 Sep 2025 23:09:41 +0500 Subject: [PATCH 09/16] [nrf52] add more tests (#10695) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- tests/components/analog_threshold/test.nrf52-adafruit.yaml | 1 + tests/components/analog_threshold/test.nrf52-mcumgr.yaml | 1 + tests/components/bang_bang/test.nrf52-adafruit.yaml | 1 + tests/components/bang_bang/test.nrf52-mcumgr.yaml | 1 + 4 files changed, 4 insertions(+) create mode 100644 tests/components/analog_threshold/test.nrf52-adafruit.yaml create mode 100644 tests/components/analog_threshold/test.nrf52-mcumgr.yaml create mode 100644 tests/components/bang_bang/test.nrf52-adafruit.yaml create mode 100644 tests/components/bang_bang/test.nrf52-mcumgr.yaml diff --git a/tests/components/analog_threshold/test.nrf52-adafruit.yaml b/tests/components/analog_threshold/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/analog_threshold/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/analog_threshold/test.nrf52-mcumgr.yaml b/tests/components/analog_threshold/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/analog_threshold/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/bang_bang/test.nrf52-adafruit.yaml b/tests/components/bang_bang/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/bang_bang/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/bang_bang/test.nrf52-mcumgr.yaml b/tests/components/bang_bang/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/bang_bang/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 054b215d8d265b0a1e139454878803d36fc5c0e4 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 29 Sep 2025 23:11:57 +0500 Subject: [PATCH 10/16] [nrf52] add more tests (#10694) --- esphome/components/cover/cover.cpp | 1 + esphome/components/valve/valve.cpp | 1 + tests/components/restart/test.nrf52-adafruit.yaml | 1 + tests/components/restart/test.nrf52-mcumgr.yaml | 1 + tests/components/script/test.nrf52-adafruit.yaml | 1 + tests/components/script/test.nrf52-mcumgr.yaml | 1 + tests/components/sprinkler/test.nrf52-adafruit.yaml | 1 + tests/components/sprinkler/test.nrf52-mcumgr.yaml | 1 + tests/components/template/common.yaml | 1 + tests/components/template/test.nrf52-adafruit.yaml | 6 ++++++ tests/components/template/test.nrf52-mcumgr.yaml | 6 ++++++ tests/components/thermostat/test.nrf52-adafruit.yaml | 1 + tests/components/thermostat/test.nrf52-mcumgr.yaml | 1 + 13 files changed, 23 insertions(+) create mode 100644 tests/components/restart/test.nrf52-adafruit.yaml create mode 100644 tests/components/restart/test.nrf52-mcumgr.yaml create mode 100644 tests/components/script/test.nrf52-adafruit.yaml create mode 100644 tests/components/script/test.nrf52-mcumgr.yaml create mode 100644 tests/components/sprinkler/test.nrf52-adafruit.yaml create mode 100644 tests/components/sprinkler/test.nrf52-mcumgr.yaml create mode 100644 tests/components/template/test.nrf52-adafruit.yaml create mode 100644 tests/components/template/test.nrf52-mcumgr.yaml create mode 100644 tests/components/thermostat/test.nrf52-adafruit.yaml create mode 100644 tests/components/thermostat/test.nrf52-mcumgr.yaml diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 700bceec01..3378279371 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -1,5 +1,6 @@ #include "cover.h" #include "esphome/core/log.h" +#include namespace esphome { namespace cover { diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index 0ee710fc02..b041fe8449 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -1,5 +1,6 @@ #include "valve.h" #include "esphome/core/log.h" +#include namespace esphome { namespace valve { diff --git a/tests/components/restart/test.nrf52-adafruit.yaml b/tests/components/restart/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/restart/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/restart/test.nrf52-mcumgr.yaml b/tests/components/restart/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/restart/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/script/test.nrf52-adafruit.yaml b/tests/components/script/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/script/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/script/test.nrf52-mcumgr.yaml b/tests/components/script/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/script/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sprinkler/test.nrf52-adafruit.yaml b/tests/components/sprinkler/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sprinkler/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sprinkler/test.nrf52-mcumgr.yaml b/tests/components/sprinkler/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sprinkler/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index ae7dc98e57..efbb83ee06 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -341,6 +341,7 @@ datetime: time: - platform: sntp # Required for datetime + id: sntp_time wifi: # Required for sntp time ap: diff --git a/tests/components/template/test.nrf52-adafruit.yaml b/tests/components/template/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..6a8c01560a --- /dev/null +++ b/tests/components/template/test.nrf52-adafruit.yaml @@ -0,0 +1,6 @@ +packages: !include common.yaml + +time: + - id: !remove sntp_time + +wifi: !remove diff --git a/tests/components/template/test.nrf52-mcumgr.yaml b/tests/components/template/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..6a8c01560a --- /dev/null +++ b/tests/components/template/test.nrf52-mcumgr.yaml @@ -0,0 +1,6 @@ +packages: !include common.yaml + +time: + - id: !remove sntp_time + +wifi: !remove diff --git a/tests/components/thermostat/test.nrf52-adafruit.yaml b/tests/components/thermostat/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/thermostat/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/thermostat/test.nrf52-mcumgr.yaml b/tests/components/thermostat/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/thermostat/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From b176d1f890559cb939c3793346d8a85ce154d724 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:24:42 -0400 Subject: [PATCH 11/16] [core] Don't remove storage in clean-all (#10921) --- esphome/__main__.py | 4 +- esphome/writer.py | 13 +++- tests/unit_tests/test_writer.py | 132 ++++++++++++++++++++++++++++++-- 3 files changed, 137 insertions(+), 12 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 42880e6cfc..38edcb070f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1155,7 +1155,9 @@ def parse_args(argv): "configuration", help="Your YAML configuration file(s).", nargs="+" ) - parser_clean_all = subparsers.add_parser("clean-all", help="Clean all files.") + parser_clean_all = subparsers.add_parser( + "clean-all", help="Clean all build and platform files." + ) parser_clean_all.add_argument( "configuration", help="Your YAML configuration directory.", nargs="*" ) diff --git a/esphome/writer.py b/esphome/writer.py index 403cd8165d..b5cfd9b667 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -340,10 +340,15 @@ def clean_all(configuration: list[str]): # Clean entire build dir for dir in configuration: - buid_dir = Path(dir) / ".esphome" - if buid_dir.is_dir(): - _LOGGER.info("Deleting %s", buid_dir) - shutil.rmtree(buid_dir) + build_dir = Path(dir) / ".esphome" + if build_dir.is_dir(): + _LOGGER.info("Cleaning %s", build_dir) + # Don't remove storage as it will cause the dashboard to regenerate all configs + for item in build_dir.iterdir(): + if item.is_file(): + item.unlink() + elif item.name != "storage" and item.is_dir(): + shutil.rmtree(item) # Clean PlatformIO project files try: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 66e0b6cb67..bffd2b3881 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -790,16 +790,21 @@ def test_clean_all( with caplog.at_level("INFO"): clean_all([str(config1_dir), str(config2_dir)]) - # Verify deletions - assert not build_dir1.exists() - assert not build_dir2.exists() + # Verify deletions - .esphome directories remain but contents are cleaned + # The .esphome directory itself is not removed because it may contain storage + assert build_dir1.exists() + assert build_dir2.exists() + + # Verify that files in .esphome were removed + assert not (build_dir1 / "dummy.txt").exists() + assert not (build_dir2 / "dummy.txt").exists() assert not pio_cache.exists() assert not pio_packages.exists() assert not pio_platforms.exists() assert not pio_core.exists() # Verify logging mentions each - assert "Deleting" in caplog.text + assert "Cleaning" in caplog.text assert str(build_dir1) in caplog.text assert str(build_dir2) in caplog.text assert "PlatformIO cache" in caplog.text @@ -808,6 +813,55 @@ def test_clean_all( assert "PlatformIO core" in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_preserves_storage( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all preserves storage directory.""" + # Create build directory with storage subdirectory + config_dir = tmp_path / "config" + config_dir.mkdir() + + build_dir = config_dir / ".esphome" + build_dir.mkdir() + (build_dir / "dummy.txt").write_text("x") + (build_dir / "other_file.txt").write_text("y") + + # Create storage directory with content + storage_dir = build_dir / "storage" + storage_dir.mkdir() + (storage_dir / "storage.json").write_text('{"test": "data"}') + (storage_dir / "other_storage.txt").write_text("storage content") + + # Call clean_all + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + # Verify .esphome directory still exists + assert build_dir.exists() + + # Verify storage directory still exists with its contents + assert storage_dir.exists() + assert (storage_dir / "storage.json").exists() + assert (storage_dir / "other_storage.txt").exists() + + # Verify storage contents are intact + assert (storage_dir / "storage.json").read_text() == '{"test": "data"}' + assert (storage_dir / "other_storage.txt").read_text() == "storage content" + + # Verify other files were removed + assert not (build_dir / "dummy.txt").exists() + assert not (build_dir / "other_file.txt").exists() + + # Verify logging mentions deletion + assert "Cleaning" in caplog.text + assert str(build_dir) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_platformio_not_available( mock_core: MagicMock, @@ -833,8 +887,8 @@ def test_clean_all_platformio_not_available( ): clean_all([str(config_dir)]) - # Build dir removed, PlatformIO dirs remain - assert not build_dir.exists() + # Build dir contents cleaned, PlatformIO dirs remain + assert build_dir.exists() assert pio_cache.exists() # No PlatformIO-specific logs @@ -866,4 +920,68 @@ def test_clean_all_partial_exists( clean_all([str(config_dir)]) - assert not build_dir.exists() + assert build_dir.exists() + + +@patch("esphome.writer.CORE") +def test_clean_all_removes_non_storage_directories( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all removes directories other than storage.""" + # Create build directory with various subdirectories + config_dir = tmp_path / "config" + config_dir.mkdir() + + build_dir = config_dir / ".esphome" + build_dir.mkdir() + + # Create files + (build_dir / "file1.txt").write_text("content1") + (build_dir / "file2.txt").write_text("content2") + + # Create storage directory (should be preserved) + storage_dir = build_dir / "storage" + storage_dir.mkdir() + (storage_dir / "storage.json").write_text('{"test": "data"}') + + # Create other directories (should be removed) + cache_dir = build_dir / "cache" + cache_dir.mkdir() + (cache_dir / "cache_file.txt").write_text("cache content") + + logs_dir = build_dir / "logs" + logs_dir.mkdir() + (logs_dir / "log1.txt").write_text("log content") + + temp_dir = build_dir / "temp" + temp_dir.mkdir() + (temp_dir / "temp_file.txt").write_text("temp content") + + # Call clean_all + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + # Verify .esphome directory still exists + assert build_dir.exists() + + # Verify storage directory and its contents are preserved + assert storage_dir.exists() + assert (storage_dir / "storage.json").exists() + assert (storage_dir / "storage.json").read_text() == '{"test": "data"}' + + # Verify files were removed + assert not (build_dir / "file1.txt").exists() + assert not (build_dir / "file2.txt").exists() + + # Verify non-storage directories were removed + assert not cache_dir.exists() + assert not logs_dir.exists() + assert not temp_dir.exists() + + # Verify logging mentions cleaning + assert "Cleaning" in caplog.text + assert str(build_dir) in caplog.text From 3b73738d9f590183813450244bd850f31afd4c1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 16:35:53 -0500 Subject: [PATCH 12/16] [script] Reduce RAM usage by storing names in flash (#10941) --- esphome/codegen.py | 1 + esphome/components/script/__init__.py | 2 +- esphome/components/script/script.h | 14 +++++++------- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/codegen.py b/esphome/codegen.py index 8e02ec1164..6decd77c62 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -12,6 +12,7 @@ from esphome.cpp_generator import ( # noqa: F401 ArrayInitializer, Expression, LineComment, + LogStringLiteral, MockObj, MockObjClass, Pvariable, diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index ee1f6a4ad0..e8a8aa5671 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -124,7 +124,7 @@ async def to_code(config): template, func_args = parameters_to_template(conf[CONF_PARAMETERS]) trigger = cg.new_Pvariable(conf[CONF_ID], template) # Add a human-readable name to the script - cg.add(trigger.set_name(conf[CONF_ID].id)) + cg.add(trigger.set_name(cg.LogStringLiteral(conf[CONF_ID].id))) if CONF_MAX_RUNS in conf: cg.add(trigger.set_max_runs(conf[CONF_MAX_RUNS])) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index b16bb53acc..b87402f52e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -48,14 +48,14 @@ template class Script : public ScriptLogger, public Trigger void execute_tuple_(const std::tuple &tuple, seq /*unused*/) { this->execute(std::get(tuple)...); } - std::string name_; + const LogString *name_{nullptr}; }; /** A script type for which only a single instance at a time is allowed. @@ -68,7 +68,7 @@ template class SingleScript : public Script { void execute(Ts... x) override { if (this->is_action_running()) { this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' is already running! (mode: single)"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); return; } @@ -85,7 +85,7 @@ template class RestartScript : public Script { public: void execute(Ts... x) override { if (this->is_action_running()) { - this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), this->name_.c_str()); + this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), LOG_STR_ARG(this->name_)); this->stop_action(); } @@ -105,12 +105,12 @@ template class QueueingScript : public Script, public Com // num_runs_ + 1 if (this->max_runs_ != 0 && this->num_runs_ + 1 >= this->max_runs_) { this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of queued runs exceeded!"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); return; } this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' queueing new instance (mode: queued)"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); this->num_runs_++; this->var_queue_.push(std::make_tuple(x...)); return; @@ -157,7 +157,7 @@ template class ParallelScript : public Script { void execute(Ts... x) override { if (this->max_runs_ != 0 && this->automation_parent_->num_running() >= this->max_runs_) { this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of parallel runs exceeded!"), - this->name_.c_str()); + LOG_STR_ARG(this->name_)); return; } this->trigger(x...); From 300f1de11c93c3df34cc4dd943c337b84d3fe7ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 17:00:23 -0500 Subject: [PATCH 13/16] Bump aioesphomeapi from 41.10.0 to 41.11.0 (#10942) 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 42db2cc56f..0b6820e7b5 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==20250904.0 -aioesphomeapi==41.10.0 +aioesphomeapi==41.11.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 7703cabb7b666379e259dd94e850db401a50ae16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 30 Sep 2025 03:29:16 +0100 Subject: [PATCH 14/16] [voice_assistant] Fix wakeword string being reset while referenced (#10945) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/voice_assistant/voice_assistant.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 743c90e700..a0cf1a155b 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -242,7 +242,6 @@ void VoiceAssistant::loop() { msg.flags = flags; msg.audio_settings = audio_settings; msg.set_wake_word_phrase(StringRef(this->wake_word_)); - this->wake_word_ = ""; // Reset media player state tracking #ifdef USE_MEDIA_PLAYER From 83d86c8c59b0ab1a47cf083de8e2f66e33a7a772 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 22:46:47 -0500 Subject: [PATCH 15/16] [ota] Complete non-blocking authentication implementation (#10912) --- .../components/esphome/ota/ota_esphome.cpp | 627 ++++++++++++------ esphome/components/esphome/ota/ota_esphome.h | 53 +- esphome/core/defines.h | 1 + 3 files changed, 462 insertions(+), 219 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6ffeeedb1a..f1506f066c 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,11 +1,13 @@ #include "ota_esphome.h" #ifdef USE_OTA +#ifdef USE_OTA_PASSWORD #ifdef USE_OTA_MD5 #include "esphome/components/md5/md5.h" #endif #ifdef USE_OTA_SHA256 #include "esphome/components/sha256/sha256.h" #endif +#endif #include "esphome/components/network/util.h" #include "esphome/components/ota/ota_backend.h" #include "esphome/components/ota/ota_backend_arduino_esp32.h" @@ -26,9 +28,19 @@ namespace esphome { static const char *const TAG = "esphome.ota"; static constexpr uint16_t OTA_BLOCK_SIZE = 8192; +static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size for OTA data transfer static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +#ifdef USE_OTA_PASSWORD +#ifdef USE_OTA_MD5 +static constexpr size_t MD5_HEX_SIZE = 32; // MD5 hash as hex string (16 bytes * 2) +#endif +#ifdef USE_OTA_SHA256 +static constexpr size_t SHA256_HEX_SIZE = 64; // SHA256 hash as hex string (32 bytes * 2) +#endif +#endif // USE_OTA_PASSWORD + void ESPHomeOTAComponent::setup() { #ifdef USE_OTA_STATE_CALLBACK ota::register_ota_platform(this); @@ -69,7 +81,7 @@ void ESPHomeOTAComponent::setup() { return; } - err = this->server_->listen(4); + err = this->server_->listen(1); // Only one client at a time if (err != 0) { this->log_socket_error_(LOG_STR("listen")); this->mark_failed(); @@ -112,11 +124,11 @@ static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; #define ALLOW_OTA_DOWNGRADE_MD5 void ESPHomeOTAComponent::handle_handshake_() { - /// Handle the initial OTA handshake. + /// Handle the OTA handshake and authentication. /// /// This method is non-blocking and will return immediately if no data is available. - /// It reads all 5 magic bytes (0x6C, 0x26, 0xF7, 0x5C, 0x45) non-blocking - /// before proceeding to handle_data_(). A 10-second timeout is enforced from initial connection. + /// It manages the state machine through connection, magic bytes validation, feature + /// negotiation, and authentication before entering the blocking data transfer phase. if (this->client_ == nullptr) { // We already checked server_->ready() in loop(), so we can accept directly @@ -141,7 +153,8 @@ void ESPHomeOTAComponent::handle_handshake_() { } this->log_start_(LOG_STR("handshake")); this->client_connect_time_ = App.get_loop_component_start_time(); - this->magic_buf_pos_ = 0; // Reset magic buffer position + this->handshake_buf_pos_ = 0; // Reset handshake buffer position + this->ota_state_ = OTAState::MAGIC_READ; } // Check for handshake timeout @@ -152,46 +165,99 @@ void ESPHomeOTAComponent::handle_handshake_() { return; } - // Try to read remaining magic bytes - if (this->magic_buf_pos_ < 5) { - // Read as many bytes as available - uint8_t bytes_to_read = 5 - this->magic_buf_pos_; - ssize_t read = this->client_->read(this->magic_buf_ + this->magic_buf_pos_, bytes_to_read); - - if (read == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - return; // No data yet, try again next loop - } - - if (read <= 0) { - // Error or connection closed - if (read == -1) { - this->log_socket_error_(LOG_STR("reading magic bytes")); - } else { - ESP_LOGW(TAG, "Remote closed during handshake"); + switch (this->ota_state_) { + case OTAState::MAGIC_READ: { + // Try to read remaining magic bytes (5 total) + if (!this->try_read_(5, LOG_STR("read magic"))) { + return; } - this->cleanup_connection_(); - return; + + // Validate magic bytes + static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; + if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) { + ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0], + this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_MAGIC); + return; + } + + // Magic bytes valid, move to next state + this->transition_ota_state_(OTAState::MAGIC_ACK); + this->handshake_buf_[0] = ota::OTA_RESPONSE_OK; + this->handshake_buf_[1] = USE_OTA_VERSION; + [[fallthrough]]; } - this->magic_buf_pos_ += read; - } - - // Check if we have all 5 magic bytes - if (this->magic_buf_pos_ == 5) { - // Validate magic bytes - static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; - if (memcmp(this->magic_buf_, MAGIC_BYTES, 5) != 0) { - ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->magic_buf_[0], - this->magic_buf_[1], this->magic_buf_[2], this->magic_buf_[3], this->magic_buf_[4]); - // Send error response (non-blocking, best effort) - uint8_t error = static_cast(ota::OTA_RESPONSE_ERROR_MAGIC); - this->client_->write(&error, 1); - this->cleanup_connection_(); - return; + case OTAState::MAGIC_ACK: { + // Send OK and version - 2 bytes + if (!this->try_write_(2, LOG_STR("ack magic"))) { + return; + } + // All bytes sent, create backend and move to next state + this->backend_ = ota::make_ota_backend(); + this->transition_ota_state_(OTAState::FEATURE_READ); + [[fallthrough]]; } - // All 5 magic bytes are valid, continue with data handling - this->handle_data_(); + case OTAState::FEATURE_READ: { + // Read features - 1 byte + if (!this->try_read_(1, LOG_STR("read feature"))) { + return; + } + this->ota_features_ = this->handshake_buf_[0]; + ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); + this->transition_ota_state_(OTAState::FEATURE_ACK); + this->handshake_buf_[0] = + ((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression()) + ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION + : ota::OTA_RESPONSE_HEADER_OK; + [[fallthrough]]; + } + + case OTAState::FEATURE_ACK: { + // Acknowledge header - 1 byte + if (!this->try_write_(1, LOG_STR("ack feature"))) { + return; + } +#ifdef USE_OTA_PASSWORD + // If password is set, move to auth phase + if (!this->password_.empty()) { + this->transition_ota_state_(OTAState::AUTH_SEND); + } else +#endif + { + // No password, move directly to data phase + this->transition_ota_state_(OTAState::DATA); + } + [[fallthrough]]; + } + +#ifdef USE_OTA_PASSWORD + case OTAState::AUTH_SEND: { + // Non-blocking authentication send + if (!this->handle_auth_send_()) { + return; + } + this->transition_ota_state_(OTAState::AUTH_READ); + [[fallthrough]]; + } + + case OTAState::AUTH_READ: { + // Non-blocking authentication read & verify + if (!this->handle_auth_read_()) { + return; + } + this->transition_ota_state_(OTAState::DATA); + [[fallthrough]]; + } +#endif + + case OTAState::DATA: + this->handle_data_(); + return; + + default: + break; } } @@ -199,114 +265,21 @@ void ESPHomeOTAComponent::handle_data_() { /// Handle the OTA data transfer and update process. /// /// This method is blocking and will not return until the OTA update completes, - /// fails, or times out. It handles authentication, receives the firmware data, - /// writes it to flash, and reboots on success. + /// fails, or times out. It receives the firmware data, writes it to flash, + /// and reboots on success. + /// + /// Authentication has already been handled in the non-blocking states AUTH_SEND/AUTH_READ. ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; bool update_started = false; size_t total = 0; uint32_t last_progress = 0; - uint8_t buf[1024]; + uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; - uint8_t ota_features; - std::unique_ptr backend; - (void) ota_features; #if USE_OTA_VERSION == 2 size_t size_acknowledged = 0; #endif - // Send OK and version - 2 bytes - buf[0] = ota::OTA_RESPONSE_OK; - buf[1] = USE_OTA_VERSION; - this->writeall_(buf, 2); - - backend = ota::make_ota_backend(); - - // Read features - 1 byte - if (!this->readall_(buf, 1)) { - this->log_read_error_(LOG_STR("features")); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - ota_features = buf[0]; // NOLINT - ESP_LOGV(TAG, "Features: 0x%02X", ota_features); - - // Acknowledge header - 1 byte - buf[0] = ota::OTA_RESPONSE_HEADER_OK; - if ((ota_features & FEATURE_SUPPORTS_COMPRESSION) != 0 && backend->supports_compression()) { - buf[0] = ota::OTA_RESPONSE_SUPPORTS_COMPRESSION; - } - - this->writeall_(buf, 1); - -#ifdef USE_OTA_PASSWORD - if (!this->password_.empty()) { - bool auth_success = false; - -#ifdef USE_OTA_SHA256 - // SECURITY HARDENING: Prefer SHA256 authentication on platforms that support it. - // - // This is a hardening measure to prevent future downgrade attacks where an attacker - // could force the use of MD5 authentication by manipulating the feature flags. - // - // While MD5 is currently still acceptable for our OTA authentication use case - // (where the password is a shared secret and we're only authenticating, not - // encrypting), at some point in the future MD5 will likely become so weak that - // it could be practically attacked. - // - // We enforce SHA256 now on capable platforms because: - // 1. We can't retroactively update device firmware in the field - // 2. Clients (like esphome CLI) can always be updated to support SHA256 - // 3. This prevents any possibility of downgrade attacks in the future - // - // Devices that don't support SHA256 (due to platform limitations) will - // continue to use MD5 as their only option (see #else branch below). - - bool client_supports_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; - -#ifdef ALLOW_OTA_DOWNGRADE_MD5 - // Temporary compatibility mode: Allow MD5 for ~3 versions to enable OTA downgrades - // This prevents users from being locked out if they need to downgrade after updating - // TODO: Remove this entire ifdef block in 2026.1.0 - if (client_supports_sha256) { - sha256::SHA256 sha_hasher; - auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, - LOG_STR("SHA256"), sbuf); - } else { -#ifdef USE_OTA_MD5 - ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); - md5::MD5Digest md5_hasher; - auth_success = - this->perform_hash_auth_(&md5_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); -#endif // USE_OTA_MD5 - } -#else - // Strict mode: SHA256 required on capable platforms (future default) - if (!client_supports_sha256) { - ESP_LOGW(TAG, "Client requires SHA256"); - error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - sha256::SHA256 sha_hasher; - auth_success = this->perform_hash_auth_(&sha_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_SHA256_AUTH, - LOG_STR("SHA256"), sbuf); -#endif // ALLOW_OTA_DOWNGRADE_MD5 -#else - // Platform only supports MD5 - use it as the only available option - // This is not a security downgrade as the platform cannot support SHA256 -#ifdef USE_OTA_MD5 - md5::MD5Digest md5_hasher; - auth_success = - this->perform_hash_auth_(&md5_hasher, this->password_, ota::OTA_RESPONSE_REQUEST_AUTH, LOG_STR("MD5"), sbuf); -#endif // USE_OTA_MD5 -#endif // USE_OTA_SHA256 - - if (!auth_success) { - error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - } -#endif // USE_OTA_PASSWORD - // Acknowledge auth OK - 1 byte buf[0] = ota::OTA_RESPONSE_AUTH_OK; this->writeall_(buf, 1); @@ -334,7 +307,7 @@ void ESPHomeOTAComponent::handle_data_() { #endif // This will block for a few seconds as it locks flash - error_code = backend->begin(ota_size); + error_code = this->backend_->begin(ota_size); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) update_started = true; @@ -350,7 +323,7 @@ void ESPHomeOTAComponent::handle_data_() { } sbuf[32] = '\0'; ESP_LOGV(TAG, "Update: Binary MD5 is %s", sbuf); - backend->set_update_md5(sbuf); + this->backend_->set_update_md5(sbuf); // Acknowledge MD5 OK - 1 byte buf[0] = ota::OTA_RESPONSE_BIN_MD5_OK; @@ -358,26 +331,24 @@ void ESPHomeOTAComponent::handle_data_() { while (total < ota_size) { // TODO: timeout check - size_t requested = std::min(sizeof(buf), ota_size - total); + size_t remaining = ota_size - total; + size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; ssize_t read = this->client_->read(buf, requested); if (read == -1) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (this->would_block_(errno)) { this->yield_and_feed_watchdog_(); continue; } - ESP_LOGW(TAG, "Read error, errno %d", errno); + ESP_LOGW(TAG, "Read err %d", errno); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } else if (read == 0) { - // $ man recv - // "When a stream socket peer has performed an orderly shutdown, the return value will - // be 0 (the traditional "end-of-file" return)." - ESP_LOGW(TAG, "Remote closed connection"); + ESP_LOGW(TAG, "Remote closed"); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - error_code = backend->write(buf, read); + error_code = this->backend_->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Flash write error, code: %d", error_code); + ESP_LOGW(TAG, "Flash write err %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } total += read; @@ -406,9 +377,9 @@ void ESPHomeOTAComponent::handle_data_() { buf[0] = ota::OTA_RESPONSE_RECEIVE_OK; this->writeall_(buf, 1); - error_code = backend->end(); + error_code = this->backend_->end(); if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Error ending update! code: %d", error_code); + ESP_LOGW(TAG, "End update err %d", error_code); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -437,8 +408,8 @@ error: this->writeall_(buf, 1); this->cleanup_connection_(); - if (backend != nullptr && update_started) { - backend->abort(); + if (this->backend_ != nullptr && update_started) { + this->backend_->abort(); } this->status_momentary_error("onerror", 5000); @@ -459,12 +430,12 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { ssize_t read = this->client_->read(buf + at, len - at); if (read == -1) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { - ESP_LOGW(TAG, "Error reading %d bytes, errno %d", len, errno); + if (!this->would_block_(errno)) { + ESP_LOGW(TAG, "Read err %d bytes, errno %d", len, errno); return false; } } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed connection"); + ESP_LOGW(TAG, "Remote closed"); return false; } else { at += read; @@ -486,8 +457,8 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { ssize_t written = this->client_->write(buf + at, len - at); if (written == -1) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { - ESP_LOGW(TAG, "Error writing %d bytes, errno %d", len, errno); + if (!this->would_block_(errno)) { + ESP_LOGW(TAG, "Write err %d bytes, errno %d", len, errno); return false; } } else { @@ -512,11 +483,74 @@ void ESPHomeOTAComponent::log_start_(const LogString *phase) { ESP_LOGD(TAG, "Starting %s from %s", LOG_STR_ARG(phase), this->client_->getpeername().c_str()); } +void ESPHomeOTAComponent::log_remote_closed_(const LogString *during) { + ESP_LOGW(TAG, "Remote closed at %s", LOG_STR_ARG(during)); +} + +bool ESPHomeOTAComponent::handle_read_error_(ssize_t read, const LogString *desc) { + if (read == -1 && this->would_block_(errno)) { + return false; // No data yet, try again next loop + } + + if (read <= 0) { + read == 0 ? this->log_remote_closed_(desc) : this->log_socket_error_(desc); + this->cleanup_connection_(); + return false; + } + return true; +} + +bool ESPHomeOTAComponent::handle_write_error_(ssize_t written, const LogString *desc) { + if (written == -1) { + if (this->would_block_(errno)) { + return false; // Try again next loop + } + this->log_socket_error_(desc); + this->cleanup_connection_(); + return false; + } + return true; +} + +bool ESPHomeOTAComponent::try_read_(size_t to_read, const LogString *desc) { + // Read bytes into handshake buffer, starting at handshake_buf_pos_ + size_t bytes_to_read = to_read - this->handshake_buf_pos_; + ssize_t read = this->client_->read(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_read); + + if (!this->handle_read_error_(read, desc)) { + return false; + } + + this->handshake_buf_pos_ += read; + // Return true only if we have all the requested bytes + return this->handshake_buf_pos_ >= to_read; +} + +bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *desc) { + // Write bytes from handshake buffer, starting at handshake_buf_pos_ + size_t bytes_to_write = to_write - this->handshake_buf_pos_; + ssize_t written = this->client_->write(this->handshake_buf_ + this->handshake_buf_pos_, bytes_to_write); + + if (!this->handle_write_error_(written, desc)) { + return false; + } + + this->handshake_buf_pos_ += written; + // Return true only if we have written all the requested bytes + return this->handshake_buf_pos_ >= to_write; +} + void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; this->client_connect_time_ = 0; - this->magic_buf_pos_ = 0; + this->handshake_buf_pos_ = 0; + this->ota_state_ = OTAState::IDLE; + this->ota_features_ = 0; + this->backend_ = nullptr; +#ifdef USE_OTA_PASSWORD + this->cleanup_auth_(); +#endif } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { @@ -525,82 +559,247 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { } #ifdef USE_OTA_PASSWORD -void ESPHomeOTAComponent::log_auth_warning_(const LogString *action, const LogString *hash_name) { - ESP_LOGW(TAG, "Auth: %s %s failed", LOG_STR_ARG(action), LOG_STR_ARG(hash_name)); +void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); } + +bool ESPHomeOTAComponent::select_auth_type_() { +#ifdef USE_OTA_SHA256 + bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + +#ifdef ALLOW_OTA_DOWNGRADE_MD5 + // Allow fallback to MD5 if client doesn't support SHA256 + if (client_supports_sha256) { + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + return true; + } +#ifdef USE_OTA_MD5 + this->log_auth_warning_(LOG_STR("Using deprecated MD5")); + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; + return true; +#else + this->log_auth_warning_(LOG_STR("SHA256 required")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; +#endif // USE_OTA_MD5 + +#else // !ALLOW_OTA_DOWNGRADE_MD5 + // Require SHA256 + if (!client_supports_sha256) { + this->log_auth_warning_(LOG_STR("SHA256 required")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; + } + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + return true; +#endif // ALLOW_OTA_DOWNGRADE_MD5 + +#else // !USE_OTA_SHA256 +#ifdef USE_OTA_MD5 + // Only MD5 available + this->auth_type_ = ota::OTA_RESPONSE_REQUEST_AUTH; + return true; +#else + // No auth methods available + this->log_auth_warning_(LOG_STR("No auth methods available")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; +#endif // USE_OTA_MD5 +#endif // USE_OTA_SHA256 } -// Non-template function definition to reduce binary size -bool ESPHomeOTAComponent::perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, - const LogString *name, char *buf) { - // Get sizes from the hasher - const size_t hex_size = hasher->get_size() * 2; // Hex is twice the byte size - const size_t nonce_len = hasher->get_size() / 4; // Nonce is 1/4 of hash size in bytes +bool ESPHomeOTAComponent::handle_auth_send_() { + // Initialize auth buffer if not already done + if (!this->auth_buf_) { + // Select auth type based on client capabilities and configuration + if (!this->select_auth_type_()) { + return false; + } - // Use the provided buffer for all hex operations + // Generate nonce with appropriate hasher + bool success = false; +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + sha256::SHA256 sha_hasher; + success = this->prepare_auth_nonce_(&sha_hasher); + } +#endif +#ifdef USE_OTA_MD5 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_AUTH) { + md5::MD5Digest md5_hasher; + success = this->prepare_auth_nonce_(&md5_hasher); + } +#endif - // Small stack buffer for nonce seed bytes - uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) - - // Send auth request type - this->writeall_(&auth_request, 1); - - hasher->init(); - - // Generate nonce seed bytes using random_bytes - if (!random_bytes(nonce_bytes, nonce_len)) { - this->log_auth_warning_(LOG_STR("Random bytes generation failed"), name); - return false; + if (!success) { + return false; + } } - hasher->add(nonce_bytes, nonce_len); - hasher->calculate(); - // Generate and send nonce - hasher->get_hex(buf); - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Nonce is %s", LOG_STR_ARG(name), buf); + // Try to write auth_type + nonce + size_t hex_size = this->get_auth_hex_size_(); + const size_t to_write = 1 + hex_size; + size_t remaining = to_write - this->auth_buf_pos_; - if (!this->writeall_(reinterpret_cast(buf), hex_size)) { - this->log_auth_warning_(LOG_STR("Writing nonce"), name); + ssize_t written = this->client_->write(this->auth_buf_.get() + this->auth_buf_pos_, remaining); + if (!this->handle_write_error_(written, LOG_STR("ack auth"))) { return false; } - // Start challenge: password + nonce - hasher->init(); - hasher->add(password.c_str(), password.length()); - hasher->add(buf, hex_size); + this->auth_buf_pos_ += written; - // Read cnonce and add to hash - if (!this->readall_(reinterpret_cast(buf), hex_size)) { - this->log_auth_warning_(LOG_STR("Reading cnonce"), name); + // Check if we still have more to write + if (this->auth_buf_pos_ < to_write) { + return false; // More to write, try again next loop + } + + // All written, prepare for reading phase + this->auth_buf_pos_ = 0; + return true; +} + +bool ESPHomeOTAComponent::handle_auth_read_() { + size_t hex_size = this->get_auth_hex_size_(); + const size_t to_read = hex_size * 2; // CNonce + Response + + // Try to read remaining bytes (CNonce + Response) + // We read cnonce+response starting at offset 1+hex_size (after auth_type and our nonce) + size_t cnonce_offset = 1 + hex_size; // Offset where cnonce should be stored in buffer + size_t remaining = to_read - this->auth_buf_pos_; + ssize_t read = this->client_->read(this->auth_buf_.get() + cnonce_offset + this->auth_buf_pos_, remaining); + + if (!this->handle_read_error_(read, LOG_STR("read auth"))) { return false; } - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s CNonce is %s", LOG_STR_ARG(name), buf); - hasher->add(buf, hex_size); - hasher->calculate(); + this->auth_buf_pos_ += read; - // Log expected result (digest is already in hasher) - hasher->get_hex(buf); - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Result is %s", LOG_STR_ARG(name), buf); - - // Read response into the buffer - if (!this->readall_(reinterpret_cast(buf), hex_size)) { - this->log_auth_warning_(LOG_STR("Reading response"), name); - return false; + // Check if we still need more data + if (this->auth_buf_pos_ < to_read) { + return false; // More to read, try again next loop } - buf[hex_size] = '\0'; - ESP_LOGV(TAG, "Auth: %s Response is %s", LOG_STR_ARG(name), buf); - // Compare response directly with digest in hasher - bool matches = hasher->equals_hex(buf); + // We have all the data, verify it + bool matches = false; + +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + sha256::SHA256 sha_hasher; + matches = this->verify_hash_auth_(&sha_hasher, hex_size); + } +#endif +#ifdef USE_OTA_MD5 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_AUTH) { + md5::MD5Digest md5_hasher; + matches = this->verify_hash_auth_(&md5_hasher, hex_size); + } +#endif if (!matches) { - this->log_auth_warning_(LOG_STR("Password mismatch"), name); + this->log_auth_warning_(LOG_STR("Password mismatch")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_AUTH_INVALID); + return false; } - return matches; + // Authentication successful - clean up auth state + this->cleanup_auth_(); + + return true; +} + +bool ESPHomeOTAComponent::prepare_auth_nonce_(HashBase *hasher) { + // Calculate required buffer size using the hasher + const size_t hex_size = hasher->get_size() * 2; + const size_t nonce_len = hasher->get_size() / 4; + + // Buffer layout after AUTH_READ completes: + // [0]: auth_type (1 byte) + // [1...hex_size]: nonce (hex_size bytes) - our random nonce sent in AUTH_SEND + // [1+hex_size...1+2*hex_size-1]: cnonce (hex_size bytes) - client's nonce + // [1+2*hex_size...1+3*hex_size-1]: response (hex_size bytes) - client's hash + // Total: 1 + 3*hex_size + const size_t auth_buf_size = 1 + 3 * hex_size; + this->auth_buf_ = std::make_unique(auth_buf_size); + this->auth_buf_pos_ = 0; + + // Generate nonce + char *buf = reinterpret_cast(this->auth_buf_.get() + 1); + if (!random_bytes(reinterpret_cast(buf), nonce_len)) { + this->log_auth_warning_(LOG_STR("Random failed")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN); + return false; + } + + hasher->init(); + hasher->add(buf, nonce_len); + hasher->calculate(); + + // Prepare buffer: auth_type (1 byte) + nonce (hex_size bytes) + this->auth_buf_[0] = this->auth_type_; + hasher->get_hex(buf); + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char log_buf[hex_size + 1]; + // Log nonce for debugging + memcpy(log_buf, buf, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Nonce is %s", log_buf); +#endif + + return true; +} + +bool ESPHomeOTAComponent::verify_hash_auth_(HashBase *hasher, size_t hex_size) { + // Get pointers to the data in the buffer (see prepare_auth_nonce_ for buffer layout) + const char *nonce = reinterpret_cast(this->auth_buf_.get() + 1); // Skip auth_type byte + const char *cnonce = nonce + hex_size; // CNonce immediately follows nonce + const char *response = cnonce + hex_size; // Response immediately follows cnonce + + // Calculate expected hash: password + nonce + cnonce + hasher->init(); + hasher->add(this->password_.c_str(), this->password_.length()); + hasher->add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer) + hasher->calculate(); + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char log_buf[hex_size + 1]; + // Log CNonce + memcpy(log_buf, cnonce, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: CNonce is %s", log_buf); + + // Log computed hash + hasher->get_hex(log_buf); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Result is %s", log_buf); + + // Log received response + memcpy(log_buf, response, hex_size); + log_buf[hex_size] = '\0'; + ESP_LOGV(TAG, "Auth: Response is %s", log_buf); +#endif + + // Compare response + return hasher->equals_hex(response); +} + +size_t ESPHomeOTAComponent::get_auth_hex_size_() const { +#ifdef USE_OTA_SHA256 + if (this->auth_type_ == ota::OTA_RESPONSE_REQUEST_SHA256_AUTH) { + return SHA256_HEX_SIZE; + } +#endif +#ifdef USE_OTA_MD5 + return MD5_HEX_SIZE; +#else +#ifndef USE_OTA_SHA256 +#error "Either USE_OTA_MD5 or USE_OTA_SHA256 must be defined when USE_OTA_PASSWORD is enabled" +#endif +#endif +} + +void ESPHomeOTAComponent::cleanup_auth_() { + this->auth_buf_ = nullptr; + this->auth_buf_pos_ = 0; + this->auth_type_ = 0; } #endif // USE_OTA_PASSWORD diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 5bacb60706..1e26494fd0 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -14,6 +14,18 @@ namespace esphome { /// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA. class ESPHomeOTAComponent : public ota::OTAComponent { public: + enum class OTAState : uint8_t { + IDLE, + MAGIC_READ, // Reading magic bytes + MAGIC_ACK, // Sending OK and version after magic bytes + FEATURE_READ, // Reading feature flags from client + FEATURE_ACK, // Sending feature acknowledgment +#ifdef USE_OTA_PASSWORD + AUTH_SEND, // Sending authentication request + AUTH_READ, // Reading authentication data +#endif // USE_OTA_PASSWORD + DATA, // BLOCKING! Processing OTA data (update, etc.) + }; #ifdef USE_OTA_PASSWORD void set_auth_password(const std::string &password) { password_ = password; } #endif // USE_OTA_PASSWORD @@ -32,16 +44,39 @@ class ESPHomeOTAComponent : public ota::OTAComponent { void handle_handshake_(); void handle_data_(); #ifdef USE_OTA_PASSWORD - bool perform_hash_auth_(HashBase *hasher, const std::string &password, uint8_t auth_request, const LogString *name, - char *buf); - void log_auth_warning_(const LogString *action, const LogString *hash_name); + bool handle_auth_send_(); + bool handle_auth_read_(); + bool select_auth_type_(); + bool prepare_auth_nonce_(HashBase *hasher); + bool verify_hash_auth_(HashBase *hasher, size_t hex_size); + size_t get_auth_hex_size_() const; + void cleanup_auth_(); + void log_auth_warning_(const LogString *msg); #endif // USE_OTA_PASSWORD bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); + + bool try_read_(size_t to_read, const LogString *desc); + bool try_write_(size_t to_write, const LogString *desc); + + inline bool would_block_(int error_code) const { return error_code == EAGAIN || error_code == EWOULDBLOCK; } + bool handle_read_error_(ssize_t read, const LogString *desc); + bool handle_write_error_(ssize_t written, const LogString *desc); + inline void transition_ota_state_(OTAState next_state) { + this->ota_state_ = next_state; + this->handshake_buf_pos_ = 0; // Reset buffer position for next state + } + void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); void log_start_(const LogString *phase); + void log_remote_closed_(const LogString *during); void cleanup_connection_(); + inline void send_error_and_cleanup_(ota::OTAResponseTypes error) { + uint8_t error_byte = static_cast(error); + this->client_->write(&error_byte, 1); // Best effort, non-blocking + this->cleanup_connection_(); + } void yield_and_feed_watchdog_(); #ifdef USE_OTA_PASSWORD @@ -50,11 +85,19 @@ class ESPHomeOTAComponent : public ota::OTAComponent { std::unique_ptr server_; std::unique_ptr client_; + std::unique_ptr backend_; uint32_t client_connect_time_{0}; uint16_t port_; - uint8_t magic_buf_[5]; - uint8_t magic_buf_pos_{0}; + uint8_t handshake_buf_[5]; + OTAState ota_state_{OTAState::IDLE}; + uint8_t handshake_buf_pos_{0}; + uint8_t ota_features_{0}; +#ifdef USE_OTA_PASSWORD + std::unique_ptr auth_buf_; + uint8_t auth_buf_pos_{0}; + uint8_t auth_type_{0}; // Store auth type to know which hasher to use +#endif // USE_OTA_PASSWORD }; } // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 2449b1249d..7fc42ea334 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -126,6 +126,7 @@ #define USE_OTA_MD5 #define USE_OTA_PASSWORD #define USE_OTA_SHA256 +#define ALLOW_OTA_DOWNGRADE_MD5 #define USE_OTA_STATE_CALLBACK #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE From 96868aa7549cdea7f214c1e2f2a051b8b72eb040 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Sep 2025 22:52:47 -0500 Subject: [PATCH 16/16] [socket] Reduce memory overhead for LWIP TCP accept queue on ESP8266/RP2040 (#10938) --- .../components/socket/lwip_raw_tcp_impl.cpp | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 2d64a275df..3377682474 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -9,7 +9,7 @@ #include "lwip/tcp.h" #include #include -#include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -50,12 +50,18 @@ class LWIPRawImpl : public Socket { errno = EBADF; return nullptr; } - if (accepted_sockets_.empty()) { + if (this->accepted_socket_count_ == 0) { errno = EWOULDBLOCK; return nullptr; } - std::unique_ptr sock = std::move(accepted_sockets_.front()); - accepted_sockets_.pop(); + // Take from front for FIFO ordering + std::unique_ptr sock = std::move(this->accepted_sockets_[0]); + // Shift remaining sockets forward + for (uint8_t i = 1; i < this->accepted_socket_count_; i++) { + this->accepted_sockets_[i - 1] = std::move(this->accepted_sockets_[i]); + } + this->accepted_socket_count_--; + LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_); if (addr != nullptr) { sock->getpeername(addr, addrlen); } @@ -494,9 +500,18 @@ class LWIPRawImpl : public Socket { // nothing to do here, we just don't push it to the queue return ERR_OK; } + // Check if we've reached the maximum accept queue size + if (this->accepted_socket_count_ >= MAX_ACCEPTED_SOCKETS) { + LWIP_LOG("Rejecting connection, queue full (%d)", this->accepted_socket_count_); + // Abort the connection when queue is full + tcp_abort(newpcb); + // Must return ERR_ABRT since we called tcp_abort() + return ERR_ABRT; + } auto sock = make_unique(family_, newpcb); sock->init(); - accepted_sockets_.push(std::move(sock)); + this->accepted_sockets_[this->accepted_socket_count_++] = std::move(sock); + LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); return ERR_OK; } void err_fn(err_t err) { @@ -587,7 +602,20 @@ class LWIPRawImpl : public Socket { } struct tcp_pcb *pcb_; - std::queue> accepted_sockets_; + // Accept queue - holds incoming connections briefly until the event loop calls accept() + // This is NOT a connection pool - just a temporary queue between LWIP callbacks and the main loop + // 3 slots is plenty since connections are pulled out quickly by the event loop + // + // Memory analysis: std::array<3> vs original std::queue implementation: + // - std::queue uses std::deque internally which on 32-bit systems needs: + // 24 bytes (deque object) + 32+ bytes (map array) + heap allocations + // Total: ~56+ bytes minimum, plus heap fragmentation + // - std::array<3>: 12 bytes fixed (3 pointers × 4 bytes) + // Saves ~44+ bytes RAM per listening socket + avoids ALL heap allocations + // Used on ESP8266 and RP2040 (platforms using LWIP_TCP implementation) + static constexpr size_t MAX_ACCEPTED_SOCKETS = 3; + std::array, MAX_ACCEPTED_SOCKETS> accepted_sockets_; + uint8_t accepted_socket_count_ = 0; // Number of sockets currently in queue bool rx_closed_ = false; pbuf *rx_buf_ = nullptr; size_t rx_buf_offset_ = 0;