The wake-hook call (esphome_wake_ota_component_any_context / App.wake_ota_component_any_context)
was placed AFTER xTaskNotifyGive()/wake_loop_any_context() in both the fast-select callback and
the raw-TCP accept callback. That opened a race: the main task could wake, run a full iteration
(draining has_pending_enable_loop_requests_), and finish before the flag-set ran — losing the
pending-enable request until the next unrelated socket event happened to re-trigger the path.
Swap the order so the pending-enable flags are set first, then the main task is woken. The
main-loop iteration triggered by the wake is now guaranteed to see the pending request.
Note: host's yield_with_select_ path already sets and consumes the flag on the main thread
with no cross-task wake in between, so it has no race and is unchanged.
The wake hook is called only from:
- esphome_socket_event_callback (lwip fast select, LwIP TCP/IP task context)
- LWIPRawListenImpl::accept_fn_ (raw TCP accept callback)
- Application::yield_with_select_ (host select fallback, main thread)
None of those contexts require IRAM-resident code. The LwIP fast-select
event callback itself is not IRAM_ATTR; the raw-TCP accept callback runs
from a low-priority user IRQ on RP2040 where IRAM_ATTR is a no-op anyway
(it's an ESP32-specific section attribute for code that must run while
flash cache is disabled). This is not a real ISR path the way
enable_loop_soon_any_context() is (which is called from GPIO ISRs and
genuinely does need IRAM).
Removing IRAM_ATTR frees scarce IRAM on ESP32.
Addresses copilot review on #15636.
1. Fix cleanup_connection_() race with queued listener events.
While an OTA session was active, a second incoming connection would
fire esphome_socket_event_callback → esphome_wake_ota_component_any_context,
which sets pending_enable_loop_ on the (still-active) OTA component.
enable_pending_loops_() only scans the inactive section, so that flag
goes invisible. When cleanup_connection_() then called disable_loop(),
the component dropped to LOOP_DONE with a stale pending flag and
nothing to re-trigger the scan — the queued client sat forever until
some unrelated socket activity woke the main loop.
Fix: don't call disable_loop() from cleanup_connection_(). loop() has
the idempotent idle check at its top; one more dispatch after cleanup
is cheap and guarantees we re-read server_->ready() and either accept
the queued client or disable cleanly.
2. Tighten the multi-port error message. Merging is fine — the constraint
is single-port. Reworded: "Only a single port is supported for 'ota'
'platform: esphome'. Got ports [...]. Consolidate onto a single port;
configs sharing a port are merged automatically."
3. Comment drift: three call sites and the fast-select extern declaration
still referred to enable_loop_soon_any_context() and implied the hook
wakes the main loop. Updated to reflect the current mechanism (sets
pending-enable flags only; callers have already woken the main loop).
Also clarified that esphome_wake_ota_component_any_context fires on
every RCVPLUS event across all monitored sockets, so false wakes are
expected and OTA::loop() disables itself again when idle.
4. Added tests/component_tests/ota/test_esphome_ota.py covering
ota_esphome_final_validate: single instance accepted, same-port
configs merge, different-port configs rejected with cv.Invalid,
non-esphome platforms unaffected.
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.
ESPHomeOTAComponent::loop() previously ran every main-loop tick just to
check `client_ != nullptr || server_->ready()` — a wasted dispatch on
every device, since OTA is idle the vast majority of the time.
OTA now disables its own loop after setup() and after cleanup_connection_().
A single 4-byte Component* slot in Application (only compiled in under
USE_OTA) lets the existing socket-wake paths call
enable_loop_soon_any_context() on the registered OTA component:
- ESP32 / LibreTiny (lwip fast select): hooked in
esphome_socket_event_callback on NETCONN_EVT_RCVPLUS.
- ESP8266 / RP2040 (raw TCP): hooked in LWIPRawListenImpl::accept_fn_
right after the existing wake_loop_any_context() call.
- Host (select fallback): called after select() returns ready in
Application::yield_with_select_.
False wakes (e.g. an API-socket event firing the fast-select callback)
land in ESPHomeOTAComponent::loop(), which re-disables itself immediately
when idle. Net cost is still far below running every tick.
This is deliberately an OTA-only hook: OTA is the only component that
benefits, and a single global slot avoids adding per-socket Component*
storage, wake-callback lists, or any new API churn to the socket layer.