[esphome.ota] Reject multi-port esphome OTA, drop redundant wake

Address copilot review on #15636.

1. Enforce single ESPHome OTA instance (BREAKING CHANGE).
   The `ota_esphome_final_validate` hook has always merged multiple
   `ota: - platform: esphome` configs by port so a user config and a
   remote package that both define OTA would merge rather than break.
   That merge behavior is preserved. But if two configs survive on
   *different* ports they produce two independent listening sockets,
   which is not a sane deployment: it creates ambiguity for safe_mode
   coordination and for the socket wake hook added in this PR.
   Raise cv.Invalid when more than one port remains after merging.

2. Drop redundant main-loop wake in Application::wake_ota_component_any_context.
   Every caller (lwip fast-select callback already calls xTaskNotifyGive,
   raw-tcp accept callback already calls wake_loop_any_context(), host
   path is already running post-select in the main loop) has woken the
   main loop by the time we reach this hook. Calling
   enable_loop_soon_any_context() would re-wake it. Application is a
   friend of Component, so set pending_enable_loop_ and
   has_pending_enable_loop_requests_ directly instead.
This commit is contained in:
J. Nick Koston
2026-04-10 14:09:27 -10:00
parent 5b84ad5926
commit 0f8419f97d
2 changed files with 22 additions and 3 deletions
@@ -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
+8 -3
View File
@@ -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