diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 337064dd271..d3584380961 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -78,6 +78,20 @@ def ota_esphome_final_validate(config): else: new_ota_conf.append(ota_conf) + # BREAKING CHANGE: Only a single ESPHome OTA instance is supported. Historically + # the config layer merged multiple configs by port to accommodate users who had a + # local 'ota:' block and then imported a remote package that also declared one — + # the merge kept their build working. That merge behavior is preserved. But two + # ESPHome OTA instances on *different* ports is not a real-world use case and + # creates ambiguity for listeners, socket wake hooks, and safe_mode coordination. + if len(merged_ota_esphome_configs_by_port) > 1: + raise cv.Invalid( + f"Only a single '{CONF_OTA}' '{CONF_PLATFORM}: {CONF_ESPHOME}' instance is " + f"supported, but multiple were configured on different ports " + f"({sorted(merged_ota_esphome_configs_by_port.keys())}). Remove the extra " + f"configurations or place them on the same port so they can be merged." + ) + new_ota_conf.extend(merged_ota_esphome_configs_by_port.values()) full_conf[CONF_OTA] = new_ota_conf diff --git a/esphome/core/application.h b/esphome/core/application.h index 63a951b6814..100ff3440dc 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -564,11 +564,16 @@ class Application { /// a new connection arrives on the listening socket. OTA disables its own /// loop while idle to avoid per-tick dispatch overhead. void set_ota_wake_component(Component *component) { this->ota_wake_component_ = component; } - /// Wake the registered OTA component (if any) from any context. Safe to call - /// from the LwIP TCP/IP task and other callback contexts. + /// Mark the registered OTA component (if any) for loop re-enable from any + /// context. Intentionally does NOT call wake_loop_any_context() — every + /// caller (lwip fast-select callback, raw-tcp accept callback, host + /// select() return path) has already woken the main loop, so a second wake + /// here would be redundant. Application is a friend of Component, so we + /// set the pending-enable flags directly. void IRAM_ATTR wake_ota_component_any_context() { if (this->ota_wake_component_ != nullptr) { - this->ota_wake_component_->enable_loop_soon_any_context(); + this->ota_wake_component_->pending_enable_loop_ = true; + this->has_pending_enable_loop_requests_ = true; } } #endif