From 9289fc36f79222af346394b096ad629b683234d8 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:48:32 +0100 Subject: [PATCH 1/9] [nextion] Do not set alternative baud rate when not specified or `<= 0` (#12097) --- esphome/components/nextion/nextion_upload_arduino.cpp | 3 +++ esphome/components/nextion/nextion_upload_idf.cpp | 3 +++ 2 files changed, 6 insertions(+) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index b4d217d7a..baea93872 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -174,6 +174,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Check if baud rate is supported this->original_baud_rate_ = this->parent_->get_baud_rate(); + if (baud_rate <= 0) { + baud_rate = this->original_baud_rate_; + } ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client diff --git a/esphome/components/nextion/nextion_upload_idf.cpp b/esphome/components/nextion/nextion_upload_idf.cpp index 3b0d65643..942e3dd6c 100644 --- a/esphome/components/nextion/nextion_upload_idf.cpp +++ b/esphome/components/nextion/nextion_upload_idf.cpp @@ -177,6 +177,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Check if baud rate is supported this->original_baud_rate_ = this->parent_->get_baud_rate(); + if (baud_rate <= 0) { + baud_rate = this->original_baud_rate_; + } ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client From acdcd56395fdcad51c652c10b6e289196403ef1b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:14:53 -0500 Subject: [PATCH 2/9] [esp32] Fix platformio flash size print (#12099) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 59c602933..d372af3e6 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -854,6 +854,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None: async def to_code(config): cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) + cg.add_platformio_option( + "board_upload.maximum_size", + int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, + ) cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) From 278f12fb99fd83eefaf261e2ccab396c0cb38d67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 10:30:01 -0600 Subject: [PATCH 3/9] [script] Fix script.wait hanging when triggered from on_boot (#12102) --- esphome/components/script/script.h | 7 +- .../fixtures/script_wait_on_boot.yaml | 54 ++++++++ tests/integration/test_script_wait_on_boot.py | 130 ++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/script_wait_on_boot.yaml create mode 100644 tests/integration/test_script_wait_on_boot.py diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index d60ed657f..3a0823f3c 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -278,7 +278,12 @@ template class ScriptWaitAction : public Action, void setup() override { // Start with loop disabled - only enable when there's work to do - this->disable_loop(); + // IMPORTANT: Only disable if num_running_ is 0, otherwise play_complex() was already + // called before our setup() (e.g., from on_boot trigger at same priority level) + // and we must not undo its enable_loop() call + if (this->num_running_ == 0) { + this->disable_loop(); + } } void play_complex(const Ts &...x) override { diff --git a/tests/integration/fixtures/script_wait_on_boot.yaml b/tests/integration/fixtures/script_wait_on_boot.yaml new file mode 100644 index 000000000..8736b0229 --- /dev/null +++ b/tests/integration/fixtures/script_wait_on_boot.yaml @@ -0,0 +1,54 @@ +esphome: + name: test-script-wait-on-boot + on_boot: + # Use default priority (600.0) which is same as ScriptWaitAction's setup priority + # This tests the race condition where on_boot runs before ScriptWaitAction::setup() + then: + - logger.log: "=== on_boot: Starting boot sequence ===" + - script.execute: show_start_page + - script.wait: show_start_page + - logger.log: "=== on_boot: First script completed, starting second ===" + - script.execute: flip_thru_pages + - script.wait: flip_thru_pages + - logger.log: "=== on_boot: All boot scripts completed successfully ===" + +host: + +api: + actions: + # Manual trigger for additional testing + - action: test_script_wait + then: + - logger.log: "=== Manual test: Starting ===" + - script.execute: show_start_page + - script.wait: show_start_page + - logger.log: "=== Manual test: First script completed ===" + - script.execute: flip_thru_pages + - script.wait: flip_thru_pages + - logger.log: "=== Manual test: All completed ===" + +logger: + level: DEBUG + +script: + # First script - simulates display initialization + - id: show_start_page + mode: single + then: + - logger.log: "show_start_page: Starting" + - delay: 100ms + - logger.log: "show_start_page: After delay 1" + - delay: 100ms + - logger.log: "show_start_page: Completed" + + # Second script - simulates page flip sequence + - id: flip_thru_pages + mode: single + then: + - logger.log: "flip_thru_pages: Starting" + - delay: 50ms + - logger.log: "flip_thru_pages: Page 1" + - delay: 50ms + - logger.log: "flip_thru_pages: Page 2" + - delay: 50ms + - logger.log: "flip_thru_pages: Completed" diff --git a/tests/integration/test_script_wait_on_boot.py b/tests/integration/test_script_wait_on_boot.py new file mode 100644 index 000000000..478090f78 --- /dev/null +++ b/tests/integration/test_script_wait_on_boot.py @@ -0,0 +1,130 @@ +"""Integration test for script.wait during on_boot (issue #12043). + +This test verifies that script.wait works correctly when triggered from on_boot. +The issue was that ScriptWaitAction::setup() unconditionally disabled the loop, +even if play_complex() had already been called (from an on_boot trigger at the +same priority level) and enabled it. + +The race condition occurs because: +1. on_boot's default priority is 600.0 (setup_priority::DATA) +2. ScriptWaitAction's default setup priority is also DATA (600.0) +3. When they have the same priority, if on_boot runs first and triggers a script, + ScriptWaitAction::play_complex() enables the loop +4. Then ScriptWaitAction::setup() runs and unconditionally disables the loop +5. The wait never completes because the loop is disabled + +The fix adds a conditional check (like WaitUntilAction has) to only disable the +loop in setup() if num_running_ is 0. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_wait_on_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that script.wait works correctly when triggered from on_boot. + + This reproduces issue #12043 where script.wait would hang forever when + triggered from on_boot due to a race condition in ScriptWaitAction::setup(). + """ + test_complete = asyncio.Event() + + # Track progress through the boot sequence + boot_started = False + first_script_started = False + first_script_completed = False + first_wait_returned = False + second_script_started = False + second_script_completed = False + all_completed = False + + # Patterns for boot sequence logs + boot_start_pattern = re.compile(r"on_boot: Starting boot sequence") + show_start_pattern = re.compile(r"show_start_page: Starting") + show_complete_pattern = re.compile(r"show_start_page: Completed") + first_wait_pattern = re.compile(r"on_boot: First script completed") + flip_start_pattern = re.compile(r"flip_thru_pages: Starting") + flip_complete_pattern = re.compile(r"flip_thru_pages: Completed") + all_complete_pattern = re.compile(r"on_boot: All boot scripts completed") + + def check_output(line: str) -> None: + """Check log output for boot sequence progress.""" + nonlocal boot_started, first_script_started, first_script_completed + nonlocal first_wait_returned, second_script_started, second_script_completed + nonlocal all_completed + + if boot_start_pattern.search(line): + boot_started = True + elif show_start_pattern.search(line): + first_script_started = True + elif show_complete_pattern.search(line): + first_script_completed = True + elif first_wait_pattern.search(line): + first_wait_returned = True + elif flip_start_pattern.search(line): + second_script_started = True + elif flip_complete_pattern.search(line): + second_script_completed = True + elif all_complete_pattern.search(line): + all_completed = True + test_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-script-wait-on-boot" + + # Wait for on_boot sequence to complete + # The boot sequence should complete automatically + # Timeout is generous to allow for delays in the scripts + try: + await asyncio.wait_for(test_complete.wait(), timeout=5.0) + except TimeoutError: + # Build a detailed error message showing where the boot sequence got stuck + progress = [] + if boot_started: + progress.append("boot started") + if first_script_started: + progress.append("show_start_page started") + if first_script_completed: + progress.append("show_start_page completed") + if first_wait_returned: + progress.append("first script.wait returned") + if second_script_started: + progress.append("flip_thru_pages started") + if second_script_completed: + progress.append("flip_thru_pages completed") + + if not first_wait_returned and first_script_completed: + pytest.fail( + f"Test timed out - script.wait hung after show_start_page completed! " + f"This is the issue #12043 bug. Progress: {', '.join(progress)}" + ) + else: + pytest.fail( + f"Test timed out. Progress: {', '.join(progress) if progress else 'none'}" + ) + + # Verify the complete boot sequence executed in order + assert boot_started, "on_boot did not start" + assert first_script_started, "show_start_page did not start" + assert first_script_completed, "show_start_page did not complete" + assert first_wait_returned, "First script.wait did not return" + assert second_script_started, "flip_thru_pages did not start" + assert second_script_completed, "flip_thru_pages did not complete" + assert all_completed, "Boot sequence did not complete" From 46ae6d35a237fecea76eb68a4398ac2d7765a5d5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 26 Nov 2025 05:06:42 +1000 Subject: [PATCH 4/9] [lvgl] Allow multiple widgets per grid cell (#12091) --- esphome/components/lvgl/layout.py | 9 ++++++++- tests/components/lvgl/lvgl-package.yaml | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index a6aa816fd..caa503ef0 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -36,6 +36,8 @@ from .defines import ( ) from .lv_validation import padding, size +CONF_MULTIPLE_WIDGETS_PER_CELL = "multiple_widgets_per_cell" + cell_alignments = LV_CELL_ALIGNMENTS.one_of grid_alignments = LV_GRID_ALIGNMENTS.one_of flex_alignments = LV_FLEX_ALIGNMENTS.one_of @@ -220,6 +222,7 @@ class GridLayout(Layout): cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments, cv.Optional(CONF_PAD_ROW): padding, cv.Optional(CONF_PAD_COLUMN): padding, + cv.Optional(CONF_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean, }, { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, @@ -263,6 +266,7 @@ class GridLayout(Layout): # should be guaranteed to be a dict at this point assert isinstance(layout, dict) assert layout.get(CONF_TYPE).lower() == TYPE_GRID + allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False) rows = len(layout[CONF_GRID_ROWS]) columns = len(layout[CONF_GRID_COLUMNS]) used_cells = [[None] * columns for _ in range(rows)] @@ -299,7 +303,10 @@ class GridLayout(Layout): f"exceeds grid size {rows}x{columns}", [CONF_WIDGETS, index], ) - if used_cells[row + i][column + j] is not None: + if ( + not allow_multiple + and used_cells[row + i][column + j] is not None + ): raise cv.Invalid( f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}", [CONF_WIDGETS, index], diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index cb5b6f59b..70afd5b3d 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -881,6 +881,7 @@ lvgl: grid_columns: [40, fr(1), fr(1)] pad_row: 6px pad_column: 0 + multiple_widgets_per_cell: true widgets: - image: grid_cell_row_pos: 0 @@ -905,6 +906,10 @@ lvgl: grid_cell_row_pos: 1 grid_cell_column_pos: 0 text: "Grid cell 1/0" + - label: + grid_cell_row_pos: 1 + grid_cell_column_pos: 0 + text: "Duplicate for 1/0" - label: styles: bdr_style grid_cell_row_pos: 1 From ae140f52e3dbfab83c516a9b5e7d1997fe7b3149 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:47:27 +1000 Subject: [PATCH 5/9] [lvgl] Fix position of errors in widget config (#12111) Co-authored-by: J. Nick Koston --- esphome/components/lvgl/schemas.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 6b77f66ab..b2d463c5f 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,6 +1,7 @@ from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation from esphome.components.time import RealTimeClock +from esphome.config_validation import prepend_path from esphome.const import ( CONF_ARGS, CONF_FORMAT, @@ -422,7 +423,10 @@ def any_widget_schema(extras=None): def validator(value): if isinstance(value, dict): # Convert to list + is_dict = True value = [{k: v} for k, v in value.items()] + else: + is_dict = False if not isinstance(value, list): raise cv.Invalid("Expected a list of widgets") result = [] @@ -443,7 +447,9 @@ def any_widget_schema(extras=None): ) # Apply custom validation value = widget_type.validate(value or {}) - result.append({key: container_validator(value)}) + path = [key] if is_dict else [index, key] + with prepend_path(path): + result.append({key: container_validator(value)}) return result return validator From 6645994700cd0c9b3bd25e2799bfdc4a2f606fb9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:01:35 -0500 Subject: [PATCH 6/9] [esp32] Fix hosted update when there is no wifi (#12123) --- .../components/esp32_hosted/update/esp32_hosted_update.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index adbcc5bf1..6f91d1b3e 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -22,6 +22,11 @@ constexpr size_t CHUNK_SIZE = 1500; void Esp32HostedUpdate::setup() { this->update_info_.title = "ESP32 Hosted Coprocessor"; + // if wifi is not present, connect to the coprocessor +#ifndef USE_WIFI + esp_hosted_connect_to_slave(); // NOLINT +#endif + // get coprocessor version esp_hosted_coprocessor_fwver_t ver_info; if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { From b4b34aee13874eb4d3ce4062fa90ff50c1636cdd Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 28 Nov 2025 02:30:03 +1100 Subject: [PATCH 7/9] [wifi] Restore blocking setup until connected for RP2040 (#12142) --- esphome/components/wifi/wifi_component.cpp | 14 ++++++++++++++ esphome/components/wifi/wifi_component.h | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index e31d7bbf3..abf62cb06 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1555,6 +1555,20 @@ void WiFiComponent::retry_connect() { } } +#ifdef USE_RP2040 +// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart +// mDNS when the network interface reconnects. However, this callback is disabled +// in the arduino-pico framework. As a workaround, we block component setup until +// WiFi is connected, ensuring mDNS.begin() is called with an active connection. + +bool WiFiComponent::can_proceed() { + if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) { + return true; + } + return this->is_connected(); +} +#endif + void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } bool WiFiComponent::is_connected() { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 2e0a9816c..28eef211d 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -280,6 +280,10 @@ class WiFiComponent : public Component { void retry_connect(); +#ifdef USE_RP2040 + bool can_proceed() override; +#endif + void set_reboot_timeout(uint32_t reboot_timeout); bool is_connected(); From d5e2543751105d90b31e4e0d6dc9bacd08ca9827 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Nov 2025 10:50:21 -0600 Subject: [PATCH 8/9] [scheduler] Fix use-after-move crash in heap operations (#12124) --- esphome/core/scheduler.cpp | 26 +++++++++++++------------- esphome/core/scheduler.h | 4 +++- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 09d50ee7c..352587bf1 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -359,8 +359,7 @@ void HOT Scheduler::call(uint32_t now) { std::unique_ptr item; { LockGuard guard{this->lock_}; - item = std::move(this->items_[0]); - this->pop_raw_(); + item = this->pop_raw_locked_(); } const char *name = item->get_name(); @@ -401,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) { // Don't run on failed components if (item->component != nullptr && item->component->is_failed()) { LockGuard guard{this->lock_}; - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); continue; } @@ -414,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) { { LockGuard guard{this->lock_}; if (is_item_removed_(item.get())) { - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -423,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) { // Single-threaded or multi-threaded with atomics: can check without lock if (is_item_removed_(item.get())) { LockGuard guard{this->lock_}; - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -443,14 +442,14 @@ void HOT Scheduler::call(uint32_t now) { LockGuard guard{this->lock_}; - auto executed_item = std::move(this->items_[0]); // Only pop after function call, this ensures we were reachable // during the function call and know if we were cancelled. - this->pop_raw_(); + auto executed_item = this->pop_raw_locked_(); if (executed_item->remove) { - // We were removed/cancelled in the function call, stop + // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; + this->recycle_item_(std::move(executed_item)); continue; } @@ -497,7 +496,7 @@ size_t HOT Scheduler::cleanup_() { return this->items_.size(); // We must hold the lock for the entire cleanup operation because: - // 1. We're modifying items_ (via pop_raw_) which requires exclusive access + // 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access // 2. We're decrementing to_remove_ which is also modified by other threads // (though all modifications are already under lock) // 3. Other threads read items_ when searching for items to cancel in cancel_item_locked_() @@ -510,17 +509,18 @@ size_t HOT Scheduler::cleanup_() { if (!item->remove) break; this->to_remove_--; - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); } return this->items_.size(); } -void HOT Scheduler::pop_raw_() { +std::unique_ptr HOT Scheduler::pop_raw_locked_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - // Instead of destroying, recycle the item - this->recycle_item_(std::move(this->items_.back())); + // Move the item out before popping - this is the item that was at the front of the heap + auto item = std::move(this->items_.back()); this->items_.pop_back(); + return item; } // Helper to execute a scheduler item diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index bea1503df..08e003c9f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -219,7 +219,9 @@ class Scheduler { // Returns the number of items remaining after cleanup // IMPORTANT: This method should only be called from the main thread (loop task). size_t cleanup_(); - void pop_raw_(); + // Remove and return the front item from the heap + // IMPORTANT: Caller must hold the scheduler lock before calling this function. + std::unique_ptr pop_raw_locked_(); private: // Helper to cancel items by name - must be called with lock held From 4115dd7222088bae60d3995c06c876be4a1b5e88 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:23:28 -0500 Subject: [PATCH 9/9] Bump version to 2025.11.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a2b6efcfa..d30bd8425 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.11.1 +PROJECT_NUMBER = 2025.11.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 f4ddd01c0..45b726e59 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.11.1" +__version__ = "2025.11.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (