From f2e013d71a8ee2040714d926671a1dd299457743 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:18:55 -1000 Subject: [PATCH 1/4] Bump bundled esphome-device-builder to 1.25.0 (#21175) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 658b8c06302..3c160153cc9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.24.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.25.0 RUN \ platformio settings set enable_telemetry No \ From 9d296a5748b71796eab58330b2f5b06e3b96ddf3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Oct 2026 01:09:13 -1000 Subject: [PATCH 2/4] [ota] Accept an unknown image size in the RP2040 backend so web_server OTA works (#21178) --- esphome/components/ota/ota_backend.h | 5 +++-- .../components/ota/ota_backend_arduino_rp2.cpp | 15 ++++++++++++--- tests/components/web_server/test.rp2040-ard.yaml | 3 +++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index a505d58efef..0c2e60cf4c8 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -98,8 +98,9 @@ enum OTAType : uint8_t { // The OTA backend method surface. Exactly one backend exists per build, // selected in ota_backend_factory.h where this concept is asserted on // make_ota_backend()'s return type. Semantics beyond the signatures: -// - begin: prepare for an image of the given size; ota_type defaults to an -// app update, so both call forms must be accepted. +// - begin: prepare for an image of the given size; 0 means the size is unknown +// (web_server upload) and the backend allows the largest image it can take. +// ota_type defaults to an app update, so both call forms must be accepted. // - set_update_md5: expected digest of the incoming image, hex string. // - write: consume the next chunk; end: finalize and mark bootable. // - abort: safe to call in any state, including after end(). diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index 48725b12657..4f393fc8f39 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -9,6 +9,12 @@ #include +// LittleFS region bounds, provided by the arduino-pico linker script. +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) +extern "C" uint8_t _FS_start; +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) +extern "C" uint8_t _FS_end; + namespace esphome::ota { static const char *const TAG = "ota"; @@ -19,8 +25,11 @@ OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } - // OTA size of 0 is not currently handled, but - // web_server is not supported for RP2040, so this is not an issue. + // 0 means the size is unknown (web_server upload). The updater only checks the size + // against the LittleFS region it stages the image in, so ask for all of it. + if (image_size == 0) { + image_size = reinterpret_cast(&_FS_end) - reinterpret_cast(&_FS_start); + } bool ret = Update.begin(image_size, U_FLASH); if (ret) { rp2::preferences_prevent_write(true); @@ -34,7 +43,7 @@ OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type return OTA_RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG; if (error == UPDATE_ERROR_FLASH_CONFIG) return OTA_RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG; - if (error == UPDATE_ERROR_SPACE) + if (error == UPDATE_ERROR_SPACE || error == UPDATE_ERROR_SIZE) return OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE; ESP_LOGE(TAG, "Begin error: %d", error); diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 6a01180892a..56bd155717f 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -6,3 +6,6 @@ web_server: username: admin password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA type: basic + +ota: + - platform: web_server From 4fdce9d96be58b56aeb33027f97d36f9a181dbf7 Mon Sep 17 00:00:00 2001 From: Jeroen Date: Tue, 20 Oct 2026 22:41:10 +0200 Subject: [PATCH 3/4] [web_server_idf] Reduce loopTask stack usage for state SSE (#21109) Co-authored-by: Jeroen Jansen Co-authored-by: J. Nick Koston --- .../web_server_idf/web_server_idf.cpp | 118 ++++++++++-------- .../web_server_idf/web_server_idf.h | 11 ++ 2 files changed, 80 insertions(+), 49 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index d120e826726..f9f0dce850b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -57,7 +57,7 @@ namespace esphome::web_server_idf { static const char *const TAG = "web_server_idf"; -// Only send_json_() may hold the JSON arena: every other frame in this file is capped below one +// Only prepare_json_() may hold the JSON arena: every other frame in this file is capped below one // arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it. #if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) #pragma GCC diagnostic error "-Wstack-usage=2048" @@ -1121,68 +1121,88 @@ void AsyncEventSourceResponse::loop() { #if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) #pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the arena and the JSON buffer +#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the JSON arena #endif -bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) { - // The arena lives only in this frame, so no call chain ever holds two of them +AsyncEventSourceResponse::JsonPrepareResult AsyncEventSourceResponse::prepare_json_(void *source, + message_generator_t *generator, + char *buffer, size_t *size_out) { + // Log lines emitted while the generator runs are dropped for this session: on the large path they would fill + // tail_ before it is overwritten, and on the small path they would go out ahead of the event being built. + SendGuard guard{*this}; json::JsonArena arena; json::JsonBuilder builder(&arena); generator(this->web_server_, source, builder); - char buf[JSON_BUF_SIZE]; - const size_t len = builder.serialize_to(buf, sizeof(buf)); - if (len < sizeof(buf)) { - return this->try_send_nodefer(buf, len, "state"); + *size_out = builder.serialize_to(buffer, JSON_BUF_SIZE); + if (*size_out < JSON_BUF_SIZE) { + return JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER; } - // Too large for the stack: the tail holds the whole chunk and loop() drains it. Serialized - // JSON has no raw line break, so the body is one data line. - if (!this->ready_to_send_()) { - return false; - } - { - SendGuard guard{*this}; - char prefix[PREFIX_BUF_SIZE]; - const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true); + char prefix[PREFIX_BUF_SIZE]; + const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true); - // Grow the tail until the document fits. Nothing has reached the wire, so a document that - // cannot be held costs only this event, and the tail grown for it is released. - size_t json_len = 0; - size_t cap = std::max(JSON_BUF_SIZE * 2, this->tail_cap_); - for (;;) { - if (!this->reserve_tail_(cap)) { - this->tail_alloc_failed_(cap); - return false; // stays deferred, retried on a later pass - } - const size_t room = cap - prefix_len - SSE_SUFFIX_LEN; - json_len = builder.serialize_to(reinterpret_cast(this->tail_.get()) + prefix_len, room); - if (json_len < room) { - break; - } - if (cap >= TAIL_MAX_SIZE) { - ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", room); - this->tail_.reset(); - this->tail_cap_ = 0; - this->send_failure_started_ms_ = 0; - return true; // would never fit, reported as sent - } - cap = std::min(cap * 2, TAIL_MAX_SIZE); + // Nothing reaches the wire until the complete document and its framing are owned by the caller. + size_t json_len = 0; + size_t cap = std::max(JSON_BUF_SIZE * 2, this->tail_cap_); + for (;;) { + if (!this->reserve_tail_(cap)) { + *size_out = cap; + return JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY; } - - const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN; - write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN); - uint8_t *dst = this->tail_.get(); - std::memcpy(dst, prefix, prefix_len); - std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN); - this->tail_len_ = total; - this->tail_sent_ = 0; + const size_t room = cap - prefix_len - SSE_SUFFIX_LEN; + json_len = builder.serialize_to(reinterpret_cast(this->tail_.get()) + prefix_len, room); + if (json_len < room) { + break; + } + if (cap >= TAIL_MAX_SIZE) { + *size_out = room; + return JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE; + } + cap = std::min(cap * 2, TAIL_MAX_SIZE); } - drain_tail_(); - return true; + + const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN; + write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN); + uint8_t *dst = this->tail_.get(); + std::memcpy(dst, prefix, prefix_len); + std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN); + this->tail_len_ = total; + this->tail_sent_ = 0; + return JsonPrepareResult::JSON_PREPARE_RESULT_TAIL; } #if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__) #pragma GCC diagnostic pop #endif +bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) { + if (!this->ready_to_send_()) { + return false; + } + char buffer[JSON_BUF_SIZE]; + size_t size; + const auto result = this->prepare_json_(source, generator, buffer, &size); + switch (result) { + case JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER: + return this->try_send_nodefer(buffer, size, "state"); + case JsonPrepareResult::JSON_PREPARE_RESULT_TAIL: + this->drain_tail_(); + return true; + case JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY: { + SendGuard guard{*this}; + this->tail_alloc_failed_(size); + return false; + } + case JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE: { + SendGuard guard{*this}; + ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", size); + this->tail_.reset(); + this->tail_cap_ = 0; + this->send_failure_started_ms_ = 0; + return true; + } + } + return false; +} + void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) { // Same stall clock as a socket that stops draining, so a session cannot retry forever const uint32_t now = App.get_loop_component_start_time(); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 45128602e3a..2d13c19e101 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -317,6 +317,17 @@ class AsyncEventSourceResponse { size_t sent); // Send a state event; JSON too large for the stack buffer is serialized into tail_ instead bool send_json_(void *source, message_generator_t *generator); + enum class JsonPrepareResult : uint8_t { + JSON_PREPARE_RESULT_BUFFER, + JSON_PREPARE_RESULT_TAIL, + JSON_PREPARE_RESULT_NO_MEMORY, + JSON_PREPARE_RESULT_TOO_LARGE, + }; + // Keep the arena out of the caller's frame so it is gone before the state send. + // size_out: BUFFER = JSON length, NO_MEMORY = requested tail capacity, TOO_LARGE = available JSON room; + // unused for TAIL. + JsonPrepareResult __attribute__((noinline)) + prepare_json_(void *source, message_generator_t *generator, char *buffer, size_t *size_out); // Warn once, and close the session once the stall timeout passes with no memory for the tail void tail_alloc_failed_(size_t cap); void request_close_(); From c8df23a9cb9cb13ee199107a03ad3e313ccb3d70 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:17:51 +1300 Subject: [PATCH 4/4] Bump version to 2026.10.0b6 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 5261997847d..85cefa022e5 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.10.0b5 +PROJECT_NUMBER = 2026.10.0b6 # 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 785a4de64a3..28460480fd0 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.10.0b5" +__version__ = "2026.10.0b6" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (