From d3591c8d9e3724e21a508c58d4f4fe7459e17aa9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 10 Apr 2026 21:21:26 +0200 Subject: [PATCH 01/28] [micro_wake_word] Pin esp-nn version (#15628) --- .../components/micro_wake_word/__init__.py | 2 + .../micro_wake_word/streaming_model.cpp | 92 +++++++++++++++++-- .../micro_wake_word/streaming_model.h | 5 + 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index ff27dec6df..de95e4961b 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -451,6 +451,8 @@ async def to_code(config): ota.request_ota_state_listeners() esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1") + # Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn) + esp32.add_idf_component(name="espressif/esp-nn", ref="1.2.1") cg.add_build_flag("-DTF_LITE_STATIC_MEMORY") cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON") diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 0ab6cd3772..e761e4866f 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -29,14 +29,6 @@ void VADModel::log_model_config() { bool StreamingModel::load_model_() { RAMAllocator arena_allocator; - if (this->tensor_arena_ == nullptr) { - this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_); - if (this->tensor_arena_ == nullptr) { - ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena."); - return false; - } - } - if (this->var_arena_ == nullptr) { this->var_arena_ = arena_allocator.allocate(STREAMING_MODEL_VARIABLE_ARENA_SIZE); if (this->var_arena_ == nullptr) { @@ -53,6 +45,26 @@ bool StreamingModel::load_model_() { return false; } + // Probe for the actual required tensor arena size if not yet determined + if (!this->tensor_arena_size_probed_) { + size_t probed_size = this->probe_arena_size_(); + if (probed_size > 0) { + ESP_LOGD(TAG, "Probed tensor arena size: %zu bytes", probed_size); + this->tensor_arena_size_ = probed_size; + } else { + ESP_LOGW(TAG, "Arena size probe failed, using manifest size: %zu bytes", this->tensor_arena_size_); + } + this->tensor_arena_size_probed_ = true; + } + + if (this->tensor_arena_ == nullptr) { + this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_); + if (this->tensor_arena_ == nullptr) { + ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena."); + return false; + } + } + if (this->interpreter_ == nullptr) { this->interpreter_ = make_unique(tflite::GetModel(this->model_start_), this->streaming_op_resolver_, @@ -94,6 +106,70 @@ bool StreamingModel::load_model_() { return true; } +size_t StreamingModel::probe_arena_size_() { + RAMAllocator arena_allocator; + + // Try with the manifest size first, then escalates to 1.5, then 2x if it fails. Different platforms and different + // versions of the esp-nn library require different amounts of memory, so the manifest size may not always be correct, + // and probing allows us to find the actual required size for the current build and platform. Aligns test sizes to 16 + // bytes. + size_t attempt_sizes[] = {(this->tensor_arena_size_ + 15) & ~15, (this->tensor_arena_size_ * 3 / 2 + 15) & ~15, + (this->tensor_arena_size_ * 2 + 15) & ~15}; + + for (size_t attempt_size : attempt_sizes) { + uint8_t *probe_arena = arena_allocator.allocate(attempt_size); + if (probe_arena == nullptr) { + continue; + } + + // Verify the model works at all with this arena size + auto probe_interpreter = make_unique( + tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, attempt_size, this->mrv_); + + if (probe_interpreter->AllocateTensors() != kTfLiteOk) { + probe_interpreter.reset(); + arena_allocator.deallocate(probe_arena, attempt_size); + this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE); + this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20); + continue; + } + + // Try to shrink the arena. Start with arena_used_bytes() + 16 (rounded to 16-byte alignment). + // If that works, use it. Otherwise, try midpoints between that and the full size until one succeeds. + size_t lower = (probe_interpreter->arena_used_bytes() + 16 + 15) & ~15; + probe_interpreter.reset(); + this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE); + this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20); + + size_t upper = attempt_size; + + while (lower < upper) { + auto test_interpreter = make_unique( + tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, lower, this->mrv_); + + bool ok = test_interpreter->AllocateTensors() == kTfLiteOk; + + test_interpreter.reset(); + this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE); + this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20); + + if (ok) { + // Found a working size smaller than the full arena + upper = lower + 16; // Pad by 16 bytes to be safe for future allocations + break; + } + + // Try the midpoint between current attempt and full size + lower = ((lower + upper) / 2 + 15) & ~15; + } + + arena_allocator.deallocate(probe_arena, attempt_size); + return upper; + } + + return 0; +} + void StreamingModel::unload_model() { this->interpreter_.reset(); diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 0811bfb19b..fc9eeb5e2d 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -63,6 +63,10 @@ class StreamingModel { /// @brief Allocates tensor and variable arenas and sets up the model interpreter /// @return True if successful, false otherwise bool load_model_(); + /// @brief Probes the actual required tensor arena size by trial allocation. + /// Tries the manifest size first, then 2x if that fails. + /// @return The required arena size rounded up to 16-byte alignment, or 0 on failure. + size_t probe_arena_size_(); /// @brief Returns true if successfully registered the streaming model's TensorFlow operations bool register_streaming_ops_(tflite::MicroMutableOpResolver<20> &op_resolver); @@ -70,6 +74,7 @@ class StreamingModel { bool loaded_{false}; bool enabled_{true}; + bool tensor_arena_size_probed_{false}; bool unprocessed_probability_status_{false}; uint8_t current_stride_step_{0}; int16_t ignore_windows_{-MIN_SLICES_BEFORE_DETECTION}; From 2c610abcd010bfa0fbdae5e52f7ca83cca8b1bfb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:19:52 -1000 Subject: [PATCH 02/28] Bump resvg-py from 0.2.6 to 0.3.0 (#15629) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d7db44454c..8f8ada561a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.2.0 -resvg-py==0.2.6 +resvg-py==0.3.0 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From ae96f82b824682da5d855b8883fc76520b9f0276 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:20:04 -1000 Subject: [PATCH 03/28] Bump actions/upload-artifact from 7.0.0 to 7.0.1 (#15631) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 6d200956e9..677032b7fa 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -62,7 +62,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf9fa8e7c0..2240879bd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -904,7 +904,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: memory-analysis-target path: memory-analysis-target.json @@ -969,7 +969,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: memory-analysis-pr path: memory-analysis-pr.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e8a040888..12d2ce30aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,7 +138,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests From 395610c117bd9358c77f0903890f27c0c65ccf65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:20:17 -1000 Subject: [PATCH 04/28] Bump docker/build-push-action from 7.0.0 to 7.1.0 in /.github/actions/build-image (#15633) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/build-image/action.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index a895226030..52d72544d3 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -47,7 +47,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -73,7 +73,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false From 1dfeef0265c7fe7a6bf322a3d79b08aeb1926c2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:20:43 -1000 Subject: [PATCH 05/28] Bump actions/github-script from 8.0.0 to 9.0.0 (#15632) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 4 ++-- .github/workflows/ci-clang-tidy-hash.yml | 4 ++-- .github/workflows/codeowner-approved-label-update.yml | 2 +- .github/workflows/codeowner-review-request.yml | 2 +- .github/workflows/external-component-bot.yml | 2 +- .github/workflows/issue-codeowner-notify.yml | 2 +- .github/workflows/pr-title-check.yml | 2 +- .github/workflows/release.yml | 6 +++--- .github/workflows/status-check-labels.yml | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 3b5e9f0d15..27ddfe5911 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -33,7 +33,7 @@ jobs: private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - name: Auto Label PR - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 677032b7fa..e5143911d9 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -47,7 +47,7 @@ jobs: fi - if: failure() name: Review PR - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | await github.rest.pulls.createReview({ @@ -70,7 +70,7 @@ jobs: esphome/components/api/api_pb2_service.* - if: success() name: Dismiss review - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 7905739b15..40cdff0cba 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -42,7 +42,7 @@ jobs: - if: failure() && github.event.pull_request.head.repo.full_name == github.repository name: Request changes - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | await github.rest.pulls.createReview({ @@ -55,7 +55,7 @@ jobs: - if: success() && github.event.pull_request.head.repo.full_name == github.repository name: Dismiss review - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 34ff934b77..49653b6fb3 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -34,7 +34,7 @@ jobs: CODEOWNERS - name: Check codeowner approval and update label - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PR_NUMBER: ${{ github.event.pull_request.number }} with: diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index a89c03ba04..76be6ecd7b 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -33,7 +33,7 @@ jobs: ref: ${{ github.event.pull_request.base.sha }} - name: Request reviews from component codeowners - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js'); diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 4fa020f63d..3165b17078 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Add external component comment - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/issue-codeowner-notify.yml b/.github/workflows/issue-codeowner-notify.yml index 6faf956c87..b211c13985 100644 --- a/.github/workflows/issue-codeowner-notify.yml +++ b/.github/workflows/issue-codeowner-notify.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify codeowners for component issues - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 0021654def..8700996271 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12d2ce30aa..c92581b49b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -229,7 +229,7 @@ jobs: repositories: home-assistant-addon - name: Trigger Workflow - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | @@ -264,7 +264,7 @@ jobs: repositories: esphome-schema - name: Trigger Workflow - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | @@ -295,7 +295,7 @@ jobs: repositories: version-notifier - name: Trigger Workflow - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 6483bbe789..709342e5ae 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check for blocking labels - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr']; From e1a813e11fc26d9ebc86d6e7fe0e6a7d38f00f30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:21:01 -1000 Subject: [PATCH 06/28] Bump peter-evans/create-pull-request from 8.1.0 to 8.1.1 (#15630) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/sync-device-classes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a71e5ef4ca..be1457387d 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -41,7 +41,7 @@ jobs: python script/run-in-env.py pre-commit run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot From a7c5b0ab466d6f75279cd8228036856fb118cb2a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:26:09 -0400 Subject: [PATCH 07/28] [sx127x][cc1101][sx126x] Use GPIO interrupt to wake loop (#15627) --- esphome/components/cc1101/cc1101.cpp | 25 ++++++++++++++++--------- esphome/components/cc1101/cc1101.h | 1 + esphome/components/sx126x/sx126x.cpp | 9 +++++++++ esphome/components/sx126x/sx126x.h | 2 ++ esphome/components/sx127x/sx127x.cpp | 9 ++++----- esphome/components/sx127x/sx127x.h | 2 ++ 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index f2b7451721..c231f314cc 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -102,6 +102,8 @@ CC1101Component::CC1101Component() { memset(this->pa_table_, 0, sizeof(this->pa_table_)); } +void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_loop_soon_any_context(); } + void CC1101Component::setup() { this->spi_setup(); this->cs_->digital_write(true); @@ -148,11 +150,12 @@ void CC1101Component::setup() { // Defer pin mode setup until after all components have completed setup() // This handles the case where remote_transmitter runs after CC1101 and changes pin mode if (this->gdo0_pin_ != nullptr) { - this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); }); - } - - if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { - this->disable_loop(); + this->defer([this]() { + this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); + if (this->state_.PKT_FORMAT == static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { + this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + } + }); } } @@ -164,6 +167,7 @@ void CC1101Component::call_listeners_(const std::vector &packet, float } void CC1101Component::loop() { + this->disable_loop(); if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr || !this->gdo0_pin_->digital_read()) { return; @@ -244,6 +248,7 @@ void CC1101Component::begin_tx() { this->write_(Register::PKTCTRL0, 0x32); ESP_LOGV(TAG, "Beginning TX sequence"); if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->detach_interrupt(); this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); } // Transition through IDLE to bypass CCA (Clear Channel Assessment) which can @@ -673,10 +678,12 @@ void CC1101Component::set_packet_mode(bool value) { this->state_.GDO0_CFG = 0x0D; } if (this->initialized_) { - if (value) { - this->enable_loop(); - } else { - this->disable_loop(); + if (this->gdo0_pin_ != nullptr) { + if (value) { + this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + } else { + this->gdo0_pin_->detach_interrupt(); + } } this->write_(Register::PKTCTRL0); this->write_(Register::PKTCTRL1); diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 2efd9e082d..68d81ac8f3 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -93,6 +93,7 @@ class CC1101Component : public Component, // GDO pin for packet reception InternalGPIOPin *gdo0_pin_{nullptr}; + static void IRAM_ATTR gpio_intr(CC1101Component *arg); // Packet handling void call_listeners_(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi); diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index ec62fad10a..6ea09e3a9e 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -104,11 +104,17 @@ void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) { delayMicroseconds(SWITCHING_DELAY_US); } +void IRAM_ATTR SX126x::gpio_intr(SX126x *arg) { arg->enable_loop_soon_any_context(); } + void SX126x::setup() { // setup pins this->busy_pin_->setup(); this->rst_pin_->setup(); this->dio1_pin_->setup(); + if (this->dio1_pin_->is_internal()) { + static_cast(this->dio1_pin_) + ->attach_interrupt(&SX126x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + } // start spi this->spi_setup(); @@ -348,6 +354,9 @@ void SX126x::call_listeners_(const std::vector &packet, float rssi, flo } void SX126x::loop() { + if (this->dio1_pin_->is_internal()) { + this->disable_loop(); + } if (!this->dio1_pin_->digital_read()) { return; } diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index a758d63795..edc00e3727 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -3,6 +3,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "sx126x_reg.h" #include #include @@ -100,6 +101,7 @@ class SX126x : public Component, Trigger, float, float> *get_packet_trigger() { return &this->packet_trigger_; } protected: + static void IRAM_ATTR gpio_intr(SX126x *arg); void configure_fsk_ook_(); void configure_lora_(); void set_packet_params_(uint8_t payload_length); diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 83be96767a..2b13efb38d 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -53,6 +53,8 @@ void SX127x::write_fifo_(const std::vector &packet) { this->disable(); } +void IRAM_ATTR SX127x::gpio_intr(SX127x *arg) { arg->enable_loop_soon_any_context(); } + void SX127x::setup() { // setup reset this->rst_pin_->setup(); @@ -60,6 +62,7 @@ void SX127x::setup() { // setup dio0 if (this->dio0_pin_) { this->dio0_pin_->setup(); + this->dio0_pin_->attach_interrupt(&SX127x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); } // start spi @@ -313,6 +316,7 @@ void SX127x::call_listeners_(const std::vector &packet, float rssi, flo } void SX127x::loop() { + this->disable_loop(); if (this->dio0_pin_ == nullptr || !this->dio0_pin_->digital_read()) { return; } @@ -386,11 +390,6 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) { return; } } - if (mode == MODE_RX && (modulation == MOD_LORA || this->packet_mode_)) { - this->enable_loop(); - } else { - this->disable_loop(); - } } void SX127x::set_mode_rx() { diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index be7b6d8d9f..76f942fdda 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -4,6 +4,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include namespace esphome { @@ -86,6 +87,7 @@ class SX127x : public Component, Trigger, float, float> *get_packet_trigger() { return &this->packet_trigger_; } protected: + static void IRAM_ATTR gpio_intr(SX127x *arg); void configure_fsk_ook_(); void configure_lora_(); void set_mode_(uint8_t modulation, uint8_t mode); From 40081e5ae723e15870d56f5464a8e559dd71599c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 13:13:05 -1000 Subject: [PATCH 08/28] [rp2040] Fix W5500 Ethernet pbuf corruption by mirroring LWIPMutex semantics (#15624) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/rp2040/helpers.cpp | 31 +++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2040/helpers.cpp index 8cb5f7c18d..6e5ddad236 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2040/helpers.cpp @@ -9,7 +9,7 @@ #include #include // For cyw43_arch_lwip_begin/end (LwIPLock) #elif defined(USE_ETHERNET) -#include // For ethernet_arch_lwip_begin/end (LwIPLock) +#include // For LWIPMutex — LwIPLock mirrors its semantics (see below) #include "esphome/components/ethernet/ethernet_component.h" #endif #include @@ -43,9 +43,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } // main loop, corrupting the shared rx_buf_ pbuf chain (use-after-free, pbuf_cat // assertion failures). See esphome#10681. // -// WiFi uses cyw43_arch_lwip_begin/end; Ethernet uses ethernet_arch_lwip_begin/end. -// Both acquire the async_context recursive mutex to prevent IRQ callbacks from -// firing during critical sections. +// WiFi uses cyw43_arch_lwip_begin/end. +// +// For wired Ethernet, taking only the async_context lock is NOT enough. The +// W5500 GPIO IRQ path (LwipIntfDev::_irq) checks arduino-pico's `__inLWIP` +// counter to decide whether to defer packet processing. If we hold the +// async_context lock without bumping `__inLWIP`, an interrupt-driven packet +// arrival re-enters lwIP from IRQ context and corrupts pbufs (the `pbuf_cat` +// assertion crash on wiznet-w5500-evb-pico). We mirror arduino-pico's +// LWIPMutex (cores/rp2040/lwip_wrap.h) exactly: bump `__inLWIP`, take the +// lock, and on release re-unmask any GPIO IRQs that were deferred while we +// held it. We can't `using LwIPLock = LWIPMutex;` in helpers.h because +// pulling lwip_wrap.h there poisons many TUs with lwIP types. // // When neither WiFi nor Ethernet is configured, this is a no-op since // there's no network stack and no lwip callbacks to race with. @@ -53,8 +62,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } LwIPLock::LwIPLock() { cyw43_arch_lwip_begin(); } LwIPLock::~LwIPLock() { cyw43_arch_lwip_end(); } #elif defined(USE_ETHERNET) -LwIPLock::LwIPLock() { ethernet_arch_lwip_begin(); } -LwIPLock::~LwIPLock() { ethernet_arch_lwip_end(); } +LwIPLock::LwIPLock() { + __inLWIP++; + ethernet_arch_lwip_begin(); +} +LwIPLock::~LwIPLock() { + ethernet_arch_lwip_end(); + __inLWIP--; + if (__needsIRQEN && !__inLWIP) { + __needsIRQEN = false; + ethernet_arch_lwip_gpio_unmask(); + } +} #else LwIPLock::LwIPLock() {} LwIPLock::~LwIPLock() {} From 5b84ad592671cf12242794474e45a2395f46b67f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 13:24:32 -1000 Subject: [PATCH 09/28] [esphome.ota] Disable loop while idle, wake on listening-socket activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/esphome/ota/ota_esphome.cpp | 25 ++++++++++++++----- .../components/socket/lwip_raw_tcp_impl.cpp | 9 +++++++ esphome/core/application.cpp | 13 ++++++++++ esphome/core/application.h | 17 +++++++++++++ esphome/core/lwip_fast_select.c | 11 ++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index af9b8ee19a..ba526cc2b7 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -65,6 +65,12 @@ void ESPHomeOTAComponent::setup() { this->server_failed_(LOG_STR("listen")); return; } + + // Disable loop() while idle. Socket wake paths (LwIP fast select, raw TCP accept + // callback, host select) call App.wake_ota_component_any_context() which re-enables + // this component's loop when an incoming connection arrives. + App.set_ota_wake_component(this); + this->disable_loop(); } void ESPHomeOTAComponent::dump_config() { @@ -81,13 +87,18 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Skip handle_handshake_() call if no client connected and no incoming connections - // This optimization reduces idle loop overhead when OTA is not active - // Note: No need to check server_ for null as the component is marked failed in setup() - // if server_ creation fails - if (this->client_ != nullptr || this->server_->ready()) { - this->handle_handshake_(); + // loop() is disabled while idle (see setup() / cleanup_connection_()). Socket-wake + // paths (LwIP fast select, raw TCP accept, host select) call + // App.wake_ota_component_any_context() to re-enable this loop when a monitored + // socket signals activity. False wakes (e.g. an API-socket event) land here with + // no pending work — in that case we disable the loop again and go back to sleep. + // Note: No need to check server_ for null as the component is marked failed in + // setup() if server_ creation fails. + if (this->client_ == nullptr && !this->server_->ready()) { + this->disable_loop(); + return; } + this->handle_handshake_(); } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; @@ -566,6 +577,8 @@ void ESPHomeOTAComponent::cleanup_connection_() { #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); #endif + // Back to idle — sleep until the next incoming connection wakes us. + this->disable_loop(); } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 86131d3ddb..823637e0f7 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -10,6 +10,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/wake.h" #include "esphome/core/log.h" +#ifdef USE_OTA +#include "esphome/core/application.h" +#endif #ifdef USE_ESP8266 #include // For esp_schedule() @@ -856,6 +859,12 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); +#ifdef USE_OTA + // Re-enable the OTA component loop if it disabled itself while idle. + // enable_loop_soon_any_context() is IRAM/IRQ-safe, which is required on RP2040 + // where this callback runs in a low-priority user IRQ context. + esphome::App.wake_ota_component_any_context(); +#endif return ERR_OK; } diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd75859880..45bd2350f7 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -449,6 +449,12 @@ void Application::enable_pending_loops_() { } } +#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) +// Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS. +// enable_loop_soon_any_context() is task-safe and IRAM-resident. +extern "C" void IRAM_ATTR esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } +#endif + #ifdef USE_LWIP_FAST_SELECT bool Application::register_socket(struct lwip_sock *sock) { // It modifies monitored_sockets_ without locking — must only be called from the main loop. @@ -554,6 +560,13 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected if (ret >= 0) [[likely]] { +#ifdef USE_OTA + // A socket is ready; re-enable the OTA component loop if it disabled itself while idle. + // The wake is a no-op if OTA didn't register or is already active. + if (ret > 0) { + this->wake_ota_component_any_context(); + } +#endif // Yield if zero timeout since select(0) only polls without yielding if (delay_ms == 0) [[unlikely]] { yield(); diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b490..63a951b681 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -559,6 +559,20 @@ class Application { /// Wake from any context (ISR, thread, callback). static void IRAM_ATTR wake_loop_any_context() { esphome::wake_loop_any_context(); } +#ifdef USE_OTA + /// Register the OTA component so socket-wake paths can enable its loop when + /// 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. + void IRAM_ATTR wake_ota_component_any_context() { + if (this->ota_wake_component_ != nullptr) { + this->ota_wake_component_->enable_loop_soon_any_context(); + } + } +#endif + protected: friend Component; #ifdef USE_HOST @@ -634,6 +648,9 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; +#ifdef USE_OTA + Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications +#endif // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index bb3acbafcb..80fe933941 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,13 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +#ifdef USE_OTA +// Extern wake hook for the OTA component (implemented in application.cpp). Called from the +// TCP/IP task so the OTA component's disabled loop can be re-enabled when a new connection +// arrives on its listening socket. Safe from task context via enable_loop_soon_any_context(). +extern void esphome_wake_ota_component_any_context(void); +#endif + // Wrapper callback: calls original event_callback + notifies main loop task. // Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR). static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) { @@ -175,6 +182,10 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt if (task != NULL) { xTaskNotifyGive(task); } +#ifdef USE_OTA + // Re-enable the OTA component loop if it disabled itself while idle. + esphome_wake_ota_component_any_context(); +#endif } } From 0f8419f97dba73f3c3a9d1d9609caf41cad0c0ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 14:09:27 -1000 Subject: [PATCH 10/28] [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. --- esphome/components/esphome/ota/__init__.py | 14 ++++++++++++++ esphome/core/application.h | 11 ++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 337064dd27..d358438096 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 63a951b681..100ff3440d 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 From ae9c5bab80953ff03ee46d2ebf72c060516a4772 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 14:11:19 -1000 Subject: [PATCH 11/28] [esphome.ota] Drop narrative comment from multi-port validator --- esphome/components/esphome/ota/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index d358438096..761a825d84 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -78,12 +78,6 @@ 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 " From af8fd1d0607af7bf865bce54802340a926f3a114 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 14:29:32 -1000 Subject: [PATCH 12/28] [esphome.ota] Fix cleanup race, tighten error message and comments, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- esphome/components/esphome/ota/__init__.py | 8 +- .../components/esphome/ota/ota_esphome.cpp | 8 +- .../components/socket/lwip_raw_tcp_impl.cpp | 8 +- esphome/core/application.cpp | 3 +- esphome/core/lwip_fast_select.c | 10 +- tests/component_tests/ota/test_esphome_ota.py | 105 ++++++++++++++++++ 6 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 tests/component_tests/ota/test_esphome_ota.py diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 761a825d84..c8cb9920ba 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -80,10 +80,10 @@ def ota_esphome_final_validate(config): 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." + f"Only a single port is supported for '{CONF_OTA}' " + f"'{CONF_PLATFORM}: {CONF_ESPHOME}'. Got ports " + f"{sorted(merged_ota_esphome_configs_by_port.keys())}. Consolidate " + f"onto a single port; configs sharing a port are merged automatically." ) new_ota_conf.extend(merged_ota_esphome_configs_by_port.values()) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index ba526cc2b7..08ec4dba66 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -577,8 +577,12 @@ void ESPHomeOTAComponent::cleanup_connection_() { #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); #endif - // Back to idle — sleep until the next incoming connection wakes us. - this->disable_loop(); + // Do not disable_loop() here. loop() itself disables when idle. If a second + // connection was queued on the listener while we were busy, the wake flag was + // set while this component was in LOOP state — enable_pending_loops_() only + // scans the inactive section and would never clear it. Letting loop() run one + // more iteration guarantees we re-check server_->ready() and either accept the + // queued client or disable ourselves cleanly. } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 823637e0f7..3d1679553e 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -860,9 +860,11 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); #ifdef USE_OTA - // Re-enable the OTA component loop if it disabled itself while idle. - // enable_loop_soon_any_context() is IRAM/IRQ-safe, which is required on RP2040 - // where this callback runs in a low-priority user IRQ context. + // Mark the OTA component loop to be re-enabled if it disabled itself while idle. + // This only sets pending-enable flags; the wake_loop_any_context() call above has + // already woken the main loop, which will process the pending enable on its next + // iteration. Safe to call from RP2040's low-priority user IRQ context — it only + // writes volatile bools, no heap or locks. esphome::App.wake_ota_component_any_context(); #endif return ERR_OK; diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 45bd2350f7..8373c672ed 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -451,7 +451,8 @@ void Application::enable_pending_loops_() { #if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) // Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS. -// enable_loop_soon_any_context() is task-safe and IRAM-resident. +// Only marks the OTA component as pending loop-enable; the fast-select callback itself has +// already woken the main task via xTaskNotifyGive(). extern "C" void IRAM_ATTR esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 80fe933941..32b79f288d 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -159,8 +159,10 @@ static netconn_callback s_original_callback = NULL; #ifdef USE_OTA // Extern wake hook for the OTA component (implemented in application.cpp). Called from the -// TCP/IP task so the OTA component's disabled loop can be re-enabled when a new connection -// arrives on its listening socket. Safe from task context via enable_loop_soon_any_context(). +// TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false +// wake from an unrelated monitored socket. OTA::loop() handles that by disabling itself again +// when there is no pending work. The hook only marks the OTA component as pending loop-enable; +// it does not itself wake the main task (the caller below already does that). extern void esphome_wake_ota_component_any_context(void); #endif @@ -183,7 +185,9 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt xTaskNotifyGive(task); } #ifdef USE_OTA - // Re-enable the OTA component loop if it disabled itself while idle. + // Mark the OTA component loop to be re-enabled if it disabled itself while idle. + // Only sets pending-enable flags — the xTaskNotifyGive above has already woken + // the main task, which will process the pending enable on its next iteration. esphome_wake_ota_component_any_context(); #endif } diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py new file mode 100644 index 0000000000..cdac430ff7 --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -0,0 +1,105 @@ +"""Tests for the esphome OTA platform final_validate logic.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.esphome.ota import ota_esphome_final_validate +from esphome.const import ( + CONF_ESPHOME, + CONF_ID, + CONF_OTA, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_VERSION, +) +from esphome.core import ID +import esphome.final_validate as fv + + +def _make_ota_config(port: int = 3232, **kwargs: Any) -> dict[str, Any]: + config: dict[str, Any] = { + CONF_PLATFORM: CONF_ESPHOME, + CONF_ID: ID(f"ota_esphome_{port}", is_manual=False), + CONF_VERSION: 2, + CONF_PORT: port, + } + config.update(kwargs) + return config + + +def test_single_esphome_ota_instance_accepted() -> None: + """A single ESPHome OTA config passes final_validate untouched.""" + full_conf = {CONF_OTA: [_make_ota_config(port=3232)]} + token = fv.full_config.set(full_conf) + try: + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert len(updated[CONF_OTA]) == 1 + assert updated[CONF_OTA][0][CONF_PORT] == 3232 + finally: + fv.full_config.reset(token) + + +def test_same_port_configs_merge(caplog: pytest.LogCaptureFixture) -> None: + """Two ESPHome OTA configs on the same port merge into one instance.""" + full_conf = { + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_PASSWORD: "pw"}), + _make_ota_config(port=3232), + ] + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert len(updated[CONF_OTA]) == 1 + assert updated[CONF_OTA][0][CONF_PORT] == 3232 + assert any("Found and merged" in record.message for record in caplog.records), ( + "Expected merge warning not found in log" + ) + finally: + fv.full_config.reset(token) + + +def test_multiple_ports_rejected() -> None: + """Two ESPHome OTA configs on different ports raise cv.Invalid.""" + full_conf = { + CONF_OTA: [ + _make_ota_config(port=3232), + _make_ota_config(port=3233), + ] + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match=r"Only a single port is supported for 'ota' 'platform: esphome'", + ): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_non_esphome_ota_unaffected() -> None: + """Non-esphome OTA platforms are not subject to the single-instance rule.""" + full_conf = { + CONF_OTA: [ + _make_ota_config(port=3232), + {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, + {CONF_PLATFORM: "http_request", CONF_ID: ID("ota_hr", is_manual=False)}, + ] + } + token = fv.full_config.set(full_conf) + try: + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert len(updated[CONF_OTA]) == 3 + finally: + fv.full_config.reset(token) From 92f93e128fd4be7691105e807c143f8dd372ee57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 14:47:52 -1000 Subject: [PATCH 13/28] [esphome.ota] Drop unnecessary IRAM_ATTR from wake hook 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. --- esphome/core/application.cpp | 2 +- esphome/core/application.h | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 8373c672ed..9b7091f59a 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -453,7 +453,7 @@ void Application::enable_pending_loops_() { // Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS. // Only marks the OTA component as pending loop-enable; the fast-select callback itself has // already woken the main task via xTaskNotifyGive(). -extern "C" void IRAM_ATTR esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } +extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/core/application.h b/esphome/core/application.h index 100ff3440d..67ef1d666f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -564,13 +564,14 @@ 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; } - /// 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() { + /// Mark the registered OTA component (if any) for loop re-enable. 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. + /// Not IRAM_ATTR: all callers run in task / user-IRQ context, not a real ISR, + /// and the LwIP event callbacks that invoke this are not IRAM-resident either. + void wake_ota_component_any_context() { if (this->ota_wake_component_ != nullptr) { this->ota_wake_component_->pending_enable_loop_ = true; this->has_pending_enable_loop_requests_ = true; From 903a159344cc3f2a3c9366462a13c79524e88dbe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 14:52:12 -1000 Subject: [PATCH 14/28] [esphome.ota] Fix loop() docstring to reflect cleanup no longer disables --- .../components/esphome/ota/ota_esphome.cpp | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 08ec4dba66..3b8c5c0ce8 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -87,13 +87,20 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // loop() is disabled while idle (see setup() / cleanup_connection_()). Socket-wake - // paths (LwIP fast select, raw TCP accept, host select) call - // App.wake_ota_component_any_context() to re-enable this loop when a monitored - // socket signals activity. False wakes (e.g. an API-socket event) land here with - // no pending work — in that case we disable the loop again and go back to sleep. - // Note: No need to check server_ for null as the component is marked failed in - // setup() if server_ creation fails. + // loop() starts disabled while idle (setup() calls disable_loop()). Socket-wake + // paths (LwIP fast select, raw TCP accept, host select) mark us pending-enable + // via App.wake_ota_component_any_context() when a monitored socket signals + // activity; enable_pending_loops_() then re-activates us. False wakes (e.g. an + // API-socket event on an unrelated monitored socket) land here with no pending + // work, and this early-return disables the loop again. + // + // cleanup_connection_() deliberately does NOT call disable_loop() — letting + // loop() run one more iteration after a session ends guarantees we re-read + // server_->ready() and either accept a client queued during the session or + // disable cleanly here. + // + // Note: No need to check server_ for null — setup() marks the component failed + // if server_ creation fails. if (this->client_ == nullptr && !this->server_->ready()) { this->disable_loop(); return; From ffbd0dcbfcdc13a655f8af890647a3303f49967f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 15:41:57 -1000 Subject: [PATCH 15/28] [esphome.ota] Set pending-enable flags before main-loop wake (fix race) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 12 ++++++------ esphome/core/application.cpp | 7 ++++--- esphome/core/lwip_fast_select.c | 13 +++++++------ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 3d1679553e..cfd8cf0daa 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -857,16 +857,16 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn); tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); - // Wake the main loop immediately so it can accept the new connection. - esphome::wake_loop_any_context(); #ifdef USE_OTA // Mark the OTA component loop to be re-enabled if it disabled itself while idle. - // This only sets pending-enable flags; the wake_loop_any_context() call above has - // already woken the main loop, which will process the pending enable on its next - // iteration. Safe to call from RP2040's low-priority user IRQ context — it only - // writes volatile bools, no heap or locks. + // This MUST happen before wake_loop_any_context() below — otherwise the main loop + // could wake, run a full iteration, and finish before we set the pending-enable + // flags, losing the wake event. Safe from RP2040's low-priority user IRQ context: + // it only writes volatile bools, no heap or locks. esphome::App.wake_ota_component_any_context(); #endif + // Wake the main loop immediately so it can accept the new connection. + esphome::wake_loop_any_context(); return ERR_OK; } diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 9b7091f59a..9311b9fbf0 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -450,9 +450,10 @@ void Application::enable_pending_loops_() { } #if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) -// Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS. -// Only marks the OTA component as pending loop-enable; the fast-select callback itself has -// already woken the main task via xTaskNotifyGive(). +// Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS, +// BEFORE the callback calls xTaskNotifyGive() — the flag-set must happen before the wake, +// otherwise the main task could wake, run a full iteration, and miss the pending-enable. +// Only marks the OTA component as pending loop-enable; does not itself wake the main task. extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 32b79f288d..96eddf00ff 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -180,16 +180,17 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { +#ifdef USE_OTA + // Mark the OTA component loop to be re-enabled if it disabled itself while idle. + // This MUST happen before xTaskNotifyGive below — otherwise the main task could + // wake, run a full iteration, and finish before we set the pending-enable flags, + // causing the wake event to be lost until the next unrelated socket activity. + esphome_wake_ota_component_any_context(); +#endif TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); } -#ifdef USE_OTA - // Mark the OTA component loop to be re-enabled if it disabled itself while idle. - // Only sets pending-enable flags — the xTaskNotifyGive above has already woken - // the main task, which will process the pending enable on its next iteration. - esphome_wake_ota_component_any_context(); -#endif } } From ae54f3e071b60d1f0a0c67833b8ade3fa5c7cb6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 15:42:32 -1000 Subject: [PATCH 16/28] [esphome.ota] Drop explicit disable_loop() from setup, let loop() self-disable --- .../components/esphome/ota/ota_esphome.cpp | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3b8c5c0ce8..566d2f5ef0 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -66,11 +66,9 @@ void ESPHomeOTAComponent::setup() { return; } - // Disable loop() while idle. Socket wake paths (LwIP fast select, raw TCP accept - // callback, host select) call App.wake_ota_component_any_context() which re-enables - // this component's loop when an incoming connection arrives. + // Register for socket wake notifications. loop() disables itself on its first + // idle tick — no need to disable_loop() here explicitly. App.set_ota_wake_component(this); - this->disable_loop(); } void ESPHomeOTAComponent::dump_config() { @@ -87,17 +85,21 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // loop() starts disabled while idle (setup() calls disable_loop()). Socket-wake - // paths (LwIP fast select, raw TCP accept, host select) mark us pending-enable - // via App.wake_ota_component_any_context() when a monitored socket signals - // activity; enable_pending_loops_() then re-activates us. False wakes (e.g. an - // API-socket event on an unrelated monitored socket) land here with no pending - // work, and this early-return disables the loop again. + // Self-disabling idle loop. On the first tick after setup() (and after every + // session cleanup and after every false wake), if there's no client and the + // listener has nothing queued, we disable ourselves and go back to sleep. + // Socket-wake paths (LwIP fast select, raw TCP accept, host select) mark us + // pending-enable via App.wake_ota_component_any_context() when a monitored + // socket signals activity, and enable_pending_loops_() reactivates us. + // + // False wakes from unrelated monitored sockets are expected — the event + // callbacks fire on every RCVPLUS across all monitored sockets, not just + // OTA's listener — and they land here with no pending work. // // cleanup_connection_() deliberately does NOT call disable_loop() — letting // loop() run one more iteration after a session ends guarantees we re-read - // server_->ready() and either accept a client queued during the session or - // disable cleanly here. + // server_->ready() and either accept a client that queued during the session + // or disable cleanly here. // // Note: No need to check server_ for null — setup() marks the component failed // if server_ creation fails. From 06285dbb75a04edfd273d756e7efa26b41d00a37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 15:43:10 -1000 Subject: [PATCH 17/28] [esphome.ota] Comment host wake path as currently dead code --- esphome/core/application.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 9311b9fbf0..5fc794d1f6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -563,8 +563,10 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret == 0: timeout occurred - normal and expected if (ret >= 0) [[likely]] { #ifdef USE_OTA - // A socket is ready; re-enable the OTA component loop if it disabled itself while idle. - // The wake is a no-op if OTA didn't register or is already active. + // Dead code today — host does not currently support the esphome OTA platform, + // so ota_wake_component_ is never set and this call is a no-op. Kept so the + // wake path works out of the box if host ever gains OTA support. Harmless + // cost: a single nullptr check per select() return. if (ret > 0) { this->wake_ota_component_any_context(); } From f92c745ae9fb53a05d334dd6f0a1fa242438265d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:01:07 -1000 Subject: [PATCH 18/28] [esphome.ota] TEMP: debug logging to diagnose wake/accept race --- esphome/components/esphome/ota/ota_esphome.cpp | 14 ++++++++++++-- esphome/core/application.h | 7 ++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 566d2f5ef0..4109fe6cf1 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -69,6 +69,7 @@ void ESPHomeOTAComponent::setup() { // Register for socket wake notifications. loop() disables itself on its first // idle tick — no need to disable_loop() here explicitly. App.set_ota_wake_component(this); + ESP_LOGD(TAG, "setup complete: registered wake component, listener fd ready"); } void ESPHomeOTAComponent::dump_config() { @@ -103,7 +104,12 @@ void ESPHomeOTAComponent::loop() { // // Note: No need to check server_ for null — setup() marks the component failed // if server_ creation fails. - if (this->client_ == nullptr && !this->server_->ready()) { + // DEBUG: trace every loop tick while we investigate a wake/accept race. + const uint32_t wake_count = App.ota_wake_count_debug(); + const bool ready = this->server_->ready(); + ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u", (void *) this->client_.get(), ready, wake_count); + if (this->client_ == nullptr && !ready) { + ESP_LOGD(TAG, "loop tick: idle, disabling"); this->disable_loop(); return; } @@ -126,9 +132,13 @@ void ESPHomeOTAComponent::handle_handshake_() { socklen_t addr_len = sizeof(source_addr); int enable = 1; + ESP_LOGD(TAG, "handle_handshake_: attempting accept"); this->client_ = this->server_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); - if (this->client_ == nullptr) + if (this->client_ == nullptr) { + ESP_LOGD(TAG, "handle_handshake_: accept returned null (would-block)"); return; + } + ESP_LOGD(TAG, "handle_handshake_: accept ok, client=%p", (void *) this->client_.get()); int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); if (err != 0) { this->log_socket_error_(LOG_STR("nodelay")); diff --git a/esphome/core/application.h b/esphome/core/application.h index 67ef1d666f..185991a37a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -572,11 +572,15 @@ class Application { /// Not IRAM_ATTR: all callers run in task / user-IRQ context, not a real ISR, /// and the LwIP event callbacks that invoke this are not IRAM-resident either. void wake_ota_component_any_context() { + this->ota_wake_count_debug_++; if (this->ota_wake_component_ != nullptr) { this->ota_wake_component_->pending_enable_loop_ = true; this->has_pending_enable_loop_requests_ = true; } } + /// DEBUG: monotonically increasing count of wake calls, regardless of whether + /// ota_wake_component_ was set. Read from OTA::loop() to verify the hook fires. + uint32_t ota_wake_count_debug() const { return this->ota_wake_count_debug_; } #endif protected: @@ -655,7 +659,8 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; #ifdef USE_OTA - Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications + Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications + volatile uint32_t ota_wake_count_debug_{0}; // DEBUG: incremented by wake_ota_component_any_context #endif // std::vector (3 pointers each: begin, end, capacity) From e820d485e242eb3948ff2f07f36785840905bf33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:08:00 -1000 Subject: [PATCH 19/28] [esphome.ota] TEMP: keep loop enabled to trace wake/ready flow --- esphome/components/esphome/ota/ota_esphome.cpp | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 4109fe6cf1..dac8b6e1c7 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -104,14 +104,20 @@ void ESPHomeOTAComponent::loop() { // // Note: No need to check server_ for null — setup() marks the component failed // if server_ creation fails. - // DEBUG: trace every loop tick while we investigate a wake/accept race. + // DEBUG: self-disable removed. Log every tick with wake counter + ready state so + // we can tell (a) whether the lwip listener wake hook ever fires and (b) whether + // server_->ready() ever flips to true when a client tries to connect. const uint32_t wake_count = App.ota_wake_count_debug(); const bool ready = this->server_->ready(); - ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u", (void *) this->client_.get(), ready, wake_count); + static uint32_t last_wake_count = 0; + static bool last_ready = false; + if (wake_count != last_wake_count || ready != last_ready || this->client_ != nullptr) { + ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u", (void *) this->client_.get(), ready, wake_count); + last_wake_count = wake_count; + last_ready = ready; + } if (this->client_ == nullptr && !ready) { - ESP_LOGD(TAG, "loop tick: idle, disabling"); - this->disable_loop(); - return; + return; // stay in LOOP state so we can poll every tick for diagnosis } this->handle_handshake_(); } From 9c709869090dc8aefeab300850dfeaf8ea30f43d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:13:56 -1000 Subject: [PATCH 20/28] [esphome.ota] TEMP: log server_->ready() after setup + add global rcvplus counter --- .../components/esphome/ota/ota_esphome.cpp | 27 +++++++++++++++---- esphome/core/lwip_fast_select.c | 6 +++++ esphome/core/lwip_fast_select.h | 3 +++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index dac8b6e1c7..fb583e6d7a 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -15,6 +15,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_LWIP_FAST_SELECT +#include "esphome/core/lwip_fast_select.h" +#endif #include #include @@ -34,6 +37,13 @@ void ESPHomeOTAComponent::setup() { this->server_failed_(LOG_STR("creation")); return; } + // DEBUG: immediately after socket creation, ready() on an idle monitored socket + // MUST return false. If it returns true, loop_monitored_ is false — meaning + // App.register_socket() failed (likely because esphome_lwip_get_sock returned null + // because the socket fd is outside the lwip socket table range), and our wake hook + // was never installed on this socket. In that case the whole disable_loop+wake + // approach silently degrades — ready() stays true forever and we poll every tick. + ESP_LOGD(TAG, "setup: server_->ready() immediately after socket creation = %d (expect 0)", this->server_->ready()); int enable = 1; int err = this->server_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { @@ -104,16 +114,23 @@ void ESPHomeOTAComponent::loop() { // // Note: No need to check server_ for null — setup() marks the component failed // if server_ creation fails. - // DEBUG: self-disable removed. Log every tick with wake counter + ready state so - // we can tell (a) whether the lwip listener wake hook ever fires and (b) whether - // server_->ready() ever flips to true when a client tries to connect. + // DEBUG: self-disable removed; always poll so we can see ready/wake state. const uint32_t wake_count = App.ota_wake_count_debug(); +#ifdef USE_LWIP_FAST_SELECT + const uint32_t total_rcvplus = esphome_fast_select_rcvplus_total_debug; +#else + const uint32_t total_rcvplus = 0; +#endif const bool ready = this->server_->ready(); static uint32_t last_wake_count = 0; + static uint32_t last_total_rcvplus = 0; static bool last_ready = false; - if (wake_count != last_wake_count || ready != last_ready || this->client_ != nullptr) { - ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u", (void *) this->client_.get(), ready, wake_count); + if (wake_count != last_wake_count || total_rcvplus != last_total_rcvplus || ready != last_ready || + this->client_ != nullptr) { + ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u total_rcvplus=%u", (void *) this->client_.get(), ready, + wake_count, total_rcvplus); last_wake_count = wake_count; + last_total_rcvplus = total_rcvplus; last_ready = ready; } if (this->client_ == nullptr && !ready) { diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 96eddf00ff..a3d9898757 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,11 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +// DEBUG: counts every RCVPLUS the wrapper callback observes, for any monitored socket. +// Read by OTA's debug logging to distinguish "callback not firing at all" from +// "callback fires for other sockets but not OTA's listener". +volatile uint32_t esphome_fast_select_rcvplus_total_debug = 0; + #ifdef USE_OTA // Extern wake hook for the OTA component (implemented in application.cpp). Called from the // TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false @@ -180,6 +185,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { + esphome_fast_select_rcvplus_total_debug++; // DEBUG #ifdef USE_OTA // Mark the OTA component loop to be re-enabled if it disabled itself while idle. // This MUST happen before xTaskNotifyGive below — otherwise the main task could diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 20ac191673..6ed2704ef0 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,6 +53,9 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); +// DEBUG counter: total RCVPLUS events the wrapper callback observed across all monitored sockets. +extern volatile uint32_t esphome_fast_select_rcvplus_total_debug; + /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, From 08c09fbe367a85e765e6675615ee3545807f0dc0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:20:03 -1000 Subject: [PATCH 21/28] [esphome.ota] TEMP: add shim call counter to pinpoint C->C++ wake break --- esphome/components/esphome/ota/ota_esphome.cpp | 12 ++++++++---- esphome/core/application.cpp | 10 +++++++++- esphome/core/lwip_fast_select.h | 4 ++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fb583e6d7a..3367696ddd 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -118,19 +118,23 @@ void ESPHomeOTAComponent::loop() { const uint32_t wake_count = App.ota_wake_count_debug(); #ifdef USE_LWIP_FAST_SELECT const uint32_t total_rcvplus = esphome_fast_select_rcvplus_total_debug; + const uint32_t shim_count = esphome_ota_shim_call_count_debug; #else const uint32_t total_rcvplus = 0; + const uint32_t shim_count = 0; #endif const bool ready = this->server_->ready(); static uint32_t last_wake_count = 0; static uint32_t last_total_rcvplus = 0; + static uint32_t last_shim_count = 0; static bool last_ready = false; - if (wake_count != last_wake_count || total_rcvplus != last_total_rcvplus || ready != last_ready || - this->client_ != nullptr) { - ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u total_rcvplus=%u", (void *) this->client_.get(), ready, - wake_count, total_rcvplus); + if (wake_count != last_wake_count || total_rcvplus != last_total_rcvplus || shim_count != last_shim_count || + ready != last_ready || this->client_ != nullptr) { + ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u shim=%u total_rcvplus=%u", (void *) this->client_.get(), + ready, wake_count, shim_count, total_rcvplus); last_wake_count = wake_count; last_total_rcvplus = total_rcvplus; + last_shim_count = shim_count; last_ready = ready; } if (this->client_ == nullptr && !ready) { diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 5fc794d1f6..46f8922bc6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -450,11 +450,19 @@ void Application::enable_pending_loops_() { } #if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) +// DEBUG: directly-incremented C counter so we can tell whether the shim is being called at all, +// independent of whether the App.wake_ota_component_any_context() call inside it is working. +extern "C" { +volatile uint32_t esphome_ota_shim_call_count_debug = 0; +} // Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS, // BEFORE the callback calls xTaskNotifyGive() — the flag-set must happen before the wake, // otherwise the main task could wake, run a full iteration, and miss the pending-enable. // Only marks the OTA component as pending loop-enable; does not itself wake the main task. -extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } +extern "C" void esphome_wake_ota_component_any_context() { + esphome_ota_shim_call_count_debug++; + esphome::App.wake_ota_component_any_context(); +} #endif #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 6ed2704ef0..f152106f0e 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -56,6 +56,10 @@ void esphome_lwip_hook_socket(struct lwip_sock *sock); // DEBUG counter: total RCVPLUS events the wrapper callback observed across all monitored sockets. extern volatile uint32_t esphome_fast_select_rcvplus_total_debug; +// DEBUG counter: number of times esphome_wake_ota_component_any_context() C shim was entered. +// Independent of whether App.wake_ota_component_any_context() inside it actually ran. +extern volatile uint32_t esphome_ota_shim_call_count_debug; + /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, From 4630c7ad945c68bc5e3e1da9027a5817f7acb901 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:28:30 -1000 Subject: [PATCH 22/28] [ota] Emit USE_OTA as build flag + define so .c files can see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the wake failure: ota/__init__.py only called cg.add_define("USE_OTA"), which writes to the generated defines.h. That's invisible to .c translation units that can't include defines.h — notably lwip_fast_select.c, which can't include defines.h because macros.h → Arduino.h under Arduino builds would break the C compile. So the #ifdef USE_OTA gate inside lwip_fast_select.c's RCVPLUS callback was always false, and esphome_wake_ota_component_any_context() was never called. The listener callback fired (total rcvplus counter incremented from the device), but OTA's pending-enable flag was never set, so the main task woke and ran a loop iteration without touching the (disabled) OTA component. Fix: emit both cg.add_define (keeps defines.h in sync for static analyzers / IDEs) and cg.add_build_flag("-DUSE_OTA") (passes it as a compiler -D flag, visible to every .c TU). Also reverts all the diagnostic scaffolding (per-loop-tick logs, debug counters, runtime function pointer hook) that I added while chasing this. --- .../components/esphome/ota/ota_esphome.cpp | 74 +++++-------------- esphome/components/ota/__init__.py | 6 ++ esphome/core/application.cpp | 16 ++-- esphome/core/application.h | 7 +- esphome/core/lwip_fast_select.c | 19 ++--- esphome/core/lwip_fast_select.h | 7 -- 6 files changed, 37 insertions(+), 92 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3367696ddd..e2b12e1208 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -15,9 +15,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" -#ifdef USE_LWIP_FAST_SELECT -#include "esphome/core/lwip_fast_select.h" -#endif #include #include @@ -37,13 +34,6 @@ void ESPHomeOTAComponent::setup() { this->server_failed_(LOG_STR("creation")); return; } - // DEBUG: immediately after socket creation, ready() on an idle monitored socket - // MUST return false. If it returns true, loop_monitored_ is false — meaning - // App.register_socket() failed (likely because esphome_lwip_get_sock returned null - // because the socket fd is outside the lwip socket table range), and our wake hook - // was never installed on this socket. In that case the whole disable_loop+wake - // approach silently degrades — ready() stays true forever and we poll every tick. - ESP_LOGD(TAG, "setup: server_->ready() immediately after socket creation = %d (expect 0)", this->server_->ready()); int enable = 1; int err = this->server_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); if (err != 0) { @@ -79,7 +69,6 @@ void ESPHomeOTAComponent::setup() { // Register for socket wake notifications. loop() disables itself on its first // idle tick — no need to disable_loop() here explicitly. App.set_ota_wake_component(this); - ESP_LOGD(TAG, "setup complete: registered wake component, listener fd ready"); } void ESPHomeOTAComponent::dump_config() { @@ -96,49 +85,26 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Self-disabling idle loop. On the first tick after setup() (and after every - // session cleanup and after every false wake), if there's no client and the - // listener has nothing queued, we disable ourselves and go back to sleep. - // Socket-wake paths (LwIP fast select, raw TCP accept, host select) mark us - // pending-enable via App.wake_ota_component_any_context() when a monitored - // socket signals activity, and enable_pending_loops_() reactivates us. + // Self-disabling idle loop. On the first tick after setup() (and after every session + // cleanup and every false wake), if there's no client and the listener has nothing + // queued, we disable ourselves and go back to sleep. Socket-wake paths (LwIP fast + // select, raw TCP accept, host select) mark us pending-enable via + // App.wake_ota_component_any_context() when a monitored socket signals activity, and + // enable_pending_loops_() reactivates us. // - // False wakes from unrelated monitored sockets are expected — the event - // callbacks fire on every RCVPLUS across all monitored sockets, not just - // OTA's listener — and they land here with no pending work. + // False wakes from unrelated monitored sockets are expected — the event callbacks + // fire on every RCVPLUS across all monitored sockets, not just OTA's listener — and + // they land here with no pending work. // - // cleanup_connection_() deliberately does NOT call disable_loop() — letting - // loop() run one more iteration after a session ends guarantees we re-read - // server_->ready() and either accept a client that queued during the session - // or disable cleanly here. + // cleanup_connection_() deliberately does NOT call disable_loop() — letting loop() + // run one more iteration after a session ends guarantees we re-read server_->ready() + // and either accept a client that queued during the session or disable cleanly here. // - // Note: No need to check server_ for null — setup() marks the component failed - // if server_ creation fails. - // DEBUG: self-disable removed; always poll so we can see ready/wake state. - const uint32_t wake_count = App.ota_wake_count_debug(); -#ifdef USE_LWIP_FAST_SELECT - const uint32_t total_rcvplus = esphome_fast_select_rcvplus_total_debug; - const uint32_t shim_count = esphome_ota_shim_call_count_debug; -#else - const uint32_t total_rcvplus = 0; - const uint32_t shim_count = 0; -#endif - const bool ready = this->server_->ready(); - static uint32_t last_wake_count = 0; - static uint32_t last_total_rcvplus = 0; - static uint32_t last_shim_count = 0; - static bool last_ready = false; - if (wake_count != last_wake_count || total_rcvplus != last_total_rcvplus || shim_count != last_shim_count || - ready != last_ready || this->client_ != nullptr) { - ESP_LOGD(TAG, "loop tick: client=%p ready=%d wakes=%u shim=%u total_rcvplus=%u", (void *) this->client_.get(), - ready, wake_count, shim_count, total_rcvplus); - last_wake_count = wake_count; - last_total_rcvplus = total_rcvplus; - last_shim_count = shim_count; - last_ready = ready; - } - if (this->client_ == nullptr && !ready) { - return; // stay in LOOP state so we can poll every tick for diagnosis + // Note: No need to check server_ for null — setup() marks the component failed if + // server_ creation fails. + if (this->client_ == nullptr && !this->server_->ready()) { + this->disable_loop(); + return; } this->handle_handshake_(); } @@ -159,13 +125,9 @@ void ESPHomeOTAComponent::handle_handshake_() { socklen_t addr_len = sizeof(source_addr); int enable = 1; - ESP_LOGD(TAG, "handle_handshake_: attempting accept"); this->client_ = this->server_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); - if (this->client_ == nullptr) { - ESP_LOGD(TAG, "handle_handshake_: accept returned null (would-block)"); + if (this->client_ == nullptr) return; - } - ESP_LOGD(TAG, "handle_handshake_: accept ok, client=%p", (void *) this->client_.get()); int err = this->client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); if (err != 0) { this->log_socket_error_(LOG_STR("nodelay")); diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 8f31eb5cdd..e7a362ca10 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -102,7 +102,13 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): + # Both: add_define keeps defines.h in sync for static analyzers / IDEs that read it, + # while add_build_flag passes -DUSE_OTA as a compiler flag so USE_OTA is visible in .c + # translation units that cannot include defines.h (lwip_fast_select.c in particular — + # including defines.h would drag in macros.h → Arduino.h under Arduino builds and + # break the C compile). Needed for the fast-select OTA wake hook. cg.add_define("USE_OTA") + cg.add_build_flag("-DUSE_OTA") CORE.add_job(final_step) if CORE.is_rp2040 and CORE.using_arduino: diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 46f8922bc6..d99b7f28d9 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -450,19 +450,13 @@ void Application::enable_pending_loops_() { } #if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) -// DEBUG: directly-incremented C counter so we can tell whether the shim is being called at all, -// independent of whether the App.wake_ota_component_any_context() call inside it is working. -extern "C" { -volatile uint32_t esphome_ota_shim_call_count_debug = 0; -} // Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS, -// BEFORE the callback calls xTaskNotifyGive() — the flag-set must happen before the wake, -// otherwise the main task could wake, run a full iteration, and miss the pending-enable. +// BEFORE that callback calls xTaskNotifyGive() — pending-enable flags must be visible before +// the main task wakes, or the main loop can run a full iteration without seeing the request. // Only marks the OTA component as pending loop-enable; does not itself wake the main task. -extern "C" void esphome_wake_ota_component_any_context() { - esphome_ota_shim_call_count_debug++; - esphome::App.wake_ota_component_any_context(); -} +// OTA's __init__.py emits USE_OTA as both a cg.add_define (for static analyzers) AND a +// cg.add_build_flag (so the .c fast-select file also sees it). +extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/core/application.h b/esphome/core/application.h index 185991a37a..67ef1d666f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -572,15 +572,11 @@ class Application { /// Not IRAM_ATTR: all callers run in task / user-IRQ context, not a real ISR, /// and the LwIP event callbacks that invoke this are not IRAM-resident either. void wake_ota_component_any_context() { - this->ota_wake_count_debug_++; if (this->ota_wake_component_ != nullptr) { this->ota_wake_component_->pending_enable_loop_ = true; this->has_pending_enable_loop_requests_ = true; } } - /// DEBUG: monotonically increasing count of wake calls, regardless of whether - /// ota_wake_component_ was set. Read from OTA::loop() to verify the hook fires. - uint32_t ota_wake_count_debug() const { return this->ota_wake_count_debug_; } #endif protected: @@ -659,8 +655,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; #ifdef USE_OTA - Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications - volatile uint32_t ota_wake_count_debug_{0}; // DEBUG: incremented by wake_ota_component_any_context + Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications #endif // std::vector (3 pointers each: begin, end, capacity) diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index a3d9898757..ab20bdd29e 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,17 +157,15 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; -// DEBUG: counts every RCVPLUS the wrapper callback observes, for any monitored socket. -// Read by OTA's debug logging to distinguish "callback not firing at all" from -// "callback fires for other sockets but not OTA's listener". -volatile uint32_t esphome_fast_select_rcvplus_total_debug = 0; - #ifdef USE_OTA // Extern wake hook for the OTA component (implemented in application.cpp). Called from the // TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false // wake from an unrelated monitored socket. OTA::loop() handles that by disabling itself again // when there is no pending work. The hook only marks the OTA component as pending loop-enable; // it does not itself wake the main task (the caller below already does that). +// NOTE: USE_OTA reaches this file only because ota/__init__.py adds it as a build flag +// (not a cg.add_define). defines.h cannot be included from this .c file (it pulls in +// macros.h → Arduino.h under Arduino builds). extern void esphome_wake_ota_component_any_context(void); #endif @@ -185,14 +183,11 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { - esphome_fast_select_rcvplus_total_debug++; // DEBUG -#ifdef USE_OTA - // Mark the OTA component loop to be re-enabled if it disabled itself while idle. - // This MUST happen before xTaskNotifyGive below — otherwise the main task could - // wake, run a full iteration, and finish before we set the pending-enable flags, - // causing the wake event to be lost until the next unrelated socket activity. + // Invoke the OTA wake hook BEFORE xTaskNotifyGive — if OTA is compiled in, this marks + // its component pending-enable, and those flags must be visible before we wake the + // main task. Otherwise the main loop could run a full iteration without seeing the + // pending-enable request. When OTA is not compiled in, this function's body is empty. esphome_wake_ota_component_any_context(); -#endif TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index f152106f0e..20ac191673 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,13 +53,6 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); -// DEBUG counter: total RCVPLUS events the wrapper callback observed across all monitored sockets. -extern volatile uint32_t esphome_fast_select_rcvplus_total_debug; - -// DEBUG counter: number of times esphome_wake_ota_component_any_context() C shim was entered. -// Independent of whether App.wake_ota_component_any_context() inside it actually ran. -extern volatile uint32_t esphome_ota_shim_call_count_debug; - /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, From 0480f43984f51a3a0d60831f0d6830ca3d981e91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:30:22 -1000 Subject: [PATCH 23/28] [ota] Use ESPHOME_USE_OTA build flag to avoid USE_OTA redefinition warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emitting -DUSE_OTA alongside the defines.h #define USE_OTA entry caused 'USE_OTA redefined' warnings in every TU that includes defines.h. Use a distinct ESPHOME_USE_OTA name for the compiler -D flag — only the .c files that cannot include defines.h (lwip_fast_select.c) reference the ESPHOME_-prefixed name, and everyone else continues to use USE_OTA via defines.h. --- esphome/components/ota/__init__.py | 12 ++++++------ esphome/core/lwip_fast_select.c | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index e7a362ca10..3abfd2c69e 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -102,13 +102,13 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): - # Both: add_define keeps defines.h in sync for static analyzers / IDEs that read it, - # while add_build_flag passes -DUSE_OTA as a compiler flag so USE_OTA is visible in .c - # translation units that cannot include defines.h (lwip_fast_select.c in particular — - # including defines.h would drag in macros.h → Arduino.h under Arduino builds and - # break the C compile). Needed for the fast-select OTA wake hook. cg.add_define("USE_OTA") - cg.add_build_flag("-DUSE_OTA") + # Separate compiler -D flag using an ESPHOME_-prefixed name so .c translation units + # (which cannot include defines.h because macros.h → Arduino.h breaks the C compile + # under Arduino builds) can still tell OTA is compiled in. Needed by the fast-select + # OTA wake hook in lwip_fast_select.c. A distinct name avoids the "USE_OTA redefined" + # warning that would fire if we also emitted -DUSE_OTA — defines.h already has it. + cg.add_build_flag("-DESPHOME_USE_OTA") CORE.add_job(final_step) if CORE.is_rp2040 and CORE.using_arduino: diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index ab20bdd29e..613d1f29ec 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,15 +157,16 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; -#ifdef USE_OTA +#ifdef ESPHOME_USE_OTA // Extern wake hook for the OTA component (implemented in application.cpp). Called from the // TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false // wake from an unrelated monitored socket. OTA::loop() handles that by disabling itself again // when there is no pending work. The hook only marks the OTA component as pending loop-enable; // it does not itself wake the main task (the caller below already does that). -// NOTE: USE_OTA reaches this file only because ota/__init__.py adds it as a build flag -// (not a cg.add_define). defines.h cannot be included from this .c file (it pulls in -// macros.h → Arduino.h under Arduino builds). +// NOTE: ESPHOME_USE_OTA (not USE_OTA) because USE_OTA only lives in defines.h, and this .c +// file cannot include defines.h — macros.h → Arduino.h would break the C compile under +// Arduino builds. ota/__init__.py emits -DESPHOME_USE_OTA as a build flag specifically so +// this file can see it without a name collision with the defines.h USE_OTA entry. extern void esphome_wake_ota_component_any_context(void); #endif @@ -183,11 +184,12 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { - // Invoke the OTA wake hook BEFORE xTaskNotifyGive — if OTA is compiled in, this marks - // its component pending-enable, and those flags must be visible before we wake the - // main task. Otherwise the main loop could run a full iteration without seeing the - // pending-enable request. When OTA is not compiled in, this function's body is empty. +#ifdef ESPHOME_USE_OTA + // Mark the OTA component pending-enable BEFORE xTaskNotifyGive — the flags must be + // visible before we wake the main task, otherwise the main loop could run a full + // iteration without seeing the pending-enable request. esphome_wake_ota_component_any_context(); +#endif TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { xTaskNotifyGive(task); From 1fe2588c08acdfe329d8bc60bb156ff8b730fe02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:56:48 -1000 Subject: [PATCH 24/28] [esphome.ota] Inline fast-select wake hook via ota_wake_hook.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extern C shim esphome_wake_ota_component_any_context() was an out-of-line call from lwip_fast_select.c into application.cpp: save registers, call, prologue, two stores, epilogue, ret. Per-RCVPLUS, that's ~10-15 Xtensa cycles of pure call overhead on top of the two volatile bool stores the shim actually does. Move the body into a new C-compatible header (esphome/core/ota_wake_hook.h) as a static inline, backed by two extern C 'volatile bool *' globals that point at Component::pending_enable_loop_ and Application::has_pending_enable_loop_requests_. set_ota_wake_component() captures the addresses once at registration time; the fast-select callback then inlines a null-check + two volatile stores with zero call overhead. The main loop sees the same two flags it already checks every iteration (has_pending_enable_loop_requests_ gates enable_pending_loops_, which iterates the inactive section looking for components with pending_enable_loop_ set). Zero new main-loop work — the inline hook writes exactly the state enable_loop_soon_any_context() would have written. RAM change: -4 bytes on Application (ota_wake_component_ field removed) plus +8 bytes in BSS for the two extern pointers. Net +4 bytes RAM. Raw-TCP and HOST paths switched from App.wake_ota_component_any_context() to the inline hook too. --- .../components/socket/lwip_raw_tcp_impl.cpp | 8 ++-- esphome/core/application.cpp | 36 ++++++++++----- esphome/core/application.h | 28 ++++-------- esphome/core/lwip_fast_select.c | 3 ++ esphome/core/ota_wake_hook.h | 45 +++++++++++++++++++ 5 files changed, 84 insertions(+), 36 deletions(-) create mode 100644 esphome/core/ota_wake_hook.h diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index cfd8cf0daa..b35338a2bf 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -11,7 +11,7 @@ #include "esphome/core/wake.h" #include "esphome/core/log.h" #ifdef USE_OTA -#include "esphome/core/application.h" +#include "esphome/core/ota_wake_hook.h" #endif #ifdef USE_ESP8266 @@ -861,9 +861,9 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Mark the OTA component loop to be re-enabled if it disabled itself while idle. // This MUST happen before wake_loop_any_context() below — otherwise the main loop // could wake, run a full iteration, and finish before we set the pending-enable - // flags, losing the wake event. Safe from RP2040's low-priority user IRQ context: - // it only writes volatile bools, no heap or locks. - esphome::App.wake_ota_component_any_context(); + // flags, losing the wake event. Inline hook (ota_wake_hook.h) — two volatile stores, + // no function call. Safe from RP2040's low-priority user IRQ context. + esphome_wake_ota_component_any_context(); #endif // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d99b7f28d9..b2b2e0fdbe 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -2,6 +2,9 @@ #include "esphome/core/build_info_data.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" +#ifdef USE_OTA +#include "esphome/core/ota_wake_hook.h" +#endif #include #ifdef USE_ESP8266 @@ -449,14 +452,24 @@ void Application::enable_pending_loops_() { } } -#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) -// Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS, -// BEFORE that callback calls xTaskNotifyGive() — pending-enable flags must be visible before -// the main task wakes, or the main loop can run a full iteration without seeing the request. -// Only marks the OTA component as pending loop-enable; does not itself wake the main task. -// OTA's __init__.py emits USE_OTA as both a cg.add_define (for static analyzers) AND a -// cg.add_build_flag (so the .c fast-select file also sees it). -extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } +#ifdef USE_OTA +// Storage for the inline OTA wake hook (see esphome/core/ota_wake_hook.h). Set in +// Application::set_ota_wake_component() and read from the lwip fast-select callback on +// every NETCONN_EVT_RCVPLUS. Kept as raw C globals so the .c file can inline the wake +// body (two volatile stores) without a function-call round trip into application.cpp. +extern "C" { +volatile bool *esphome_ota_pending_enable_loop_ptr = nullptr; +volatile bool *esphome_ota_has_pending_requests_ptr = nullptr; +} + +void Application::set_ota_wake_component(Component *component) { + // Application is a friend of Component — can take the address of its protected + // pending_enable_loop_ field. The C-side inline hook writes through that pointer when + // any monitored socket fires RCVPLUS, making the flags visible to enable_pending_loops_() + // on the next main loop iteration. + esphome_ota_pending_enable_loop_ptr = &component->pending_enable_loop_; + esphome_ota_has_pending_requests_ptr = &this->has_pending_enable_loop_requests_; +} #endif #ifdef USE_LWIP_FAST_SELECT @@ -566,11 +579,10 @@ void Application::yield_with_select_(uint32_t delay_ms) { if (ret >= 0) [[likely]] { #ifdef USE_OTA // Dead code today — host does not currently support the esphome OTA platform, - // so ota_wake_component_ is never set and this call is a no-op. Kept so the - // wake path works out of the box if host ever gains OTA support. Harmless - // cost: a single nullptr check per select() return. + // so the inline hook's pointers are always NULL and this is a no-op. Kept so the + // wake path works out of the box if host ever gains OTA support. if (ret > 0) { - this->wake_ota_component_any_context(); + esphome_wake_ota_component_any_context(); } #endif // Yield if zero timeout since select(0) only polls without yielding diff --git a/esphome/core/application.h b/esphome/core/application.h index 67ef1d666f..48fc620cdc 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -560,23 +560,14 @@ class Application { static void IRAM_ATTR wake_loop_any_context() { esphome::wake_loop_any_context(); } #ifdef USE_OTA - /// Register the OTA component so socket-wake paths can enable its loop when - /// 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; } - /// Mark the registered OTA component (if any) for loop re-enable. 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. - /// Not IRAM_ATTR: all callers run in task / user-IRQ context, not a real ISR, - /// and the LwIP event callbacks that invoke this are not IRAM-resident either. - void wake_ota_component_any_context() { - if (this->ota_wake_component_ != nullptr) { - this->ota_wake_component_->pending_enable_loop_ = true; - this->has_pending_enable_loop_requests_ = true; - } - } + /// Register the OTA component so socket-wake paths can enable its loop when a new + /// connection arrives on the listening socket. Captures the address of the component's + /// pending_enable_loop_ flag and the Application has_pending_enable_loop_requests_ flag + /// into extern C globals consumed by the inline wake hook in ota_wake_hook.h. Defined + /// out-of-line in application.cpp so application.h doesn't need to pull in the hook + /// header. OTA calls this once from setup(); the component itself then self-disables + /// its loop on its first idle tick. + void set_ota_wake_component(Component *component); #endif protected: @@ -654,9 +645,6 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; -#ifdef USE_OTA - Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications -#endif // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 613d1f29ec..f0cafc2dbe 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -124,6 +124,9 @@ #include "esphome/core/lwip_fast_select.h" #include "esphome/core/main_task.h" +#ifdef ESPHOME_USE_OTA +#include "esphome/core/ota_wake_hook.h" +#endif #include diff --git a/esphome/core/ota_wake_hook.h b/esphome/core/ota_wake_hook.h new file mode 100644 index 0000000000..06ed1623df --- /dev/null +++ b/esphome/core/ota_wake_hook.h @@ -0,0 +1,45 @@ +#pragma once + +// Inline OTA wake hook, called from lwip_fast_select.c on every NETCONN_EVT_RCVPLUS so a +// disabled OTA loop can be re-enabled when a monitored socket signals activity. +// +// Defined as a static inline here (rather than an out-of-line extern "C" shim into +// application.cpp) so the fast-select callback pays zero function-call overhead per +// socket event: the two volatile stores below are cheaper inlined than dispatched. +// +// The two pointers are set once in Application::set_ota_wake_component() to the addresses +// of Component::pending_enable_loop_ and Application::has_pending_enable_loop_requests_. +// Accessing those C++ members by raw address is safe: Application is a friend of Component +// (granting access at registration time), and volatile writes through a bool* see the same +// storage the C++ side reads. + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Address of the registered OTA component's pending_enable_loop_ flag. NULL until +// Application::set_ota_wake_component() is called. When non-NULL, the has-pending +// pointer below is also non-NULL, so a single null check covers both. +extern volatile bool *esphome_ota_pending_enable_loop_ptr; +// Address of Application::has_pending_enable_loop_requests_. Set in tandem with the +// pending_enable pointer above. +extern volatile bool *esphome_ota_has_pending_requests_ptr; + +// Mark the registered OTA component pending loop-enable. Safe to call from LwIP TCP/IP +// task context and raw-TCP IRQ context — only writes to volatile bools, no locks. +// Callers must invoke this BEFORE waking the main task, so the flags are visible to the +// main loop's next iteration. +static inline void esphome_wake_ota_component_any_context(void) { + volatile bool *pending_enable = esphome_ota_pending_enable_loop_ptr; + if (pending_enable != NULL) { + *pending_enable = true; + *esphome_ota_has_pending_requests_ptr = true; + } +} + +#ifdef __cplusplus +} +#endif From 5f04cff8bc043aced5f91c1535663a19c30934b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:01:07 -1000 Subject: [PATCH 25/28] [esphome.ota] Fold OTA wake hook into wake.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All wake_* state lives in one place now. wake.h gains a C-compatible section at the top (the inline esphome_wake_ota_component_any_context() + its two extern 'volatile bool *' globals) guarded outside any C++ namespace, with the existing C++ platform wake primitives moved behind an outer #ifdef __cplusplus. lwip_fast_select.c includes wake.h directly for the inline; .cpp files continue to see the C++ side as before. Deletes the ephemeral esphome/core/ota_wake_hook.h — same code, better home. --- .../components/socket/lwip_raw_tcp_impl.cpp | 6 +- esphome/core/application.cpp | 17 +++--- esphome/core/application.h | 12 ++-- esphome/core/lwip_fast_select.c | 2 +- esphome/core/ota_wake_hook.h | 45 -------------- esphome/core/wake.h | 59 +++++++++++++++++++ 6 files changed, 78 insertions(+), 63 deletions(-) delete mode 100644 esphome/core/ota_wake_hook.h diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b35338a2bf..b6e5730b66 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -11,7 +11,7 @@ #include "esphome/core/wake.h" #include "esphome/core/log.h" #ifdef USE_OTA -#include "esphome/core/ota_wake_hook.h" +#include "esphome/core/wake.h" // inline esphome_wake_ota_component_any_context() lives here #endif #ifdef USE_ESP8266 @@ -861,8 +861,8 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Mark the OTA component loop to be re-enabled if it disabled itself while idle. // This MUST happen before wake_loop_any_context() below — otherwise the main loop // could wake, run a full iteration, and finish before we set the pending-enable - // flags, losing the wake event. Inline hook (ota_wake_hook.h) — two volatile stores, - // no function call. Safe from RP2040's low-priority user IRQ context. + // flags, losing the wake event. Inline hook (wake.h) — two volatile stores, no + // function call. Safe from RP2040's low-priority user IRQ context. esphome_wake_ota_component_any_context(); #endif // Wake the main loop immediately so it can accept the new connection. diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b2b2e0fdbe..21d3ef4494 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -2,9 +2,7 @@ #include "esphome/core/build_info_data.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" -#ifdef USE_OTA -#include "esphome/core/ota_wake_hook.h" -#endif +#include "esphome/core/wake.h" #include #ifdef USE_ESP8266 @@ -452,16 +450,19 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_OTA -// Storage for the inline OTA wake hook (see esphome/core/ota_wake_hook.h). Set in -// Application::set_ota_wake_component() and read from the lwip fast-select callback on -// every NETCONN_EVT_RCVPLUS. Kept as raw C globals so the .c file can inline the wake -// body (two volatile stores) without a function-call round trip into application.cpp. +// Storage for the inline OTA wake hook (see esphome/core/wake.h). Set in +// Application::set_ota_wake_component() and read from the lwip fast-select callback +// on every NETCONN_EVT_RCVPLUS. Kept as raw C globals so the .c file can inline the +// wake body (two volatile stores) without a function-call round trip. Defined +// unconditionally so wake.h's inline compiles the same whether or not OTA is in the +// build — when OTA is absent nothing ever calls set_ota_wake_component() and the +// pointers stay nullptr, collapsing the inline to a single null check. extern "C" { volatile bool *esphome_ota_pending_enable_loop_ptr = nullptr; volatile bool *esphome_ota_has_pending_requests_ptr = nullptr; } +#ifdef USE_OTA void Application::set_ota_wake_component(Component *component) { // Application is a friend of Component — can take the address of its protected // pending_enable_loop_ field. The C-side inline hook writes through that pointer when diff --git a/esphome/core/application.h b/esphome/core/application.h index 48fc620cdc..baf8e21a99 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -561,12 +561,12 @@ class Application { #ifdef USE_OTA /// Register the OTA component so socket-wake paths can enable its loop when a new - /// connection arrives on the listening socket. Captures the address of the component's - /// pending_enable_loop_ flag and the Application has_pending_enable_loop_requests_ flag - /// into extern C globals consumed by the inline wake hook in ota_wake_hook.h. Defined - /// out-of-line in application.cpp so application.h doesn't need to pull in the hook - /// header. OTA calls this once from setup(); the component itself then self-disables - /// its loop on its first idle tick. + /// connection arrives on the listening socket. Captures the addresses of the component's + /// pending_enable_loop_ flag and Application's has_pending_enable_loop_requests_ flag + /// into extern-C globals consumed by the inline wake hook in wake.h. Defined out-of-line + /// in application.cpp so application.h doesn't need to pull in wake.h. OTA calls this + /// once from setup(); the component itself then self-disables its loop on its first + /// idle tick. void set_ota_wake_component(Component *component); #endif diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index f0cafc2dbe..061fafbc51 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -125,7 +125,7 @@ #include "esphome/core/lwip_fast_select.h" #include "esphome/core/main_task.h" #ifdef ESPHOME_USE_OTA -#include "esphome/core/ota_wake_hook.h" +#include "esphome/core/wake.h" // inline esphome_wake_ota_component_any_context() lives here #endif #include diff --git a/esphome/core/ota_wake_hook.h b/esphome/core/ota_wake_hook.h deleted file mode 100644 index 06ed1623df..0000000000 --- a/esphome/core/ota_wake_hook.h +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -// Inline OTA wake hook, called from lwip_fast_select.c on every NETCONN_EVT_RCVPLUS so a -// disabled OTA loop can be re-enabled when a monitored socket signals activity. -// -// Defined as a static inline here (rather than an out-of-line extern "C" shim into -// application.cpp) so the fast-select callback pays zero function-call overhead per -// socket event: the two volatile stores below are cheaper inlined than dispatched. -// -// The two pointers are set once in Application::set_ota_wake_component() to the addresses -// of Component::pending_enable_loop_ and Application::has_pending_enable_loop_requests_. -// Accessing those C++ members by raw address is safe: Application is a friend of Component -// (granting access at registration time), and volatile writes through a bool* see the same -// storage the C++ side reads. - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -// Address of the registered OTA component's pending_enable_loop_ flag. NULL until -// Application::set_ota_wake_component() is called. When non-NULL, the has-pending -// pointer below is also non-NULL, so a single null check covers both. -extern volatile bool *esphome_ota_pending_enable_loop_ptr; -// Address of Application::has_pending_enable_loop_requests_. Set in tandem with the -// pending_enable pointer above. -extern volatile bool *esphome_ota_has_pending_requests_ptr; - -// Mark the registered OTA component pending loop-enable. Safe to call from LwIP TCP/IP -// task context and raw-TCP IRQ context — only writes to volatile bools, no locks. -// Callers must invoke this BEFORE waking the main task, so the flags are visible to the -// main loop's next iteration. -static inline void esphome_wake_ota_component_any_context(void) { - volatile bool *pending_enable = esphome_ota_pending_enable_loop_ptr; - if (pending_enable != NULL) { - *pending_enable = true; - *esphome_ota_has_pending_requests_ptr = true; - } -} - -#ifdef __cplusplus -} -#endif diff --git a/esphome/core/wake.h b/esphome/core/wake.h index a8c9b7ad08..9ad761e8aa 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -3,6 +3,63 @@ /// @file wake.h /// Platform-specific main loop wake primitives. /// Always available on all platforms — no opt-in needed. +/// +/// This file has two sections: +/// 1. A C-compatible section at the top (the inline OTA wake hook) that .c files +/// like lwip_fast_select.c can include. Uses only /. +/// 2. A C++ section (everything after #ifdef __cplusplus below) with the existing +/// platform wake primitives and wakeable_delay() implementations. + +// ============================================================================ +// C-compatible section: inline OTA wake hook +// ============================================================================ +// +// Called from lwip_fast_select.c (and lwip_raw_tcp_impl.cpp / host select) on every +// NETCONN_EVT_RCVPLUS so a disabled OTA loop can be re-enabled when a monitored +// socket signals activity. Static inline (not an extern-C trampoline) so the .c +// callback pays zero function-call overhead — just a pointer load, null check, and +// two volatile bool stores inlined into the callback body. +// +// The two pointers are captured once in Application::set_ota_wake_component(): they +// point at Component::pending_enable_loop_ and Application::has_pending_enable_loop_requests_. +// When OTA is not compiled into the build, nothing calls set_ota_wake_component(), +// the pointers stay NULL, and the inline collapses to a single null check. + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Address of the registered OTA component's pending_enable_loop_ flag, captured at +// registration time. NULL when no OTA component has registered (including when OTA +// is not compiled in at all). +extern volatile bool *esphome_ota_pending_enable_loop_ptr; +// Address of Application::has_pending_enable_loop_requests_. Set in tandem with the +// pending_enable pointer above — single null check covers both. +extern volatile bool *esphome_ota_has_pending_requests_ptr; + +// Mark the registered OTA component pending loop-enable. Safe from the LwIP TCP/IP +// task and raw-TCP IRQ context — only volatile stores, no locks. Callers MUST invoke +// this BEFORE waking the main task, so the flags are visible on the next iteration. +static inline void esphome_wake_ota_component_any_context(void) { + volatile bool *pending_enable = esphome_ota_pending_enable_loop_ptr; + if (pending_enable != NULL) { + *pending_enable = true; + *esphome_ota_has_pending_requests_ptr = true; + } +} + +#ifdef __cplusplus +} // extern "C" +#endif + +// ============================================================================ +// C++ section: platform wake primitives +// ============================================================================ + +#ifdef __cplusplus #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -124,3 +181,5 @@ inline void wakeable_delay(uint32_t ms) { #endif } // namespace esphome + +#endif // __cplusplus From 01beb56899202b4608f5cd6935d285c538842c8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:14:00 -1000 Subject: [PATCH 26/28] [esphome.ota] Filter fast-select wake hook to OTA listener netconn only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline OTA wake hook was firing on every NETCONN_EVT_RCVPLUS across every monitored socket (API client data packets, mDNS queries, web server, etc.). Each false fire paid two volatile stores + memw barriers to mark OTA pending-enable, only for OTA::loop() to run a wake-up tick and re-disable itself because there was no actual listener activity. Add a compare-against-listener filter in esphome_socket_event_callback so the wake hook only fires when `conn` matches the OTA listen socket's netconn. Non-match sockets now cost only a pointer load + one branch (~3 instructions) instead of the full ~10-instruction hook body. Plumbing: - lwip_fast_select.[ch]: new s_ota_listener_conn global + esphome_fast_select_set_ota_listener_sock() setter, used in the callback. - BSDSocketImpl / LwIPSocketImpl: new public get_cached_sock() accessor (only under USE_LWIP_FAST_SELECT) mirroring the existing get_fd() pattern. - ESPHomeOTAComponent::setup(): after registering the wake component, install the listener filter with this->server_->get_cached_sock(). Raw TCP (ESP8266/RP2040) is unaffected — that path wakes from LWIPRawListenImpl::accept_fn_, which only fires for the specific listener pcb it was registered on, so the filtering is implicit there. --- .../components/esphome/ota/ota_esphome.cpp | 11 +++++ esphome/components/socket/bsd_sockets_impl.h | 9 ++++ esphome/components/socket/lwip_sockets_impl.h | 7 ++++ esphome/core/lwip_fast_select.c | 42 ++++++++++++------- esphome/core/lwip_fast_select.h | 8 ++++ 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index e2b12e1208..af2a609642 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -15,6 +15,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_LWIP_FAST_SELECT +#include "esphome/core/lwip_fast_select.h" +#endif #include #include @@ -69,6 +72,14 @@ void ESPHomeOTAComponent::setup() { // Register for socket wake notifications. loop() disables itself on its first // idle tick — no need to disable_loop() here explicitly. App.set_ota_wake_component(this); +#ifdef USE_LWIP_FAST_SELECT + // Install the listener filter so the fast-select RCVPLUS wake hook only fires for + // events on this listener's netconn (i.e. new incoming connections). Without this, + // every RCVPLUS across all monitored sockets (API client data, mDNS, etc.) would + // pay the inline hook's two volatile stores + memw barriers to mark OTA + // pending-enable, even though OTA would just re-disable itself on the next tick. + esphome_fast_select_set_ota_listener_sock(this->server_->get_cached_sock()); +#endif } void ESPHomeOTAComponent::dump_config() { diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index e520784702..ceb7556d9f 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -118,6 +118,15 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } +#ifdef USE_LWIP_FAST_SELECT + // Cached lwip_sock pointer captured at construction. Used by OTA to register its + // listener netconn with the fast-select wake hook filter so the hook only fires on + // OTA-relevant events. Returns nullptr for non-monitored sockets. + struct lwip_sock *get_cached_sock() const { + return this->cached_sock_; + } +#endif + protected: int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 942d0ccf85..cda37b5dc6 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -84,6 +84,13 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } +#ifdef USE_LWIP_FAST_SELECT + // See BSDSocketImpl::get_cached_sock() — same purpose, same semantics. + struct lwip_sock *get_cached_sock() const { + return this->cached_sock_; + } +#endif + protected: int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 061fafbc51..60a1cf60f1 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -161,16 +161,24 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN static netconn_callback s_original_callback = NULL; #ifdef ESPHOME_USE_OTA -// Extern wake hook for the OTA component (implemented in application.cpp). Called from the -// TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false -// wake from an unrelated monitored socket. OTA::loop() handles that by disabling itself again -// when there is no pending work. The hook only marks the OTA component as pending loop-enable; -// it does not itself wake the main task (the caller below already does that). -// NOTE: ESPHOME_USE_OTA (not USE_OTA) because USE_OTA only lives in defines.h, and this .c -// file cannot include defines.h — macros.h → Arduino.h would break the C compile under -// Arduino builds. ota/__init__.py emits -DESPHOME_USE_OTA as a build flag specifically so -// this file can see it without a name collision with the defines.h USE_OTA entry. -extern void esphome_wake_ota_component_any_context(void); +// OTA listener netconn, captured via esphome_fast_select_set_ota_listener_sock() at OTA +// setup(). The wake hook only fires when the callback's `conn` argument matches this +// pointer — i.e. only for RCVPLUS events on the OTA listen socket (new accepts). Avoids +// paying the inline wake hook's cost (two volatile stores + memw barriers) on every API +// client data packet, mDNS query, etc. +static struct netconn *s_ota_listener_conn = NULL; +// Inline wake hook defined in esphome/core/wake.h. ESPHOME_USE_OTA (not USE_OTA) because +// USE_OTA only lives in defines.h, and this .c file cannot include defines.h — macros.h → +// Arduino.h would break the C compile under Arduino builds. ota/__init__.py emits +// -DESPHOME_USE_OTA as a build flag so this file can see it without a name collision with +// the defines.h USE_OTA entry. +#include "esphome/core/wake.h" + +void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { + s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL; +} +#else +void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { (void) sock; } #endif // Wrapper callback: calls original event_callback + notifies main loop task. @@ -188,10 +196,16 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { #ifdef ESPHOME_USE_OTA - // Mark the OTA component pending-enable BEFORE xTaskNotifyGive — the flags must be - // visible before we wake the main task, otherwise the main loop could run a full - // iteration without seeing the pending-enable request. - esphome_wake_ota_component_any_context(); + // Filter: only mark OTA pending-enable when the event is for OTA's listen socket. + // Without this, every RCVPLUS (API client data, mDNS, etc.) would pay the inline + // wake hook's two volatile stores + memw barriers. The setter that installs + // s_ota_listener_conn is called from OTA setup(); until then the pointer is NULL + // and the filter skips the wake work entirely, which is the correct idle behavior. + // MUST happen before xTaskNotifyGive below — the flags have to be visible before + // the main task wakes, or the main loop could run a full iteration and miss them. + if (conn == s_ota_listener_conn) { + esphome_wake_ota_component_any_context(); + } #endif TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 20ac191673..9e1438ecdd 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,6 +53,14 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); +/// Filter the inline OTA wake hook in the fast-select callback so it only fires for +/// RCVPLUS events on this specific listener's netconn. Without this, every monitored +/// socket's RCVPLUS (API client data, web server, mDNS, etc.) would mark OTA +/// pending-enable and force its loop to run a wake-up tick only to re-disable itself. +/// Captured at OTA setup(); stays pointing at the listener for the device's lifetime. +/// Pass NULL to clear the filter (wake fires on every RCVPLUS, pre-filter behavior). +void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock); + /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, From 1490845dcf2e59ef04e0f827101c9cc514a919ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:15:04 -1000 Subject: [PATCH 27/28] [esphome.ota] Use existing esphome_lwip_get_sock() instead of adding Socket accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_cached_sock() was a new public method that only OTA's fast-select wake filter would ever call. Drop it. The existing public C API already covers this: esphome_lwip_get_sock(fd) looks up a lwip_sock* from a file descriptor (it's exactly what BSDSocketImpl's own constructor calls to populate cached_sock_). OTA uses the public get_fd() + esphome_lwip_get_sock() chain instead — no new Socket accessor, no friend declarations, no layering concerns. The one-time lookup at setup is negligible. --- esphome/components/esphome/ota/ota_esphome.cpp | 4 +++- esphome/components/socket/bsd_sockets_impl.h | 9 --------- esphome/components/socket/lwip_sockets_impl.h | 7 ------- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index af2a609642..1999b88ef4 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -78,7 +78,9 @@ void ESPHomeOTAComponent::setup() { // every RCVPLUS across all monitored sockets (API client data, mDNS, etc.) would // pay the inline hook's two volatile stores + memw barriers to mark OTA // pending-enable, even though OTA would just re-disable itself on the next tick. - esphome_fast_select_set_ota_listener_sock(this->server_->get_cached_sock()); + // Uses the existing public esphome_lwip_get_sock() lookup instead of adding a + // dedicated accessor on Socket — the lookup happens once at setup, not per event. + esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); #endif } diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index ceb7556d9f..e520784702 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -118,15 +118,6 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } -#ifdef USE_LWIP_FAST_SELECT - // Cached lwip_sock pointer captured at construction. Used by OTA to register its - // listener netconn with the fast-select wake hook filter so the hook only fires on - // OTA-relevant events. Returns nullptr for non-monitored sockets. - struct lwip_sock *get_cached_sock() const { - return this->cached_sock_; - } -#endif - protected: int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index cda37b5dc6..942d0ccf85 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -84,13 +84,6 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } -#ifdef USE_LWIP_FAST_SELECT - // See BSDSocketImpl::get_cached_sock() — same purpose, same semantics. - struct lwip_sock *get_cached_sock() const { - return this->cached_sock_; - } -#endif - protected: int fd_{-1}; #ifdef USE_LWIP_FAST_SELECT From eb5da72dabcc3cf985b6832684fb87c5094fe4bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 17:22:49 -1000 Subject: [PATCH 28/28] [esphome.ota] Revert inline wake hook to a single Component * + extern C call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the listener filter from the previous commit, the wake hook only fires on actual OTA connection attempts — no more spurious wakes from API client data packets or other monitored-socket traffic. That removes the motivation for inlining the hook at three call sites. Collapse back to the simpler shape: - Application gains Component *ota_wake_component_ (one pointer, 4 bytes, gated on USE_OTA) and a wake_ota_component_any_context() inline method. - lwip_fast_select.c reaches the method via an extern-C trampoline (esphome_wake_ota_component_any_context) defined in application.cpp. One call_n instruction per actual wake, which now happens at most once per real OTA upload attempt. - Raw-TCP (LWIPRawListenImpl::accept_fn_) and host select paths call App.wake_ota_component_any_context() directly — both are .cpp files. - wake.h reverts to its pre-PR state (no C-compatible section, no extern pointer globals, no inline OTA hook). All wake-related state still lives in Application. Net effect versus the inline approach: - RAM: -4 bytes (one pointer vs two) - Flash: ~-60 bytes (no 3x duplication of the inlined hook body) - CPU: function-call overhead (~10 cycles) paid only on actual OTA wakes, which happen ~0 times/sec in steady state. Inline was premature once filtering reduced the fire rate to "rare intentional events." --- .../components/socket/lwip_raw_tcp_impl.cpp | 10 ++-- esphome/core/application.cpp | 36 ++++------- esphome/core/application.h | 26 +++++--- esphome/core/lwip_fast_select.c | 21 ++++--- esphome/core/wake.h | 59 ------------------- 5 files changed, 46 insertions(+), 106 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index b6e5730b66..08de3f73a8 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -11,7 +11,7 @@ #include "esphome/core/wake.h" #include "esphome/core/log.h" #ifdef USE_OTA -#include "esphome/core/wake.h" // inline esphome_wake_ota_component_any_context() lives here +#include "esphome/core/application.h" // for App.wake_ota_component_any_context() #endif #ifdef USE_ESP8266 @@ -861,9 +861,11 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Mark the OTA component loop to be re-enabled if it disabled itself while idle. // This MUST happen before wake_loop_any_context() below — otherwise the main loop // could wake, run a full iteration, and finish before we set the pending-enable - // flags, losing the wake event. Inline hook (wake.h) — two volatile stores, no - // function call. Safe from RP2040's low-priority user IRQ context. - esphome_wake_ota_component_any_context(); + // flags, losing the wake event. accept_fn_ only fires for the specific listener + // pcb it was registered on, so there's implicit filtering here (no "false wakes" + // from unrelated sockets, unlike the fast-select path). Safe from RP2040's + // low-priority user IRQ context — only writes a volatile bool, no locks. + esphome::App.wake_ota_component_any_context(); #endif // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 21d3ef4494..ffb374b321 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -2,7 +2,6 @@ #include "esphome/core/build_info_data.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" -#include "esphome/core/wake.h" #include #ifdef USE_ESP8266 @@ -450,27 +449,12 @@ void Application::enable_pending_loops_() { } } -// Storage for the inline OTA wake hook (see esphome/core/wake.h). Set in -// Application::set_ota_wake_component() and read from the lwip fast-select callback -// on every NETCONN_EVT_RCVPLUS. Kept as raw C globals so the .c file can inline the -// wake body (two volatile stores) without a function-call round trip. Defined -// unconditionally so wake.h's inline compiles the same whether or not OTA is in the -// build — when OTA is absent nothing ever calls set_ota_wake_component() and the -// pointers stay nullptr, collapsing the inline to a single null check. -extern "C" { -volatile bool *esphome_ota_pending_enable_loop_ptr = nullptr; -volatile bool *esphome_ota_has_pending_requests_ptr = nullptr; -} - -#ifdef USE_OTA -void Application::set_ota_wake_component(Component *component) { - // Application is a friend of Component — can take the address of its protected - // pending_enable_loop_ field. The C-side inline hook writes through that pointer when - // any monitored socket fires RCVPLUS, making the flags visible to enable_pending_loops_() - // on the next main loop iteration. - esphome_ota_pending_enable_loop_ptr = &component->pending_enable_loop_; - esphome_ota_has_pending_requests_ptr = &this->has_pending_enable_loop_requests_; -} +#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) +// Extern-C trampoline for the lwip fast-select callback to reach the C++ wake method. +// Invoked only when the listener filter in lwip_fast_select.c matches — i.e. only on +// actual incoming connections to the OTA listen socket — so the function-call overhead +// here is paid at most once per real OTA attempt, not on every monitored-socket event. +extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } #endif #ifdef USE_LWIP_FAST_SELECT @@ -580,10 +564,12 @@ void Application::yield_with_select_(uint32_t delay_ms) { if (ret >= 0) [[likely]] { #ifdef USE_OTA // Dead code today — host does not currently support the esphome OTA platform, - // so the inline hook's pointers are always NULL and this is a no-op. Kept so the - // wake path works out of the box if host ever gains OTA support. + // so ota_wake_component_ is always null and this is a no-op. Kept so the wake + // path works out of the box if host ever gains OTA support. Host has no listener + // filter (that lives in lwip_fast_select.c, ESP32/LibreTiny only), so any + // ret > 0 fires this — harmless when the pointer is null. if (ret > 0) { - esphome_wake_ota_component_any_context(); + this->wake_ota_component_any_context(); } #endif // Yield if zero timeout since select(0) only polls without yielding diff --git a/esphome/core/application.h b/esphome/core/application.h index baf8e21a99..7a3595ac49 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -561,13 +561,22 @@ class Application { #ifdef USE_OTA /// Register the OTA component so socket-wake paths can enable its loop when a new - /// connection arrives on the listening socket. Captures the addresses of the component's - /// pending_enable_loop_ flag and Application's has_pending_enable_loop_requests_ flag - /// into extern-C globals consumed by the inline wake hook in wake.h. Defined out-of-line - /// in application.cpp so application.h doesn't need to pull in wake.h. OTA calls this - /// once from setup(); the component itself then self-disables its loop on its first - /// idle tick. - void set_ota_wake_component(Component *component); + /// connection arrives on the listening socket. OTA calls this once from setup(); + /// the component self-disables its loop on its first idle tick and is re-enabled + /// via wake_ota_component_any_context() when the fast-select filter in + /// lwip_fast_select.c matches an incoming connection on the OTA listener. + void set_ota_wake_component(Component *component) { this->ota_wake_component_ = component; } + /// Mark the registered OTA component pending loop-enable. Called from the LwIP + /// TCP/IP task (fast-select callback), raw-TCP accept callback, and host select + /// return path — all task / user-IRQ context, not a real ISR. Does NOT wake the + /// main task itself: every caller already does so separately. Application is a + /// friend of Component, so we set pending_enable_loop_ directly. + void wake_ota_component_any_context() { + if (this->ota_wake_component_ != nullptr) { + this->ota_wake_component_->pending_enable_loop_ = true; + this->has_pending_enable_loop_requests_ = true; + } + } #endif protected: @@ -645,6 +654,9 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; +#ifdef USE_OTA + Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications +#endif // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 60a1cf60f1..9f23401aba 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -124,9 +124,6 @@ #include "esphome/core/lwip_fast_select.h" #include "esphome/core/main_task.h" -#ifdef ESPHOME_USE_OTA -#include "esphome/core/wake.h" // inline esphome_wake_ota_component_any_context() lives here -#endif #include @@ -164,15 +161,17 @@ static netconn_callback s_original_callback = NULL; // OTA listener netconn, captured via esphome_fast_select_set_ota_listener_sock() at OTA // setup(). The wake hook only fires when the callback's `conn` argument matches this // pointer — i.e. only for RCVPLUS events on the OTA listen socket (new accepts). Avoids -// paying the inline wake hook's cost (two volatile stores + memw barriers) on every API -// client data packet, mDNS query, etc. +// paying the wake-hook function call on every API client data packet, mDNS query, etc. static struct netconn *s_ota_listener_conn = NULL; -// Inline wake hook defined in esphome/core/wake.h. ESPHOME_USE_OTA (not USE_OTA) because -// USE_OTA only lives in defines.h, and this .c file cannot include defines.h — macros.h → -// Arduino.h would break the C compile under Arduino builds. ota/__init__.py emits -// -DESPHOME_USE_OTA as a build flag so this file can see it without a name collision with -// the defines.h USE_OTA entry. -#include "esphome/core/wake.h" + +// Extern-C trampoline defined in application.cpp — calls App.wake_ota_component_any_context() +// to mark the OTA component pending loop-enable. Out-of-line (not inlined) because with the +// listener filter above, this only fires on actual OTA connection attempts — the function-call +// cost is paid at most once per real wake, not on every monitored-socket RCVPLUS. +// ESPHOME_USE_OTA (not USE_OTA) because USE_OTA only lives in defines.h, and this .c file +// cannot include defines.h — macros.h → Arduino.h would break the C compile under Arduino +// builds. ota/__init__.py emits -DESPHOME_USE_OTA as a build flag so this file can see it. +extern void esphome_wake_ota_component_any_context(void); void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL; diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 9ad761e8aa..a8c9b7ad08 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -3,63 +3,6 @@ /// @file wake.h /// Platform-specific main loop wake primitives. /// Always available on all platforms — no opt-in needed. -/// -/// This file has two sections: -/// 1. A C-compatible section at the top (the inline OTA wake hook) that .c files -/// like lwip_fast_select.c can include. Uses only /. -/// 2. A C++ section (everything after #ifdef __cplusplus below) with the existing -/// platform wake primitives and wakeable_delay() implementations. - -// ============================================================================ -// C-compatible section: inline OTA wake hook -// ============================================================================ -// -// Called from lwip_fast_select.c (and lwip_raw_tcp_impl.cpp / host select) on every -// NETCONN_EVT_RCVPLUS so a disabled OTA loop can be re-enabled when a monitored -// socket signals activity. Static inline (not an extern-C trampoline) so the .c -// callback pays zero function-call overhead — just a pointer load, null check, and -// two volatile bool stores inlined into the callback body. -// -// The two pointers are captured once in Application::set_ota_wake_component(): they -// point at Component::pending_enable_loop_ and Application::has_pending_enable_loop_requests_. -// When OTA is not compiled into the build, nothing calls set_ota_wake_component(), -// the pointers stay NULL, and the inline collapses to a single null check. - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -// Address of the registered OTA component's pending_enable_loop_ flag, captured at -// registration time. NULL when no OTA component has registered (including when OTA -// is not compiled in at all). -extern volatile bool *esphome_ota_pending_enable_loop_ptr; -// Address of Application::has_pending_enable_loop_requests_. Set in tandem with the -// pending_enable pointer above — single null check covers both. -extern volatile bool *esphome_ota_has_pending_requests_ptr; - -// Mark the registered OTA component pending loop-enable. Safe from the LwIP TCP/IP -// task and raw-TCP IRQ context — only volatile stores, no locks. Callers MUST invoke -// this BEFORE waking the main task, so the flags are visible on the next iteration. -static inline void esphome_wake_ota_component_any_context(void) { - volatile bool *pending_enable = esphome_ota_pending_enable_loop_ptr; - if (pending_enable != NULL) { - *pending_enable = true; - *esphome_ota_has_pending_requests_ptr = true; - } -} - -#ifdef __cplusplus -} // extern "C" -#endif - -// ============================================================================ -// C++ section: platform wake primitives -// ============================================================================ - -#ifdef __cplusplus #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -181,5 +124,3 @@ inline void wakeable_delay(uint32_t ms) { #endif } // namespace esphome - -#endif // __cplusplus