diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 5c7eab517bd..ab526134f80 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -f31f13994768b5b07e29624406c9b053bf4bb26e1623ac2bc1e9d4a9477502d6 +dc8ad5472d9fb44ce1ca29a0601afd65705642799a2819704dfc8459fbaf9815 diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index a8952260303..52d72544d3e 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 diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index af54175c01e..21393f2aba3 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv # yamllint disable-line rule:line-length diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index 1c33772c4c3..e02b450bf0e 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -4,6 +4,7 @@ module.exports = { CODEOWNERS_MARKER: '', TOO_BIG_MARKER: '', DEPRECATED_COMPONENT_MARKER: '', + ORG_FORK_MARKER: '', MANAGED_LABELS: [ 'new-component', diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index fb9dadc6a09..25c0ba49afc 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -281,6 +281,24 @@ async function detectDeprecatedComponents(github, context, changedFiles) { return { labels, deprecatedInfo }; } +// Strategy: Detect when maintainers cannot modify the PR branch +function detectMaintainerAccess(context) { + const pr = context.payload.pull_request; + + // Only relevant for cross-repo PRs (forks) + if (!pr.head.repo || pr.head.repo.full_name === pr.base.repo.full_name) { + return null; + } + + if (pr.maintainer_can_modify) { + return null; + } + + const isOrgFork = pr.head.repo.owner.type === 'Organization'; + console.log(`Maintainer cannot modify PR branch (${isOrgFork ? 'org fork: ' + pr.head.repo.owner.login : 'user disabled'})`); + return { isOrgFork, orgName: pr.head.repo.owner.login }; +} + // Strategy: Requirements detection async function detectRequirements(allLabels, prFiles, context) { const labels = new Set(); @@ -329,5 +347,6 @@ module.exports = { detectTests, detectPRTemplateCheckboxes, detectDeprecatedComponents, + detectMaintainerAccess, detectRequirements }; diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 42588c0bc87..021e91a9ee1 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -12,9 +12,10 @@ const { detectTests, detectPRTemplateCheckboxes, detectDeprecatedComponents, + detectMaintainerAccess, detectRequirements } = require('./detectors'); -const { handleReviews } = require('./reviews'); +const { handleReviews, handleMaintainerAccessComment } = require('./reviews'); const { applyLabels, removeOldLabels } = require('./labels'); // Fetch API data @@ -114,7 +115,8 @@ module.exports = async ({ github, context }) => { codeOwnerLabels, testLabels, checkboxLabels, - deprecatedResult + deprecatedResult, + maintainerAccess ] = await Promise.all([ detectMergeBranch(context), detectComponentPlatforms(changedFiles, apiData), @@ -127,7 +129,8 @@ module.exports = async ({ github, context }) => { detectCodeOwner(github, context, changedFiles), detectTests(changedFiles), detectPRTemplateCheckboxes(context), - detectDeprecatedComponents(github, context, changedFiles) + detectDeprecatedComponents(github, context, changedFiles), + detectMaintainerAccess(context) ]); // Extract deprecated component info @@ -177,8 +180,11 @@ module.exports = async ({ github, context }) => { console.log('Computed labels:', finalLabels.join(', ')); - // Handle reviews - await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD); + // Handle reviews and org fork comment + await Promise.all([ + handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD), + handleMaintainerAccessComment(github, context, maintainerAccess) + ]); // Apply labels await applyLabels(github, context, finalLabels); diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index 906e2c456ab..7ac136515de 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -2,7 +2,8 @@ const { BOT_COMMENT_MARKER, CODEOWNERS_MARKER, TOO_BIG_MARKER, - DEPRECATED_COMPONENT_MARKER + DEPRECATED_COMPONENT_MARKER, + ORG_FORK_MARKER } = require('./constants'); // Generate review messages @@ -136,6 +137,63 @@ async function handleReviews(github, context, finalLabels, originalLabelCount, d } } +// Handle maintainer access warning comment +async function handleMaintainerAccessComment(github, context, maintainerAccess) { + if (!maintainerAccess) { + return; + } + + const { owner, repo } = context.repo; + const pr_number = context.issue.number; + const prAuthor = context.payload.pull_request.user.login; + + // Check if we already posted the warning (iterate pages to exit early) + let existingComment; + for await (const { data: comments } of github.paginate.iterator( + github.rest.issues.listComments, + { owner, repo, issue_number: pr_number } + )) { + existingComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body && comment.body.includes(ORG_FORK_MARKER) + ); + if (existingComment) { + break; + } + } + + if (existingComment) { + console.log('Maintainer access warning comment already exists, skipping'); + return; + } + + let body; + if (maintainerAccess.isOrgFork) { + body = `${ORG_FORK_MARKER}\n### ⚠️ Organization Fork Detected\n\n` + + `Hey there @${prAuthor},\n` + + `It looks like this PR was submitted from a fork owned by the **${maintainerAccess.orgName}** organization. ` + + `GitHub does not allow maintainers to push changes to pull request branches when the fork is owned by an organization. ` + + `This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` + + `To allow maintainer collaboration, please re-submit this PR from a personal fork instead.\n\n` + + `See: [Setting up the local repository](https://developers.esphome.io/contributing/development-environment/?h=org#set-up-the-local-repository) for more details.`; + } else { + body = `${ORG_FORK_MARKER}\n### ⚠️ Maintainer Access Disabled\n\n` + + `Hey there @${prAuthor},\n` + + `It looks like this PR does not have the "Allow edits from maintainers" option enabled. ` + + `This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` + + `Please enable this option in the PR sidebar to allow maintainer collaboration.`; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body + }); + console.log('Created maintainer access warning comment'); +} + module.exports = { - handleReviews + handleReviews, + handleMaintainerAccessComment }; diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 3b5e9f0d158..0e5ceb9346c 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -20,20 +20,20 @@ env: jobs: label: runs-on: ubuntu-latest - if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' + if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate a token id: generate-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} 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 6d200956e9d..e5143911d94 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({ @@ -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: | @@ -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 7905739b15e..40cdff0cba7 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/ci.yml b/.github/workflows/ci.yml index cf9fa8e7c05..6aa5b2a5472 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv # yamllint disable-line rule:line-length @@ -159,7 +159,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -198,7 +198,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -231,7 +231,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -253,7 +253,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -387,14 +387,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -466,14 +466,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -555,14 +555,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -817,7 +817,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -841,7 +841,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -883,7 +883,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -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 @@ -930,7 +930,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -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/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 34ff934b773..49653b6fb3b 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 a89c03ba042..76be6ecd7bf 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 4fa020f63d5..3165b170780 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 6faf956c876..b211c13985d 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 0021654def6..87009962719 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 9e8a040888d..35b9e065e1e 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 @@ -221,7 +221,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -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: | @@ -256,7 +256,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -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: | @@ -287,7 +287,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -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 6483bbe789d..709342e5ae6 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']; diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a71e5ef4cad..be1457387d9 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f8c21aad367..ac4f0049f83 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.9 + rev: v0.15.10 hooks: # Run the linter. - id: ruff diff --git a/esphome/__main__.py b/esphome/__main__.py index 25b404ae45c..7879cdad0ca 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -750,8 +750,15 @@ def upload_using_esptool( platformio_api.FlashImage( path=idedata.firmware_bin_path, offset=firmware_offset ), - *idedata.extra_flash_images, ] + for image in idedata.extra_flash_images: + if not image.path.is_file(): + _LOGGER.warning( + "Skipping missing flash image declared by platform: %s", + image.path, + ) + continue + flash_images.append(image) mcu = "esp8266" if CORE.is_esp32: diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index bab2762f00e..09e09f0dc1b 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -2,7 +2,11 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler -from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component +from esphome.components.esp32 import ( + get_esp32_variant, + include_builtin_idf_component, + require_adc_oneshot_iram, +) from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, @@ -24,6 +28,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE +from esphome.types import ConfigType from . import ( ATTENUATION_MODES, @@ -65,6 +70,13 @@ def validate_config(config): return config +def _require_adc_iram(config: ConfigType) -> ConfigType: + """Register ADC oneshot IRAM requirement during config validation.""" + if CORE.is_esp32: + require_adc_oneshot_iram() + return config + + ADCSensor = adc_ns.class_( "ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler ) @@ -95,6 +107,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(cv.polling_component_schema("60s")), validate_config, + _require_adc_iram, ) CONF_ADC_CHANNEL_ID = "adc_channel_id" diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 33d16f0339b..f906cfb8d78 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -671,6 +671,7 @@ message SensorStateResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_SENSOR"; option (no_delay) = true; + option (speed_optimized) = true; fixed32 key = 1 [(force) = true]; float state = 2; @@ -777,9 +778,10 @@ message SubscribeLogsResponse { option (source) = SOURCE_SERVER; option (log) = false; option (no_delay) = false; + option (speed_optimized) = true; - LogLevel level = 1; - bytes message = 3; + LogLevel level = 1 [(force) = true]; + bytes message = 3 [(force) = true]; } // ==================== NOISE ENCRYPTION ==================== @@ -1625,6 +1627,7 @@ message BluetoothLEAdvertisementResponse { } message BluetoothLERawAdvertisement { + option (inline_encode) = true; uint64 address = 1 [(force) = true]; sint32 rssi = 2 [(force) = true]; uint32 address_type = 3 [(max_value) = 4]; @@ -1637,6 +1640,7 @@ message BluetoothLERawAdvertisementsResponse { option (source) = SOURCE_SERVER; option (ifdef) = "USE_BLUETOOTH_PROXY"; option (no_delay) = true; + option (speed_optimized) = true; repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"]; } diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 0f71268d700..d5d0b37e8df 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -22,6 +22,8 @@ extend google.protobuf.MessageOptions { optional bool log = 1039 [default=true]; optional bool no_delay = 1040 [default=false]; optional string base_class = 1041; + optional bool inline_encode = 1042 [default=false]; + optional bool speed_optimized = 1043 [default=false]; } extend google.protobuf.FieldOptions { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index d27cfa57cf9..f304c852822 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -745,7 +745,9 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const { #endif return size; } -uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); @@ -755,7 +757,9 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG #endif return pos; } -uint32_t SensorStateResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +SensorStateResponse::calculate_size() const { uint32_t size = 0; size += 5; size += ProtoSize::calc_float(1, this->state); @@ -912,16 +916,22 @@ bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t } return true; } -uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level)); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->level), true); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_); return pos; } -uint32_t SubscribeLogsResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +SubscribeLogsResponse::calculate_size() const { uint32_t size = 0; - size += this->level ? 2 : 0; - size += ProtoSize::calc_length(1, this->message_len_); + size += 2; + size += ProtoSize::calc_length_force(1, this->message_len_); return size; } #ifdef USE_API_NOISE @@ -2328,40 +2338,41 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, } return true; } -uint8_t *BluetoothLERawAdvertisement::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, this->address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(this->rssi)); - if (this->address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->address_type); - } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(this->data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->data, this->data_len); - return pos; -} -uint32_t BluetoothLERawAdvertisement::calculate_size() const { - uint32_t size = 0; - size += ProtoSize::calc_uint64_force(1, this->address); - size += ProtoSize::calc_sint32_force(1, this->rssi); - size += this->address_type ? 2 : 0; - size += 2 + this->data_len; - return size; -} -uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint8_t * +BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); for (uint16_t i = 0; i < this->advertisements_len; i++) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->advertisements[i]); + auto &sub_msg = this->advertisements[i]; + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + uint8_t *len_pos = pos; + ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + if (sub_msg.address_type) { + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); + } + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + *len_pos = static_cast(pos - len_pos - 1); } return pos; } -uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { +__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) +uint32_t +BluetoothLERawAdvertisementsResponse::calculate_size() const { uint32_t size = 0; for (uint16_t i = 0; i < this->advertisements_len; i++) { - size += ProtoSize::calc_message_force(1, this->advertisements[i].calculate_size()); + auto &sub_msg = this->advertisements[i]; + size += 2; + size += ProtoSize::calc_uint64_force(1, sub_msg.address); + size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); + size += sub_msg.address_type ? 2 : 0; + size += 2 + sub_msg.data_len; } return size; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 3b239db36c5..5827a8728e6 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1888,8 +1888,6 @@ class BluetoothLERawAdvertisement final : public ProtoMessage { uint32_t address_type{0}; uint8_t data[62]{}; uint8_t data_len{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index e0a4e03189e..8cac7fff3bf 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -352,6 +352,12 @@ class ProtoEncode { PROTO_ENCODE_CHECK_BOUNDS(pos, 1); *pos++ = b; } + /// Reserve one byte for later backpatch (e.g., sub-message length). + /// Advances pos past the reserved byte without writing a value. + static inline void ESPHOME_ALWAYS_INLINE reserve_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM) { + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + pos++; + } /// Write raw bytes to the buffer (no tag, no length prefix). static inline void ESPHOME_ALWAYS_INLINE encode_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, const void *data, size_t len) { diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index c44a11e3ed4..95154812cb4 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -111,14 +111,14 @@ class ATM90E32Component : public PollingComponent, #endif float get_reference_voltage(uint8_t phase) { #ifdef USE_NUMBER - return (phase >= 0 && phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage + return (phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage #else return 120.0; // Default voltage #endif } float get_reference_current(uint8_t phase) { #ifdef USE_NUMBER - return (phase >= 0 && phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current + return (phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current #else return 5.0f; // Default current #endif diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 8f2102de6a7..fee582ca25c 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,7 +1,11 @@ from dataclasses import dataclass import esphome.codegen as cg -from esphome.components.esp32 import add_idf_component, include_builtin_idf_component +from esphome.components.esp32 import ( + add_idf_component, + add_idf_sdkconfig_option, + include_builtin_idf_component, +) import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE from esphome.core import CORE @@ -27,6 +31,7 @@ class AudioData: flac_support: bool = False mp3_support: bool = False opus_support: bool = False + micro_decoder_support: bool = False def _get_data() -> AudioData: @@ -50,6 +55,11 @@ def request_opus_support() -> None: _get_data().opus_support = True +def request_micro_decoder_support() -> None: + """Request micro-decoder library support for audio decoding.""" + _get_data().micro_decoder_support = True + + CONF_MIN_BITS_PER_SAMPLE = "min_bits_per_sample" CONF_MAX_BITS_PER_SAMPLE = "max_bits_per_sample" CONF_MIN_CHANNELS = "min_channels" @@ -208,6 +218,19 @@ async def to_code(config): ) data = _get_data() + + if data.micro_decoder_support: + add_idf_component(name="esphome/micro-decoder", ref="0.1.1") + + # All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash + if not data.flac_support: + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_FLAC", False) + if not data.mp3_support: + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False) + if not data.opus_support: + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False) + + # Legacy audio_decoder.cpp support defines and components if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") add_idf_component(name="esphome/micro-flac", ref="0.1.1") diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 5f0afa9c9f2..b63443c5f30 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -17,6 +17,7 @@ CODEOWNERS = ["@neffs", "@kbx81"] DOMAIN = "bme68x_bsec2" BSEC2_LIBRARY_VERSION = "1.10.2610" +BME68x_LIBRARY_VERSION = "v1.3.40408" CONF_ALGORITHM_OUTPUT = "algorithm_output" CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id" @@ -184,16 +185,31 @@ async def to_code_base(config): if core.CORE.using_arduino: cg.add_library("Wire", None) cg.add_library("SPI", None) - cg.add_library( - "BME68x Sensor library", - None, - "https://github.com/boschsensortec/Bosch-BME68x-Library#v1.3.40408", - ) - cg.add_library( - "BSEC2 Software Library", - None, - f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}", - ) + + if core.CORE.is_esp32: + from esphome.components.esp32 import add_idf_component + + add_idf_component( + name="boschsensortec/Bosch-BME68x-Library", + repo="https://github.com/esphome-libs/Bosch-BME68x-Library", + ref=BME68x_LIBRARY_VERSION, + ) + add_idf_component( + name="boschsensortec/Bosch-BSEC2-Library", + repo="https://github.com/esphome-libs/Bosch-BSEC2-Library", + ref=BSEC2_LIBRARY_VERSION, + ) + else: + cg.add_library( + "BME68x Sensor library", + None, + f"https://github.com/boschsensortec/Bosch-BME68x-Library#{BME68x_LIBRARY_VERSION}", + ) + cg.add_library( + "BSEC2 Software Library", + None, + f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}", + ) cg.add_define("USE_BSEC2") diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 7d3bf78f492..fcd342ad389 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -162,7 +162,6 @@ async def canbus_action_to_code(config, action_id, template_arg, args): await cg.register_parented(var, config[CONF_CANBUS_ID]) if (can_id := config.get(CONF_CAN_ID)) is not None: - can_id = await cg.templatable(can_id, args, cg.uint32) cg.add(var.set_can_id(can_id)) cg.add(var.set_use_extended_id(config[CONF_USE_EXTENDED_ID])) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 51aa88b8f72..ea0138e1dd8 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -102,8 +102,34 @@ 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(); + + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->setup(); + } + + this->configure(); + if (this->is_failed()) { + return; + } + + // 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->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + } + }); + } +} + +void CC1101Component::configure() { + // Manual reset sequence per CC1101 datasheet section 19.1.2 this->cs_->digital_write(true); delayMicroseconds(1); this->cs_->digital_write(false); @@ -126,11 +152,6 @@ void CC1101Component::setup() { return; } - // Setup GDO0 pin if configured - if (this->gdo0_pin_ != nullptr) { - this->gdo0_pin_->setup(); - } - this->initialized_ = true; for (uint8_t i = 0; i <= static_cast(Register::TEST0); i++) { @@ -140,16 +161,11 @@ void CC1101Component::setup() { this->write_(static_cast(i)); } this->set_output_power(this->output_power_requested_); + if (!this->enter_rx_()) { this->mark_failed(); return; } - - // 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); }); - } } void CC1101Component::call_listeners_(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi) { @@ -160,6 +176,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; @@ -240,6 +257,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 @@ -264,7 +282,7 @@ void CC1101Component::begin_rx() { void CC1101Component::reset() { this->strobe_(Command::RES); - this->setup(); + this->configure(); } void CC1101Component::set_idle() { @@ -669,6 +687,13 @@ void CC1101Component::set_packet_mode(bool value) { this->state_.GDO0_CFG = 0x0D; } if (this->initialized_) { + 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); this->write_(Register::IOCFG0); diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 2efd9e082d5..000a13d586c 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -25,6 +25,7 @@ class CC1101Component : public Component, void setup() override; void loop() override; void dump_config() override; + void configure(); // Actions void begin_tx(); @@ -93,6 +94,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/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index f7e012f1620..bad33a6a023 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -43,3 +43,11 @@ wave_4_26.extend( }, }, ) + + +ssd1677.extend( + "waveshare-3.97in", + width=800, + height=480, + mirror_x=True, +) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f27690c97b4..7b3f9da3da6 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -671,11 +671,12 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 7), - "latest": cv.Version(3, 3, 7), - "dev": cv.Version(3, 3, 7), + "recommended": cv.Version(3, 3, 8), + "latest": cv.Version(3, 3, 8), + "dev": cv.Version(3, 3, 8), } ARDUINO_PLATFORM_VERSION_LOOKUP = { + cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), cv.Version(3, 3, 5): cv.Version(55, 3, 35), @@ -695,6 +696,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # These versions correspond to pioarduino/esp-idf releases # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { + cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), cv.Version(3, 3, 6): cv.Version(5, 5, 2), cv.Version(3, 3, 5): cv.Version(5, 5, 2), @@ -714,17 +716,15 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 3, "1"), - "latest": cv.Version(5, 5, 3, "1"), + "recommended": cv.Version(5, 5, 4), + "latest": cv.Version(5, 5, 4), "dev": cv.Version(5, 5, 4), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version( - 5, 5, 4 - ): "https://github.com/pioarduino/platform-espressif32.git#develop", + cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), cv.Version(5, 5, 2): cv.Version(55, 3, 37), @@ -744,8 +744,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 37), - "latest": cv.Version(55, 3, 37), + "recommended": cv.Version(55, 3, 38, "1"), + "latest": cv.Version(55, 3, 38, "1"), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } @@ -1058,6 +1058,7 @@ CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" +CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" # VFS requirement tracking # Components that need VFS features can call require_vfs_*() functions @@ -1071,6 +1072,7 @@ KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" +KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" def require_vfs_select() -> None: @@ -1168,6 +1170,17 @@ def require_fatfs() -> None: CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True +def require_adc_oneshot_iram() -> None: + """Mark that ADC oneshot IRAM safety is required by a component. + + Call this from components that use the ADC oneshot driver. When flash cache is + disabled (e.g., during NVS writes by WiFi, BLE, Zigbee, or power management), + the ADC oneshot read function must be in IRAM to avoid crashes. + This sets CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM. + """ + CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -1268,6 +1281,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, + cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), @@ -2068,6 +2082,16 @@ async def to_code(config): if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False) + # Place ADC oneshot control functions in IRAM for cache safety + # When flash cache is disabled (during NVS writes by WiFi, BLE, Zigbee, Thread, + # power management, etc.), ADC reads will crash if these functions are in flash. + # Components using ADC call require_adc_oneshot_iram() to force this. + if ( + CORE.data[KEY_ESP32].get(KEY_ADC_ONESHOT_IRAM_REQUIRED, False) + or advanced[CONF_ADC_ONESHOT_IN_IRAM] + ): + add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True) + # Disable FATFS support # Components that need FATFS (SD card, etc.) can call require_fatfs() if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 2bd08e7c392..2c73fe7d08d 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1960,6 +1960,10 @@ BOARDS = { "name": "Hornbill ESP32 Minima", "variant": VARIANT_ESP32, }, + "huidu_hd_wf1": { + "name": "Huidu HD-WF1", + "variant": VARIANT_ESP32S2, + }, "huidu_hd_wf2": { "name": "Huidu HD-WF2", "variant": VARIANT_ESP32S3, @@ -2028,6 +2032,10 @@ BOARDS = { "name": "LilyGo T-Display-S3", "variant": VARIANT_ESP32S3, }, + "lilygo-t-energy-s3": { + "name": "LilyGo T-Energy-S3", + "variant": VARIANT_ESP32S3, + }, "lilygo-t3-s3": { "name": "LilyGo T3-S3", "variant": VARIANT_ESP32S3, @@ -2289,10 +2297,18 @@ BOARDS = { "name": "S.ODI Ultra v1", "variant": VARIANT_ESP32, }, + "seeed_xiao_esp32_s3_plus": { + "name": "Seeed Studio XIAO ESP32S3 Plus", + "variant": VARIANT_ESP32S3, + }, "seeed_xiao_esp32c3": { "name": "Seeed Studio XIAO ESP32C3", "variant": VARIANT_ESP32C3, }, + "seeed_xiao_esp32c5": { + "name": "Seeed Studio XIAO ESP32C5", + "variant": VARIANT_ESP32C5, + }, "seeed_xiao_esp32c6": { "name": "Seeed Studio XIAO ESP32C6", "variant": VARIANT_ESP32C6, diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 313818e6019..add50dcf4da 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -61,6 +61,9 @@ uint32_t arch_get_cpu_freq_hz() { } TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static StackType_t + loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void loop_task(void *pv_params) { setup(); @@ -73,9 +76,11 @@ extern "C" void app_main() { initArduino(); esp32::setup_preferences(); #if CONFIG_FREERTOS_UNICORE - xTaskCreate(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle); + loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, + &loop_task_tcb); #else - xTaskCreatePinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle, 1); + loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, + loop_task_stack, &loop_task_tcb, 1); #endif } diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index bc0a34ebe86..925c4e76624 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -4,7 +4,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include -#include #include #include @@ -12,9 +11,6 @@ namespace esphome::esp32 { static const char *const TAG = "preferences"; -// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding -static constexpr size_t KEY_BUFFER_SIZE = 12; - struct NVSData { uint32_t key; SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.) @@ -51,8 +47,8 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } } - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, this->key); size_t actual_len; esp_err_t err = nvs_get_blob(this->nvs_handle, key_str, nullptr, &actual_len); if (err != 0) { @@ -108,8 +104,8 @@ bool ESP32Preferences::sync() { uint32_t last_key = 0; for (const auto &save : s_pending_save) { - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, save.key); ESP_LOGVV(TAG, "Checking if NVS data %s has changed", key_str); if (this->is_changed_(this->nvs_handle, save, key_str)) { esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.data(), save.data.size()); diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index ec6730a41c5..46725fe6ddd 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -108,8 +108,13 @@ async def globals_set_to_code(config, action_id, template_arg, args): full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) template_arg = cg.TemplateArguments(full_id.type, *template_arg) var = cg.new_Pvariable(action_id, template_arg, paren) + # Use the global's value_type alias as the lambda return type so + # TemplatableFn stores a direct function pointer instead of going through + # the deprecated converting trampoline when the value expression deduces + # to a different type (e.g. int literal assigned to a float global). + value_type = cg.RawExpression(f"{full_id.type}::value_type") templ = await cg.templatable( - config[CONF_VALUE], args, None, to_exp=cg.RawExpression, wrap_constant=True + config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression ) cg.add(var.set_value(templ)) return var diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index 65dd3196dff..ccb47237b64 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -8,7 +8,7 @@ from .. import hbridge_ns CODEOWNERS = ["@DotNetDann"] HBridgeLightOutput = hbridge_ns.class_( - "HBridgeLightOutput", cg.PollingComponent, light.LightOutput + "HBridgeLightOutput", cg.Component, light.LightOutput ) CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index c3091548523..4e064d53522 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -1,20 +1,17 @@ #pragma once -#include "esphome/core/component.h" -#include "esphome/components/output/float_output.h" #include "esphome/components/light/light_output.h" -#include "esphome/core/log.h" +#include "esphome/components/output/float_output.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome { namespace hbridge { -// Using PollingComponent as the updates are more consistent and reduces flickering -class HBridgeLightOutput : public PollingComponent, public light::LightOutput { +class HBridgeLightOutput : public Component, public light::LightOutput { public: - HBridgeLightOutput() : PollingComponent(1) {} - - void set_pina_pin(output::FloatOutput *pina_pin) { pina_pin_ = pina_pin; } - void set_pinb_pin(output::FloatOutput *pinb_pin) { pinb_pin_ = pinb_pin; } + void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } + void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); @@ -24,16 +21,16 @@ class HBridgeLightOutput : public PollingComponent, public light::LightOutput { return traits; } - void setup() override { this->forward_direction_ = false; } + void setup() override { this->disable_loop(); } - void update() override { - // This method runs around 60 times per second - // We cannot do the PWM ourselves so we are reliant on the hardware PWM - if (!this->forward_direction_) { // First LED Direction + void loop() override { + // Only called when both channels are active — alternate H-bridge direction + // each iteration to multiplex cold and warm white. + if (!this->forward_direction_) { this->pina_pin_->set_level(this->pina_duty_); this->pinb_pin_->set_level(0); this->forward_direction_ = true; - } else { // Second LED Direction + } else { this->pina_pin_->set_level(0); this->pinb_pin_->set_level(this->pinb_duty_); this->forward_direction_ = false; @@ -43,15 +40,32 @@ class HBridgeLightOutput : public PollingComponent, public light::LightOutput { float get_setup_priority() const override { return setup_priority::HARDWARE; } void write_state(light::LightState *state) override { - state->current_values_as_cwww(&this->pina_duty_, &this->pinb_duty_, false); + float new_pina, new_pinb; + state->current_values_as_cwww(&new_pina, &new_pinb, false); + + this->pina_duty_ = new_pina; + this->pinb_duty_ = new_pinb; + + if (new_pina != 0.0f && new_pinb != 0.0f) { + // Both channels active — need loop to alternate H-bridge direction + this->high_freq_.start(); + this->enable_loop(); + } else { + // Zero or one channel active — drive pins directly, no multiplexing needed + this->high_freq_.stop(); + this->disable_loop(); + this->pina_pin_->set_level(new_pina); + this->pinb_pin_->set_level(new_pinb); + } } protected: output::FloatOutput *pina_pin_; output::FloatOutput *pinb_pin_; - float pina_duty_ = 0; - float pinb_duty_ = 0; - bool forward_direction_ = false; + float pina_duty_{0}; + float pinb_duty_{0}; + bool forward_direction_{false}; + HighFrequencyLoopRequester high_freq_; }; } // namespace hbridge diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 7743da77abd..b7e04374807 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -127,6 +127,6 @@ async def to_code(config): cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) cg.add_build_flag("-Wno-error=overloaded-virtual") - cg.add_library("tonia/HeatpumpIR", "1.0.40") + cg.add_library("tonia/HeatpumpIR", "1.0.41") if CORE.is_libretiny or CORE.is_esp32: CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index f10e7ec0aae..32e49c643f5 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -360,8 +360,8 @@ void LD2410Component::handle_periodic_data_() { */ #ifdef USE_SENSOR SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_, - encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])) - SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]) + encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])); + SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]); SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_, encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW])); SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]); @@ -375,26 +375,26 @@ void LD2410Component::handle_periodic_data_() { Moving energy: 20~28th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]); } /* Still energy: 29~37th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]); } /* Light sensor: 38th bytes */ - SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]) + SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]); } else { for (auto &gate_move_sensor : this->gate_move_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor); } for (auto &gate_still_sensor : this->gate_still_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor); } - SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_) + SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_); } #endif #ifdef USE_BINARY_SENSOR @@ -786,13 +786,12 @@ void LD2410Component::set_light_out_control() { } #ifdef USE_SENSOR -// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. void LD2410Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_move_sensors_[gate] = new SensorWithDedup(s); + this->gate_move_sensors_[gate].set_sensor(s); } void LD2410Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_still_sensors_[gate] = new SensorWithDedup(s); + this->gate_still_sensors_[gate].set_sensor(s); } #endif diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 687ed21d1d2..31186b135f2 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -129,8 +129,8 @@ class LD2410Component : public Component, public uart::UARTDevice { std::array gate_still_threshold_numbers_{}; #endif #ifdef USE_SENSOR - std::array *, TOTAL_GATES> gate_move_sensors_{}; - std::array *, TOTAL_GATES> gate_still_sensors_{}; + std::array, TOTAL_GATES> gate_move_sensors_{}; + std::array, TOTAL_GATES> gate_still_sensors_{}; #endif }; diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 38e1a59abab..a502ae3c109 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -397,12 +397,12 @@ void LD2412Component::handle_periodic_data_() { */ #ifdef USE_SENSOR SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_, - encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])) - SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]) + encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW])); + SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]); SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_, - encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW])) - SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]) - if (this->detection_distance_sensor_ != nullptr) { + encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW])); + SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]); + if (this->detection_distance_sensor_.has_sensor()) { int new_detect_distance = 0; if (target_state != 0x00 && (target_state & MOVE_BITMASK)) { new_detect_distance = @@ -410,7 +410,7 @@ void LD2412Component::handle_periodic_data_() { } else if (target_state != 0x00) { new_detect_distance = encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]); } - this->detection_distance_sensor_->publish_state_if_not_dup(new_detect_distance); + this->detection_distance_sensor_.publish_state_if_not_dup(new_detect_distance); } if (engineering_mode) { // Engineering mode needs at least LIGHT_SENSOR + 1 bytes @@ -423,27 +423,27 @@ void LD2412Component::handle_periodic_data_() { Moving energy: 20~28th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]); } /* Still energy: 29~37th bytes */ for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]) + SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]); } /* Light sensor value */ - SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]) + SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]); } } else { for (auto &gate_move_sensor : this->gate_move_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor); } for (auto &gate_still_sensor : this->gate_still_sensors_) { - SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor) + SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor); } - SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_) + SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_); } #endif // the radar module won't tell us when it's done, so we just have to keep polling... @@ -846,12 +846,11 @@ void LD2412Component::set_light_out_control() { } #ifdef USE_SENSOR -// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. void LD2412Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_move_sensors_[gate] = new SensorWithDedup(s); + this->gate_move_sensors_[gate].set_sensor(s); } void LD2412Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { - this->gate_still_sensors_[gate] = new SensorWithDedup(s); + this->gate_still_sensors_[gate].set_sensor(s); } #endif diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 7fd22459787..306e7ae31d2 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -133,8 +133,8 @@ class LD2412Component : public Component, public uart::UARTDevice { std::array gate_still_threshold_numbers_{}; #endif #ifdef USE_SENSOR - std::array *, TOTAL_GATES> gate_move_sensors_{}; - std::array *, TOTAL_GATES> gate_still_sensors_{}; + std::array, TOTAL_GATES> gate_move_sensors_{}; + std::array, TOTAL_GATES> gate_still_sensors_{}; #endif }; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 58c3cac42d8..0dc2638aad9 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -565,6 +565,7 @@ void LD2450Component::handle_periodic_data_() { SAFE_PUBLISH_SENSOR(this->still_target_count_sensor_, still_target_count); // Moving Target Count SAFE_PUBLISH_SENSOR(this->moving_target_count_sensor_, moving_target_count); + #endif #ifdef USE_BINARY_SENSOR @@ -872,33 +873,32 @@ void LD2450Component::query_target_tracking_mode_() { this->send_command_(CMD_QU void LD2450Component::query_zone_() { this->send_command_(CMD_QUERY_ZONE, nullptr, 0); } #ifdef USE_SENSOR -// These could leak memory, but they are only set once prior to 'setup()' and should never be used again. void LD2450Component::set_move_x_sensor(uint8_t target, sensor::Sensor *s) { - this->move_x_sensors_[target] = new SensorWithDedup(s); + this->move_x_sensors_[target].set_sensor(s); } void LD2450Component::set_move_y_sensor(uint8_t target, sensor::Sensor *s) { - this->move_y_sensors_[target] = new SensorWithDedup(s); + this->move_y_sensors_[target].set_sensor(s); } void LD2450Component::set_move_speed_sensor(uint8_t target, sensor::Sensor *s) { - this->move_speed_sensors_[target] = new SensorWithDedup(s); + this->move_speed_sensors_[target].set_sensor(s); } void LD2450Component::set_move_angle_sensor(uint8_t target, sensor::Sensor *s) { - this->move_angle_sensors_[target] = new SensorWithDedup(s); + this->move_angle_sensors_[target].set_sensor(s); } void LD2450Component::set_move_distance_sensor(uint8_t target, sensor::Sensor *s) { - this->move_distance_sensors_[target] = new SensorWithDedup(s); + this->move_distance_sensors_[target].set_sensor(s); } void LD2450Component::set_move_resolution_sensor(uint8_t target, sensor::Sensor *s) { - this->move_resolution_sensors_[target] = new SensorWithDedup(s); + this->move_resolution_sensors_[target].set_sensor(s); } void LD2450Component::set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_target_count_sensors_[zone] = new SensorWithDedup(s); + this->zone_target_count_sensors_[zone].set_sensor(s); } void LD2450Component::set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_still_target_count_sensors_[zone] = new SensorWithDedup(s); + this->zone_still_target_count_sensors_[zone].set_sensor(s); } void LD2450Component::set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s) { - this->zone_moving_target_count_sensors_[zone] = new SensorWithDedup(s); + this->zone_moving_target_count_sensors_[zone].set_sensor(s); } #endif #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index cbcdec10b31..10f9bb874a4 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -182,15 +182,15 @@ class LD2450Component : public Component, public uart::UARTDevice { ZoneOfNumbers zone_numbers_[MAX_ZONES]; #endif #ifdef USE_SENSOR - std::array *, MAX_TARGETS> move_x_sensors_{}; - std::array *, MAX_TARGETS> move_y_sensors_{}; - std::array *, MAX_TARGETS> move_speed_sensors_{}; - std::array *, MAX_TARGETS> move_angle_sensors_{}; - std::array *, MAX_TARGETS> move_distance_sensors_{}; - std::array *, MAX_TARGETS> move_resolution_sensors_{}; - std::array *, MAX_ZONES> zone_target_count_sensors_{}; - std::array *, MAX_ZONES> zone_still_target_count_sensors_{}; - std::array *, MAX_ZONES> zone_moving_target_count_sensors_{}; + std::array, MAX_TARGETS> move_x_sensors_{}; + std::array, MAX_TARGETS> move_y_sensors_{}; + std::array, MAX_TARGETS> move_speed_sensors_{}; + std::array, MAX_TARGETS> move_angle_sensors_{}; + std::array, MAX_TARGETS> move_distance_sensors_{}; + std::array, MAX_TARGETS> move_resolution_sensors_{}; + std::array, MAX_ZONES> zone_target_count_sensors_{}; + std::array, MAX_ZONES> zone_still_target_count_sensors_{}; + std::array, MAX_ZONES> zone_moving_target_count_sensors_{}; #endif #ifdef USE_TEXT_SENSOR std::array direction_text_sensors_{}; diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index fd55167974b..cba1b68a15f 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -11,28 +11,20 @@ #define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ protected: \ - ld24xx::SensorWithDedup *name##_sensor_{nullptr}; \ + ld24xx::SensorWithDedup name##_sensor_{}; \ \ public: \ - void set_##name##_sensor(sensor::Sensor *sensor) { \ - this->name##_sensor_ = new ld24xx::SensorWithDedup(sensor); \ - } + void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); } #endif #define LOG_SENSOR_WITH_DEDUP_SAFE(tag, name, sensor) \ - if ((sensor) != nullptr) { \ - LOG_SENSOR(tag, name, (sensor)->sens); \ + if ((sensor).has_sensor()) { \ + LOG_SENSOR(tag, name, (sensor).get_sensor()); \ } -#define SAFE_PUBLISH_SENSOR(sensor, value) \ - if ((sensor) != nullptr) { \ - (sensor)->publish_state_if_not_dup(value); \ - } +#define SAFE_PUBLISH_SENSOR(sensor, value) (sensor).publish_state_if_not_dup(value) -#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) \ - if ((sensor) != nullptr) { \ - (sensor)->publish_state_unknown(); \ - } +#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) (sensor).publish_state_unknown() #define highbyte(val) (uint8_t)((val) >> 8) #define lowbyte(val) (uint8_t)((val) &0xff) @@ -70,25 +62,33 @@ inline void format_version_str(const uint8_t *version, std::span buffe } #ifdef USE_SENSOR -// Helper class to store a sensor with a deduplicator & publish state only when the value changes +/// Sensor with deduplication — sensor may be null, null check is internal. +/// Stored inline, no heap allocation. Does nothing when no sensor is set. template class SensorWithDedup { public: - SensorWithDedup(sensor::Sensor *sens) : sens(sens) {} + void set_sensor(sensor::Sensor *sens) { + this->sens_ = sens; + this->dedup_ = {}; + } void publish_state_if_not_dup(T state) { - if (this->publish_dedup.next(state)) { - this->sens->publish_state(static_cast(state)); + if (this->sens_ != nullptr && this->dedup_.next(state)) { + this->sens_->publish_state(static_cast(state)); } } void publish_state_unknown() { - if (this->publish_dedup.next_unknown()) { - this->sens->publish_state(NAN); + if (this->sens_ != nullptr && this->dedup_.next_unknown()) { + this->sens_->publish_state(NAN); } } - sensor::Sensor *sens; - Deduplicator publish_dedup; + bool has_sensor() const { return this->sens_ != nullptr; } + sensor::Sensor *get_sensor() const { return this->sens_; } + + protected: + sensor::Sensor *sens_{nullptr}; + Deduplicator dedup_; }; #endif } // namespace esphome::ld24xx diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index fba67172949..313b36d31ed 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -3,7 +3,6 @@ #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include #include #include @@ -11,9 +10,6 @@ namespace esphome::libretiny { static const char *const TAG = "preferences"; -// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding -static constexpr size_t KEY_BUFFER_SIZE = 12; - struct NVSData { uint32_t key; SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.) @@ -50,8 +46,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { } } - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, this->key); fdb_blob_make(this->blob, data, len); size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob); if (actual_len != len) { @@ -92,8 +88,8 @@ bool LibreTinyPreferences::sync() { uint32_t last_key = 0; for (const auto &save : s_pending_save) { - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, save.key); ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str); if (this->is_changed_(&this->db, save, key_str)) { ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size()); diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index 9d731a2bd56..e793226bb1e 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -22,4 +22,20 @@ uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const { return (target - a <= b - target) ? lo : lo + 1; } +Color ESPColorCorrection::color_uncorrect(Color color) const { + // uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness) + return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green), + this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white)); +} + +uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const { + if (max_brightness == 0 || this->local_brightness_ == 0) + return 0; + // Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero, + // (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs. + uint32_t uncorrected = this->gamma_uncorrect_(value) * 255UL; + uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_; + return static_cast(std::min(res, uint32_t(255))); +} + } // namespace esphome::light diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 48ecc463648..4eb5208c96e 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -46,38 +46,18 @@ class ESPColorCorrection { uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_); return this->gamma_correct_(res); } - inline Color color_uncorrect(Color color) const ESPHOME_ALWAYS_INLINE { - // uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness) - return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green), - this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white)); - } + Color color_uncorrect(Color color) const; inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.red == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(red) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.red) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(red, this->max_brightness_.red); } inline uint8_t color_uncorrect_green(uint8_t green) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.green == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(green) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.green) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(green, this->max_brightness_.green); } inline uint8_t color_uncorrect_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.blue == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(blue) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.blue) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(blue, this->max_brightness_.blue); } inline uint8_t color_uncorrect_white(uint8_t white) const ESPHOME_ALWAYS_INLINE { - if (this->max_brightness_.white == 0 || this->local_brightness_ == 0) - return 0; - uint16_t uncorrected = this->gamma_uncorrect_(white) * 255UL; - uint16_t res = ((uncorrected / this->max_brightness_.white) * 255UL) / this->local_brightness_; - return (uint8_t) std::min(res, uint16_t(255)); + return this->color_uncorrect_channel_(white, this->max_brightness_.white); } protected: @@ -85,6 +65,9 @@ class ESPColorCorrection { uint8_t gamma_correct_(uint8_t value) const; /// Reverse gamma: binary search the forward PROGMEM table uint8_t gamma_uncorrect_(uint8_t value) const; + /// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line + /// to avoid duplicating two 16-bit divides at every call site. + uint8_t color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const; const uint16_t *gamma_table_{nullptr}; Color max_brightness_{255, 255, 255, 255}; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index f6f6204f4c4..b6421dc43d7 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -341,7 +341,7 @@ async def to_code(configs): df.LOGGER.info("LVGL will use hardware rotation via display driver") else: rotation_type = RotationType.ROTATION_SOFTWARE - if get_esp32_variant() == VARIANT_ESP32P4: + if CORE.is_esp32 and get_esp32_variant() == VARIANT_ESP32P4: df.LOGGER.info("LVGL will use software rotation (PPA accelerated)") else: df.LOGGER.info("LVGL will use software rotation") diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 5a1f011617a..b71d57498a2 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -24,6 +25,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) @@ -35,6 +37,8 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index fbdb6903b84..118a77ce378 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -24,11 +24,22 @@ void MCP23016::setup() { // all pins input this->write_reg_(MCP23016_IODIR1, 0xFFFF); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&MCP23016::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + this->disable_loop(); } +void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_context(); } void MCP23016::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } } bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_mask_); } @@ -37,6 +48,9 @@ void MCP23016::digital_write_hw(uint8_t pin, bool value) { this->update_reg_(pin void MCP23016::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { this->update_reg_(pin, true, MCP23016_IODIR1); + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { this->update_reg_(pin, false, MCP23016_IODIR1); } diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 494bc9c197a..32149ba3e24 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -35,7 +35,10 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander:: float get_setup_priority() const override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + protected: + static void IRAM_ATTR gpio_intr(MCP23016 *arg); // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; @@ -51,6 +54,7 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander:: uint16_t olat_{0x0000}; // Cache for input values (16-bit combined for both banks) uint16_t input_mask_{0x0000}; + InternalGPIOPin *interrupt_pin_{nullptr}; }; class MCP23016GPIOPin : public GPIOPin { diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 79d355e8ae9..7c36295e8d9 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -170,7 +170,7 @@ async def to_code(config): cg.add_library("LEAmDNS", None) if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.10.0") + add_idf_component(name="espressif/mdns", ref="1.11.0") cg.add_define("USE_MDNS") diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index ff27dec6df0..5ab1e4bb805 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.1.2") 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 0ab6cd3772d..e761e4866fd 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 0811bfb19b8..fc9eeb5e2d8 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}; diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 50521cf238b..69e0d46d2d8 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -28,7 +28,8 @@ void AirConditioner::on_status_change() { if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK && this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) { // Read existing presets (set by codegen), append frost protection, write back - const auto &existing = this->get_traits().get_supported_custom_presets(); + auto traits = this->get_traits(); + const auto &existing = traits.get_supported_custom_presets(); bool found = false; for (const char *p : existing) { if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) { diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 423226b1d70..2242be6c17b 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -234,9 +234,9 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, - this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, - this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, + internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, + (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, + this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } @@ -305,7 +305,7 @@ class MipiSpi : public display::Display, this->write_command_(BRIGHTNESS, this->brightness_.value()); // calculate new madctl value from base value adjusted for rotation - uint8_t madctl = MADCTL; // lower 8 bits only + uint8_t madctl = (uint8_t) MADCTL; // lower 8 bits only constexpr bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0; constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX; constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY; diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 04db690c6f4..3f3df753512 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -45,6 +45,18 @@ def is_remote_package(package_config: dict) -> bool: return CONF_URL in package_config +def is_package_definition(value: object) -> bool: + """Returns True if the value looks like a package definition rather than a config fragment. + + Package definitions are IncludeFile objects, git URL shorthand strings, or + remote package dicts (containing a ``url:`` key). Config fragments are + plain dicts that represent component configuration. + """ + return isinstance(value, (yaml_util.IncludeFile, str)) or ( + isinstance(value, dict) and is_remote_package(value) + ) + + def valid_package_contents(package_config: dict) -> dict: """Validate that a package looks like a plausible ESPHome config fragment. @@ -309,20 +321,23 @@ def _walk_packages( return config packages = config[CONF_PACKAGES] - if not isinstance(packages, (dict, list)): - raise cv.Invalid( - f"Packages must be a key to value mapping or list, got {type(packages)} instead" - ) - with cv.prepend_path(CONF_PACKAGES): + if isinstance(packages, yaml_util.IncludeFile): + # If the packages key is an IncludeFile, resolve it first before processing. + packages, _ = resolve_include(packages, [], context, strict_undefined=False) + if not isinstance(packages, (dict, list)): + raise cv.Invalid( + f"Packages must be a key to value mapping or list, got {type(packages)} instead" + ) + if not isinstance(packages, dict): _walk_package_list(packages, callback, context) elif (result := _walk_package_dict(packages, callback, context)) is not None: - if not validate_deprecated: + if not validate_deprecated or any( + is_package_definition(v) for v in packages.values() + ): raise result # Fallback: treat the dict as a single deprecated package. - # Note: this catches *any* cv.Invalid from the callback, which may - # mask real validation errors in named package dicts. # This block can be removed once the single-package # deprecation period (2026.7.0) is over. config[CONF_PACKAGES] = [packages] @@ -461,6 +476,9 @@ class _PackageProcessor: self, package_config: dict | str, context_vars: ContextVars | None ) -> dict: """Resolve a single package and recurse into any nested packages.""" + from_remote = isinstance(package_config, dict) and is_remote_package( + package_config + ) package_config = self.resolve_package(package_config, context_vars) self.collect_substitutions(package_config) @@ -470,7 +488,18 @@ class _PackageProcessor: # Push context from !include vars on the package root and on the packages key context_vars = push_context(package_config, context_vars) context_vars = push_context(package_config[CONF_PACKAGES], context_vars) - return _walk_packages(package_config, self.process_package, context_vars) + # Disable the deprecated single-package fallback for remote + # packages. _process_remote_package returns dicts with + # already-resolved values that is_package_definition cannot + # distinguish from config fragments, so the fallback would + # always fire and mask real errors with wrong paths + # (packages->0 instead of packages->). + return _walk_packages( + package_config, + self.process_package, + context_vars, + validate_deprecated=not from_remote, + ) def do_packages_pass( diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index b6e156e7ff4..813bb35c482 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -25,7 +26,12 @@ PCA6416AGPIOPin = pca6416a_ns.class_( CONF_PCA6416A = "pca6416a" CONFIG_SCHEMA = ( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent)}) + cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + } + ) .extend(cv.COMPONENT_SCHEMA) .extend(i2c.i2c_device_schema(0x21)) ) @@ -35,6 +41,8 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index f393af88cea..dc7463b01ba 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -49,11 +49,22 @@ void PCA6416AComponent::setup() { ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(), this->status_has_error()); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA6416AComponent::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + this->disable_loop(); } +void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enable_loop_soon_any_context(); } void PCA6416AComponent::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } } void PCA6416AComponent::dump_config() { @@ -62,6 +73,7 @@ void PCA6416AComponent::dump_config() { } else { ESP_LOGCONFIG(TAG, "PCA6416A:"); } + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -101,6 +113,9 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) { this->update_register_(pin, true, pull_dir); this->update_register_(pin, false, pull_en); } + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLUP)) { this->update_register_(pin, true, io_dir); if (has_pullup_) { @@ -109,6 +124,9 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) { } else { ESP_LOGW(TAG, "Your PCA6416A does not support pull-up resistors"); } + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { this->update_register_(pin, false, io_dir); } diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 138a51cc208..4d2e6b219e4 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -24,7 +24,10 @@ class PCA6416AComponent : public Component, void dump_config() override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + protected: + static void IRAM_ATTR gpio_intr(PCA6416AComponent *arg); // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; @@ -43,6 +46,7 @@ class PCA6416AComponent : public Component, esphome::i2c::ErrorCode last_error_; /// Only the PCAL6416A has pull-up resistors bool has_pullup_{false}; + InternalGPIOPin *interrupt_pin_{nullptr}; }; /// Helper class to expose a PCA6416A pin as an internal input GPIO pin. diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2040/helpers.cpp index 8cb5f7c18df..6e5ddad2364 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() {} diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index ec62fad10af..6ea09e3a9e8 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 a758d637955..edc00e3727e 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 0fddfdccdb7..2b13efb38da 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; } @@ -383,7 +387,7 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) { if (millis() - start > 20) { ESP_LOGE(TAG, "Set mode failure"); this->mark_failed(); - break; + return; } } } diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index be7b6d8d9f8..76f942fddac 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); diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index f42e0fe398b..5f571fcea6e 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -27,6 +28,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(TCA9555Component), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) @@ -38,6 +40,8 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 79c52538983..3eb794df441 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -24,9 +24,18 @@ void TCA9555Component::setup() { this->mark_failed(); return; } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&TCA9555Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + this->disable_loop(); } +void IRAM_ATTR TCA9555Component::gpio_intr(TCA9555Component *arg) { arg->enable_loop_soon_any_context(); } void TCA9555Component::dump_config() { ESP_LOGCONFIG(TAG, "TCA9555:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -36,6 +45,9 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { // Set mode mask bit this->mode_mask_ |= 1 << pin; + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { // Clear mode mask bit this->mode_mask_ &= ~(1 << pin); @@ -43,7 +55,12 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { // Write GPIO to enable input mode this->write_gpio_modes_(); } -void TCA9555Component::loop() { this->reset_pin_cache_(); } +void TCA9555Component::loop() { + this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } +} bool TCA9555Component::read_gpio_outputs_() { if (this->is_failed()) diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 9f7273b1e7e..d4d070013c7 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -24,7 +24,10 @@ class TCA9555Component : public Component, void loop() override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + protected: + static void IRAM_ATTR gpio_intr(TCA9555Component *arg); bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; @@ -39,6 +42,8 @@ class TCA9555Component : public Component, bool read_gpio_modes_(); bool write_gpio_modes_(); bool read_gpio_outputs_(); + + InternalGPIOPin *interrupt_pin_{nullptr}; }; /// Helper class to expose a TCA9555 pin as an internal input GPIO pin. diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 4fe87de0ca2..1098d8de5fe 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -315,7 +315,7 @@ void TCS34725Component::set_integration_time(TCS34725IntegrationTime integration my_integration_time_regval = integration_time; this->integration_time_auto_ = false; } - this->integration_time_ = (256.f - my_integration_time_regval) * 2.4f; + this->integration_time_ = (256.f - (float) my_integration_time_regval) * 2.4f; ESP_LOGI(TAG, "TCS34725I Integration time set to: %.1fms", this->integration_time_); } void TCS34725Component::set_gain(TCS34725Gain gain) { diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 95b166901ad..9812714ec03 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -114,7 +114,25 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf uint8_t *data, size_t len, bool final) { ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK; - if (index == 0 && !this->ota_backend_) { + // First byte of a new upload: index==0 with actual data. (web_server_idf + // fires a separate start-marker call with data==nullptr/len==0 before the + // first real chunk; gate on len>0 so we only trigger once per upload.) + if (index == 0 && len > 0) { + // If a previous upload was interrupted (e.g. client closed the tab, TCP + // reset) the backend from that session may still be open. Tear it down + // so flash state doesn't get concatenated with the new image (which can + // produce a technically-valid-sized but corrupted firmware that bricks + // the device once it reboots). + if (this->ota_backend_) { + ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); + this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + // Notify listeners that the previous session was aborted before the new one starts. + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif + this->ota_backend_.reset(); + } + // Initialize OTA on first call this->ota_init_(filename.c_str()); diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index a3b0471ebcc..93a9a1ae8ee 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -99,7 +99,6 @@ int main() { setup(); while (true) { loop(); - esphome::yield(); } return 0; } diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd758598801..0c17c701615 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -85,8 +85,12 @@ void Application::setup() { if (component->can_proceed()) continue; + // Force the status LED to blink WARNING while we wait for a slow + // component to come up. Cleared after setup() finishes if no real + // component has warning set. + this->app_state_ |= STATUS_LED_WARNING; + do { - uint8_t new_app_state = STATUS_LED_WARNING; uint32_t now = millis(); // Process pending loop enables to handle GPIO interrupts during setup @@ -96,17 +100,26 @@ void Application::setup() { // Update loop_component_start_time_ right before calling each component this->loop_component_start_time_ = millis(); this->components_[j]->call(); - new_app_state |= this->components_[j]->get_component_state(); - this->app_state_ |= new_app_state; this->feed_wdt(); } this->after_loop_tasks_(); - this->app_state_ = new_app_state; yield(); } while (!component->can_proceed() && !component->is_failed()); } + // Setup is complete. Reconcile STATUS_LED_WARNING: the slow-setup path + // above may have forced it on, and any status_clear_warning() calls + // from components during setup were intentional no-ops (gated by + // APP_STATE_SETUP_COMPLETE). Walk components once here to pick up the + // real state. STATUS_LED_ERROR is never artificially forced, so its + // clear path always works and needs no reconciliation. Finally, set + // APP_STATE_SETUP_COMPLETE so subsequent warning clears go through + // the normal walk-and-clear path. + if (!this->any_component_has_status_flag_(STATUS_LED_WARNING)) + this->app_state_ &= ~STATUS_LED_WARNING; + this->app_state_ |= APP_STATE_SETUP_COMPLETE; + ESP_LOGI(TAG, "setup() finished successfully!"); #ifdef USE_SETUP_PRIORITY_OVERRIDE @@ -211,6 +224,19 @@ void HOT Application::feed_wdt(uint32_t time) { #endif } } +bool Application::any_component_has_status_flag_(uint8_t flag) const { + // Walk all components (not just looping ones) so non-looping components' + // status bits are respected. Only called from the slow-path clear helpers + // (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an + // actual set→clear transition, so walking O(N) here is paid once per + // transition — not once per loop iteration. + for (auto *component : this->components_) { + if ((component->get_component_state() & flag) != 0) + return true; + } + return false; +} + void Application::reboot() { ESP_LOGI(TAG, "Forcing a reboot"); for (auto &component : std::ranges::reverse_view(this->components_)) { diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b4907..0150bb6646a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -401,7 +401,18 @@ class Application { */ void teardown_components(uint32_t timeout_ms); - uint8_t get_app_state() const { return this->app_state_; } + /// Return the public app state status bits (STATUS_LED_* only). + /// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked + /// out so external readers (status_led components, etc.) never see them. + uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; } + + /// True once Application::setup() has finished walking all components + /// and finalized the initial status flags. Before this point, the + /// slow-setup busy-wait may be forcing STATUS_LED_WARNING on, and + /// status_clear_* intentionally skips its walk-and-clear step so the + /// forced bit doesn't get wiped. Stored as a free bit on app_state_ + /// (bit 6) to avoid costing additional RAM. + bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; } // Helper macro for entity getter method declarations #ifdef USE_DEVICES @@ -577,6 +588,12 @@ class Application { bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } #endif + /// Walk all registered components looking for any whose component_state_ + /// has the given flag set. Used by Component::status_clear_*_slow_path_() + /// (which is a friend) to decide whether to clear the corresponding bit on + /// this->app_state_ (the app-wide "any component has this status" indicator). + bool any_component_has_status_flag_(uint8_t flag) const; + /// Register a component, detecting loop() override at compile time. /// Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance. template void register_component_(T *comp) { @@ -838,8 +855,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_ } inline void ESPHOME_ALWAYS_INLINE Application::loop() { - uint8_t new_app_state = 0; - // Get the initial loop time at the start uint32_t last_op_end_time = millis(); @@ -859,13 +874,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); } - new_app_state |= component->get_component_state(); - this->app_state_ |= new_app_state; this->feed_wdt(last_op_end_time); } this->after_loop_tasks_(); - this->app_state_ = new_app_state; #ifdef USE_RUNTIME_STATS // Process any pending runtime stats printing after all components have run diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index deda42b0a7d..8949b4b76dc 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -411,10 +411,23 @@ void Component::status_set_error(const LogString *message) { } void Component::status_clear_warning_slow_path_() { this->component_state_ &= ~STATUS_LED_WARNING; + // Clear the app-wide STATUS_LED_WARNING bit only if setup has finished + // AND no other component still has it set. During setup the forced + // STATUS_LED_WARNING (from the slow-setup busy-wait) must not be wiped + // by a transient component clear — Application::setup() reconciles + // the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE. + // The set path is unchanged (set_status_flag_ still writes directly). + if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING)) + App.app_state_ &= ~STATUS_LED_WARNING; ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_clear_error_slow_path_() { this->component_state_ &= ~STATUS_LED_ERROR; + // STATUS_LED_ERROR is never artificially forced — it only ever lands + // in app_state_ via a real set_status_flag_ call. So the walk-and-clear + // path is always safe, including during setup. + if (!App.any_component_has_status_flag_(STATUS_LED_ERROR)) + App.app_state_ &= ~STATUS_LED_ERROR; ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str())); } void Component::status_momentary_warning(const char *name, uint32_t length) { diff --git a/esphome/core/component.h b/esphome/core/component.h index e2b7aa85d3a..3307c5ae76e 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -89,6 +89,11 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08; inline constexpr uint8_t STATUS_LED_ERROR = 0x10; // Component loop override flag uses bit 5 (set at registration time) inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20; +// Bit 6 on Application::app_state_ (ONLY) — set at the end of +// Application::setup(). Component::status_clear_*_slow_path_() uses this to +// decide whether to propagate clears to App.app_state_. Never set on a +// Component's component_state_. +inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40; // Remove before 2026.8.0 enum class RetryResult { DONE, RETRY }; diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index dc4560b5ef1..d8b5a1e87f9 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -347,17 +347,18 @@ std::string format_mac_address_pretty(const uint8_t *mac) { return std::string(buf); } -// Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase +// Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase. +// When separator is set, it is written unconditionally after each byte and the last +// one is overwritten with '\0', eliminating the per-byte `i < length - 1` check. static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator, char base) { - if (length == 0) { - buffer[0] = '\0'; + if (length == 0 || buffer_size == 0) { + if (buffer_size > 0) + buffer[0] = '\0'; return buffer; } - // With separator: total length is 3*length (2*length hex chars, (length-1) separators, 1 null terminator) - // Without separator: total length is 2*length + 1 (2*length hex chars, 1 null terminator) uint8_t stride = separator ? 3 : 2; - size_t max_bytes = separator ? (buffer_size / stride) : ((buffer_size - 1) / stride); + size_t max_bytes = separator ? (buffer_size / 3) : ((buffer_size - 1) / 2); if (max_bytes == 0) { buffer[0] = '\0'; return buffer; @@ -369,14 +370,30 @@ static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t size_t pos = i * stride; buffer[pos] = format_hex_char(data[i] >> 4, base); buffer[pos + 1] = format_hex_char(data[i] & 0x0F, base); - if (separator && i < length - 1) { + if (separator) { buffer[pos + 2] = separator; } } + // With separator: overwrite last separator with '\0' + // Without: write '\0' after last hex char buffer[length * stride - (separator ? 1 : 0)] = '\0'; return buffer; } +char *uint32_to_str_unchecked(char *buf, uint32_t val) { + if (val == 0) { + *buf++ = '0'; + return buf; + } + char *start = buf; + while (val > 0) { + *buf++ = '0' + (val % 10); + val /= 10; + } + std::reverse(start, buf); + return buf; +} + char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) { return format_hex_internal(buffer, buffer_size, data, length, 0, 'a'); } @@ -539,7 +556,7 @@ static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy // float*int loses bits at exact-half boundaries (e.g. 23.45f*10 = 234.5 in float, // but snprintf sees 234.500007... via double promotion and rounds differently). uint32_t scaled = static_cast(lrint(static_cast(value) * mult)); - p = uint32_to_str_(p, scaled / mult); + p = uint32_to_str_unchecked(p, scaled / mult); if (accuracy_decimals > 0) { *p++ = '.'; p = frac_to_str_(p, scaled % mult, mult / 10); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 009f08c323a..d41b8181a45 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1263,13 +1263,13 @@ constexpr uint8_t parse_hex_char(char c) { } /// Convert a nibble (0-15) to hex char with specified base ('a' for lowercase, 'A' for uppercase) -inline char format_hex_char(uint8_t v, char base) { return v >= 10 ? base + (v - 10) : '0' + v; } +ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v, char base) { return v >= 10 ? base + (v - 10) : '0' + v; } /// Convert a nibble (0-15) to lowercase hex char -inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); } +ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); } /// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) -inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); } +ESPHOME_ALWAYS_INLINE inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); } /// Write int8 value to buffer without modulo operations. /// Buffer must have at least 4 bytes free. Returns pointer past last char written. @@ -1318,24 +1318,12 @@ static constexpr size_t UINT32_MAX_STR_SIZE = 11; /// Write unsigned 32-bit integer to buffer (internal, no size check). /// Buffer must have at least 10 bytes free. Returns pointer past last char written. -inline char *uint32_to_str_(char *buf, uint32_t val) { - if (val == 0) { - *buf++ = '0'; - return buf; - } - char *start = buf; - while (val > 0) { - *buf++ = '0' + (val % 10); - val /= 10; - } - std::reverse(start, buf); - return buf; -} +char *uint32_to_str_unchecked(char *buf, uint32_t val); /// Write unsigned 32-bit integer to buffer with compile-time size check. /// Null-terminates the output. Returns number of chars written (excluding null). inline size_t uint32_to_str(std::span buf, uint32_t val) { - char *end = uint32_to_str_(buf.data(), val); + char *end = uint32_to_str_unchecked(buf.data(), val); *end = '\0'; return static_cast(end - buf.data()); } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 43a3ec7049b..21af94ea4e3 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -138,7 +138,7 @@ class Scheduler { // (single-threaded). This is safe because the main loop is the only thread // that reads to_add_ without holding lock_; other threads may read it only // while holding the mutex (e.g. cancel_item_locked_). - inline void HOT process_to_add() { + inline void ESPHOME_ALWAYS_INLINE HOT process_to_add() { if (this->to_add_empty_()) return; this->process_to_add_slow_path_(); @@ -302,7 +302,7 @@ class Scheduler { // loop thread structurally modifies items_ (push/pop/erase). Other threads may // iterate items_ and mark items removed under lock_, but never change the // vector's size or data pointer. - inline bool HOT cleanup_() { + inline bool ESPHOME_ALWAYS_INLINE HOT cleanup_() { if (this->to_remove_empty_()) return !this->items_.empty(); return this->cleanup_slow_path_(); @@ -407,7 +407,7 @@ class Scheduler { // Process defer queue for FIFO execution of deferred items. // IMPORTANT: This method should only be called from the main thread (loop task). // Inlined: the fast path (nothing deferred) is just an atomic load check. - inline void HOT process_defer_queue_(uint32_t &now) { + inline void ESPHOME_ALWAYS_INLINE HOT process_defer_queue_(uint32_t &now) { // Fast path: nothing to process, avoid lock entirely. // Worst case is a one-loop-iteration delay before newly deferred items are processed. if (this->defer_empty_()) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 479090016f8..f2bd3b92a31 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -113,7 +113,8 @@ def _generate_source_table_code( entries = ", ".join(var_names) lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};") lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{") - lines.append(f' if (index == 0 || index > {count}) return LOG_STR("");') + cond = "index == 0" if count >= 255 else f"index == 0 || index > {count}" + lines.append(f' if ({cond}) return LOG_STR("");') lines.append(" return reinterpret_cast(") lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));") lines.append("}") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1e40fef2dc3..f4e3e751ec0 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -3,6 +3,8 @@ dependencies: version: "7.4.2" esphome/esp-audio-libs: version: 2.0.4 + esphome/micro-decoder: + version: 0.1.1 esphome/micro-flac: version: 0.1.1 esphome/micro-opus: @@ -14,7 +16,7 @@ dependencies: espressif/esp32-camera: version: 2.1.6 espressif/mdns: - version: 1.10.0 + version: 1.11.0 espressif/esp_wifi_remote: version: 1.4.0 rules: diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index cb080b2a953..dec541985f6 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -5,157 +5,15 @@ import os from pathlib import Path import re import subprocess -import time -from typing import Any +import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError -from esphome.util import run_external_command, run_external_process +from esphome.util import run_external_process _LOGGER = logging.getLogger(__name__) -def patch_structhash(): - # Patch platformio's structhash to not recompile the entire project when files are - # removed/added. This might have unintended consequences, but this improves compile - # times greatly when adding/removing components and a simple clean build solves - # all issues - from platformio.run import cli, helpers - - def patched_clean_build_dir(build_dir, *args): - from platformio import fs - from platformio.project.helpers import get_project_dir - - platformio_ini = Path(get_project_dir()) / "platformio.ini" - - build_dir = Path(build_dir) - - # if project's config is modified - if ( - build_dir.is_dir() - and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime - ): - fs.rmtree(build_dir) - - if not build_dir.is_dir(): - build_dir.mkdir(parents=True) - - helpers.clean_build_dir = patched_clean_build_dir - cli.clean_build_dir = patched_clean_build_dir - - -def patch_file_downloader(): - """Patch PlatformIO's FileDownloader to retry on PackageException errors. - - PlatformIO's FileDownloader uses HTTPSession which lacks built-in retry - for 502/503 errors. We add retries with exponential backoff and close the - session between attempts to force a fresh TCP connection, which may route - to a different CDN edge node. - """ - from platformio.package.download import FileDownloader - from platformio.package.exception import PackageException - - if getattr(FileDownloader.__init__, "_esphome_patched", False): - return - - original_init = FileDownloader.__init__ - - def patched_init(self, *args: Any, **kwargs: Any) -> None: - max_retries = 5 - - for attempt in range(max_retries): - try: - original_init(self, *args, **kwargs) - return - except PackageException as e: - if attempt < max_retries - 1: - # Exponential backoff: 2, 4, 8, 16 seconds - delay = 2 ** (attempt + 1) - _LOGGER.warning( - "Package download failed: %s. " - "Retrying in %d seconds... (attempt %d/%d)", - str(e), - delay, - attempt + 1, - max_retries, - ) - # Close the response and session to free resources - # and force a new TCP connection on retry, which may - # route to a different CDN edge node - # pylint: disable=protected-access,broad-except - try: - if ( - hasattr(self, "_http_response") - and self._http_response is not None - ): - self._http_response.close() - if hasattr(self, "_http_session"): - self._http_session.close() - except Exception: - pass - # pylint: enable=protected-access,broad-except - time.sleep(delay) - else: - # Final attempt - re-raise - raise - - patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access - FileDownloader.__init__ = patched_init - - -IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})" -FILTER_PLATFORMIO_LINES = [ - r"Verbose mode can be enabled via `-v, --verbose` option.*", - r"CONFIGURATION: https://docs.platformio.org/.*", - r"DEBUG: Current.*", - r"LDF Modes:.*", - r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*", - f"Looking for {IGNORE_LIB_WARNINGS} library in registry", - f"Warning! Library `.*'{IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.", - f"You can ignore this message, if `.*{IGNORE_LIB_WARNINGS}.*` is a built-in library.*", - r"Scanning dependencies...", - r"Found \d+ compatible libraries", - r"Memory Usage -> https://bit.ly/pio-memory-usage", - r"Found: https://platformio.org/lib/show/.*", - r"Using cache: .*", - r"Installing dependencies", - r"Library Manager: Already installed, built-in library", - r"Building in .* mode", - r"Advanced Memory Usage is available via .*", - r"Merged .* ELF section", - r"esptool.py v.*", - r"esptool v.*", - r"Checking size .*", - r"Retrieving maximum program size .*", - r"PLATFORM: .*", - r"PACKAGES:.*", - r" - framework-arduinoespressif.* \(.*\)", - r" - tool-esptool.* \(.*\)", - r" - toolchain-.* \(.*\)", - r"Creating BIN file .*", - r"Warning! Could not find file \".*.crt\"", - r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.", - r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", - r"Warning: esp-idf-size exited with code 2", - r"esp_idf_size: error: unrecognized arguments: --ng", - r"Package configuration completed successfully", -] - - -class PlatformioLogFilter(logging.Filter): - """Filter to suppress noisy platformio log messages.""" - - _PATTERN = re.compile( - r"|".join(r"(?:" + pattern + r")" for pattern in FILTER_PLATFORMIO_LINES) - ) - - def filter(self, record: logging.LogRecord) -> bool: - # Only filter messages from platformio-related loggers - if "platformio" not in record.name.lower(): - return True - return self._PATTERN.match(record.getMessage()) is None - - def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) @@ -166,30 +24,9 @@ def run_platformio_cli(*args, **kwargs) -> str | int: os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") # Increase uv retry count to handle transient network errors (default is 3) os.environ.setdefault("UV_HTTP_RETRIES", "10") - cmd = ["platformio"] + list(args) + cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args) - if not CORE.verbose: - kwargs["filter_lines"] = FILTER_PLATFORMIO_LINES - - if os.environ.get("ESPHOME_USE_SUBPROCESS") is not None: - return run_external_process(*cmd, **kwargs) - - import platformio.__main__ - - patch_structhash() - patch_file_downloader() - - # Add log filter to suppress noisy platformio messages - log_filter = PlatformioLogFilter() if not CORE.verbose else None - if log_filter: - for handler in logging.getLogger().handlers: - handler.addFilter(log_filter) - try: - return run_external_command(platformio.__main__.main, *cmd, **kwargs) - finally: - if log_filter: - for handler in logging.getLogger().handlers: - handler.removeFilter(log_filter) + return run_external_process(*cmd, **kwargs) def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: diff --git a/esphome/platformio_runner.py b/esphome/platformio_runner.py new file mode 100644 index 00000000000..599c9408a49 --- /dev/null +++ b/esphome/platformio_runner.py @@ -0,0 +1,187 @@ +"""Subprocess entry point that applies ESPHome's PlatformIO patches. + +Invoked via ``python -m esphome.platformio_runner`` instead of +``python -m platformio`` so that the patches (incremental rebuild +preservation, download retries) apply inside the subprocess. Running +PlatformIO in a subprocess keeps its ``sys.path`` mutations and other +global state from leaking into the ESPHome process. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +import sys +import time +from typing import Any + +_LOGGER = logging.getLogger(__name__) + + +def patch_structhash() -> None: + """Avoid full rebuilds when files are added or removed. + + PlatformIO clears the build dir whenever its structure hash changes. + We replace that with an mtime check against ``platformio.ini`` so + incremental builds are preserved unless the project config changed. + """ + from platformio.run import cli, helpers + + def patched_clean_build_dir(build_dir, *_args): + from platformio import fs + from platformio.project.helpers import get_project_dir + + platformio_ini = Path(get_project_dir()) / "platformio.ini" + build_dir = Path(build_dir) + + if ( + build_dir.is_dir() + and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime + ): + fs.rmtree(build_dir) + + if not build_dir.is_dir(): + build_dir.mkdir(parents=True) + + helpers.clean_build_dir = patched_clean_build_dir + cli.clean_build_dir = patched_clean_build_dir + + +def patch_file_downloader() -> None: + """Retry PlatformIO package downloads with exponential backoff. + + PlatformIO's ``FileDownloader`` uses an ``HTTPSession`` without built-in + retry for 502/503 errors. We wrap ``__init__`` to retry on + ``PackageException`` and close the session between attempts so a new + TCP connection can route to a different CDN edge node. + """ + from platformio.package.download import FileDownloader + from platformio.package.exception import PackageException + + if getattr(FileDownloader.__init__, "_esphome_patched", False): + return + + original_init = FileDownloader.__init__ + + def patched_init(self, *args: Any, **kwargs: Any) -> None: + max_retries = 5 + + for attempt in range(max_retries): + try: + original_init(self, *args, **kwargs) + return + except PackageException as e: + if attempt < max_retries - 1: + delay = 2 ** (attempt + 1) + _LOGGER.warning( + "Package download failed: %s. " + "Retrying in %d seconds... (attempt %d/%d)", + str(e), + delay, + attempt + 1, + max_retries, + ) + # pylint: disable=protected-access,broad-except + try: + if ( + hasattr(self, "_http_response") + and self._http_response is not None + ): + self._http_response.close() + if hasattr(self, "_http_session"): + self._http_session.close() + except Exception: + pass + # pylint: enable=protected-access,broad-except + time.sleep(delay) + else: + raise + + patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + FileDownloader.__init__ = patched_init + + +_IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})" +# Regex patterns matched against each line of PlatformIO output. Lines that +# match are dropped by RedirectText before they reach the parent process. +# Patterns are anchored at the start of the line (RedirectText uses +# ``re.match``). Disabled when the user passes ``-v`` / ``--verbose`` to +# ``esphome compile``. +FILTER_PLATFORMIO_LINES = [ + r"Verbose mode can be enabled via `-v, --verbose` option.*", + r"CONFIGURATION: https://docs.platformio.org/.*", + r"DEBUG: Current.*", + r"LDF Modes:.*", + r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*", + f"Looking for {_IGNORE_LIB_WARNINGS} library in registry", + f"Warning! Library `.*'{_IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.", + f"You can ignore this message, if `.*{_IGNORE_LIB_WARNINGS}.*` is a built-in library.*", + r"Scanning dependencies...", + r"Found \d+ compatible libraries", + r"Memory Usage -> https://bit.ly/pio-memory-usage", + r"Found: https://platformio.org/lib/show/.*", + r"Using cache: .*", + r"Installing dependencies", + r"Library Manager: Already installed, built-in library", + r"Building in .* mode", + r"Advanced Memory Usage is available via .*", + r"Merged .* ELF section", + r"esptool.py v.*", + r"esptool v.*", + r"Checking size .*", + r"Retrieving maximum program size .*", + r"PLATFORM: .*", + r"PACKAGES:.*", + r" - framework-arduinoespressif.* \(.*\)", + r" - tool-esptool.* \(.*\)", + r" - toolchain-.* \(.*\)", + r"Creating BIN file .*", + r"Warning! Could not find file \".*.crt\"", + r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.", + r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", + r"Warning: esp-idf-size exited with code 2", + r"esp_idf_size: error: unrecognized arguments: --ng", + r"Package configuration completed successfully", +] + + +def main() -> int: + patch_structhash() + patch_file_downloader() + + # Wrap stdout/stderr with RedirectText before PlatformIO runs: + # + # 1. RedirectText.isatty() unconditionally returns True. Click, tqdm, and + # PlatformIO's own progress-bar code check ``stream.isatty()`` to + # decide whether to emit TTY-format output (``\r`` cursor moves, ANSI + # colors, fancy progress bars). With the wrapper in place they always + # emit TTY format, even when our real stdout is a pipe to the parent + # process. Downstream consumers (local terminals and the Home + # Assistant dashboard log viewer) render the TTY control sequences + # correctly, so the user sees real progress bars. + # + # 2. FILTER_PLATFORMIO_LINES is applied inside RedirectText.write() in + # this subprocess, so noisy PlatformIO output is dropped before it + # ever leaves the runner. This replaces the parent-side filtering + # that was lost when we switched from in-process to subprocess — the + # parent's ``subprocess.run`` uses ``.fileno()`` on RedirectText and + # bypasses its ``write()`` path entirely. + # + # Filtering is disabled when the user passed -v / --verbose to + # ``esphome compile``, preserving the previous in-process behavior where + # verbose mode let all PlatformIO output through unfiltered. + from esphome.util import RedirectText + + is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:]) + filter_lines = None if is_verbose else FILTER_PLATFORMIO_LINES + + sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) + sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) + + import platformio.__main__ + + return platformio.__main__.main() or 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/platformio.ini b/platformio.ini index e0f7c7d443a..3897db83e13 100644 --- a/platformio.ini +++ b/platformio.ini @@ -83,7 +83,7 @@ lib_deps = fastled/FastLED@3.9.16 ; fastled_base freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea - tonia/HeatpumpIR@1.0.40 ; heatpumpir + tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = ${common.build_flags} -DUSE_ARDUINO @@ -133,10 +133,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.7/esp32-core-3.3.7.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.3.1/esp-idf-v5.5.3.1.tar.xz + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.8/esp32-core-3.3.8.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -169,16 +169,16 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.3.1/esp-idf-v5.5.3.1.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz framework = espidf lib_deps = ${common:idf.lib_deps} droscy/esp_wireguard@0.4.4 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word - tonia/HeatpumpIR@1.0.40 ; heatpumpir + tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = ${common:idf.build_flags} -Wno-nonnull-compare diff --git a/pyproject.toml b/pyproject.toml index 2e3a247768b..a744286e888 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ classifiers = [ "Topic :: Home Automation", ] -# Python 3.14 is not supported on Windows, see https://github.com/zephyrproject-rtos/windows-curses/issues/76 requires-python = ">=3.11.0,<3.15" dynamic = ["dependencies", "optional-dependencies", "version"] diff --git a/requirements.txt b/requirements.txt index d7db44454cd..cd3aa5bd868 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,14 +12,14 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.13.1 +aioesphomeapi==44.15.0 zeroconf==0.148.0 puremagic==1.30 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.1 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 diff --git a/requirements_test.txt b/requirements_test.txt index eeee3434ce0..18d0461e830 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.9 # also change in .pre-commit-config.yaml when updating +ruff==0.15.10 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 526644842da..73e0859d5eb 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -60,6 +60,10 @@ FILE_HEADER = """// This file was automatically generated with a tool. # Maps enum type name (e.g. ".BluetoothDeviceRequestType") to max enum value. _enum_max_values: dict[str, int] = {} +# Populated by main() before message generation. +# Maps message name (e.g. "BluetoothLERawAdvertisement") to its descriptor. +_message_desc_map: dict[str, Any] = {} + def indent_list(text: str, padding: str = " ") -> list[str]: """Indent each line of the given text with the specified padding.""" @@ -427,6 +431,23 @@ class TypeInfo(ABC): Estimated size in bytes including field ID and typical data """ + def get_max_encoded_size(self) -> int | None: + """Get the maximum possible encoded size in bytes for this field. + + Returns the worst-case encoded size including field ID and maximum + possible value encoding. Returns None if the size is unbounded + (e.g., variable-length strings without max_data_length). + + Used by (inline_encode) validation to ensure sub-messages fit in a + single-byte length varint (< 128 bytes). + """ + return None # Unbounded by default + + +def _varint_max_size(bits: int) -> int: + """Return the maximum varint encoding size for a value with the given number of bits.""" + return (max(bits, 1) + 6) // 7 # ceil(bits / 7), min 1 byte for varint(0) + TYPE_INFO: dict[int, TypeInfo] = {} @@ -514,8 +535,30 @@ def register_type(name: int): return func +class FixedSizeTypeMixin: + """Mixin for types with a known fixed encoded size (float, double, fixed32, fixed64).""" + + def get_max_encoded_size(self) -> int: + return self.calculate_field_id_size() + self.get_fixed_size_bytes() + + +class VarintTypeMixin: + """Mixin for varint types. Subclasses set _varint_max_bits.""" + + _varint_max_bits: int = 64 # Default to worst case + + def get_max_encoded_size(self) -> int: + max_val = self.max_value + if max_val is not None: + return self.calculate_field_id_size() + _varint_max_size( + max_val.bit_length() if max_val > 0 else 1 + ) + return self.calculate_field_id_size() + _varint_max_size(self._varint_max_bits) + + @register_type(1) -class DoubleType(TypeInfo): +class DoubleType(FixedSizeTypeMixin, TypeInfo): + # Unsupported but defined for completeness cpp_type = "double" default_value = "0.0" decode_64bit = "value.as_double()" @@ -541,7 +584,7 @@ class DoubleType(TypeInfo): @register_type(2) -class FloatType(TypeInfo): +class FloatType(FixedSizeTypeMixin, TypeInfo): cpp_type = "float" default_value = "0.0f" decode_32bit = "value.as_float()" @@ -567,8 +610,9 @@ class FloatType(TypeInfo): @register_type(3) -class Int64Type(TypeInfo): +class Int64Type(VarintTypeMixin, TypeInfo): cpp_type = "int64_t" + _varint_max_bits = 64 default_value = "0" decode_varint = "static_cast(value)" encode_func = "encode_int64" @@ -587,8 +631,9 @@ class Int64Type(TypeInfo): @register_type(4) -class UInt64Type(TypeInfo): +class UInt64Type(VarintTypeMixin, TypeInfo): cpp_type = "uint64_t" + _varint_max_bits = 64 default_value = "0" decode_varint = "value" encode_func = "encode_uint64" @@ -607,8 +652,9 @@ class UInt64Type(TypeInfo): @register_type(5) -class Int32Type(TypeInfo): +class Int32Type(VarintTypeMixin, TypeInfo): cpp_type = "int32_t" + _varint_max_bits = 64 # int32 is sign-extended to 64 bits in protobuf default_value = "0" decode_varint = "static_cast(value)" encode_func = "encode_int32" @@ -627,7 +673,7 @@ class Int32Type(TypeInfo): @register_type(6) -class Fixed64Type(TypeInfo): +class Fixed64Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "uint64_t" default_value = "0" decode_64bit = "value.as_fixed64()" @@ -653,7 +699,7 @@ class Fixed64Type(TypeInfo): @register_type(7) -class Fixed32Type(TypeInfo): +class Fixed32Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "uint32_t" default_value = "0" decode_32bit = "value.as_fixed32()" @@ -689,7 +735,8 @@ class Fixed32Type(TypeInfo): @register_type(8) -class BoolType(TypeInfo): +class BoolType(VarintTypeMixin, TypeInfo): + _varint_max_bits = 1 cpp_type = "bool" default_value = "false" decode_varint = "value != 0" @@ -807,6 +854,16 @@ class StringType(TypeInfo): def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string + def get_max_encoded_size(self) -> int | None: + max_len = self.max_data_length + if max_len is not None: + return ( + self.calculate_field_id_size() + + _varint_max_size(max_len.bit_length()) + + max_len + ) + return None # Unbounded + @register_type(11) class MessageType(TypeInfo): @@ -971,7 +1028,8 @@ class BytesType(TypeInfo): ) def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len_);" + calc_fn = "calc_length_force" if force else "calc_length" + return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len_);" def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes @@ -1052,7 +1110,8 @@ class PointerToBytesBufferType(PointerToBufferTypeBase): ) def get_size_calculation(self, name: str, force: bool = False) -> str: - return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len);" + calc_fn = "calc_length_force" if force else "calc_length" + return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len);" class PointerToStringBufferType(PointerToBufferTypeBase): @@ -1122,6 +1181,16 @@ class PointerToStringBufferType(PointerToBufferTypeBase): def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical string + def get_max_encoded_size(self) -> int | None: + max_len = self.max_data_length + if max_len is not None: + return ( + self.calculate_field_id_size() + + _varint_max_size(max_len.bit_length()) + + max_len + ) + return None + class PackedBufferTypeInfo(TypeInfo): """Type for packed repeated fields that expose raw buffer instead of decoding. @@ -1299,14 +1368,23 @@ class FixedArrayBytesType(TypeInfo): self.calculate_field_id_size() + 1 + 31 ) # field ID + length byte + typical 31 bytes + def get_max_encoded_size(self) -> int: + # field_id + varint(array_size) + array_size + return ( + self.calculate_field_id_size() + + _varint_max_size(self.array_size.bit_length()) + + self.array_size + ) + @property def wire_type(self) -> WireType: return WireType.LENGTH_DELIMITED @register_type(13) -class UInt32Type(TypeInfo): +class UInt32Type(VarintTypeMixin, TypeInfo): cpp_type = "uint32_t" + _varint_max_bits = 32 default_value = "0" decode_varint = "value" encode_func = "encode_uint32" @@ -1328,7 +1406,9 @@ class UInt32Type(TypeInfo): @register_type(14) -class EnumType(TypeInfo): +class EnumType(VarintTypeMixin, TypeInfo): + _varint_max_bits = 32 + @property def cpp_type(self) -> str: return f"enums::{self._field.type_name[1:]}" @@ -1379,7 +1459,7 @@ class EnumType(TypeInfo): @register_type(15) -class SFixed32Type(TypeInfo): +class SFixed32Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "int32_t" default_value = "0" decode_32bit = "value.as_sfixed32()" @@ -1405,7 +1485,7 @@ class SFixed32Type(TypeInfo): @register_type(16) -class SFixed64Type(TypeInfo): +class SFixed64Type(FixedSizeTypeMixin, TypeInfo): cpp_type = "int64_t" default_value = "0" decode_64bit = "value.as_sfixed64()" @@ -1431,8 +1511,9 @@ class SFixed64Type(TypeInfo): @register_type(17) -class SInt32Type(TypeInfo): +class SInt32Type(VarintTypeMixin, TypeInfo): cpp_type = "int32_t" + _varint_max_bits = 32 # zigzag encoding keeps it 32-bit default_value = "0" decode_varint = "decode_zigzag32(static_cast(value))" encode_func = "encode_sint32" @@ -1451,8 +1532,9 @@ class SInt32Type(TypeInfo): @register_type(18) -class SInt64Type(TypeInfo): +class SInt64Type(VarintTypeMixin, TypeInfo): cpp_type = "int64_t" + _varint_max_bits = 64 default_value = "0" decode_varint = "decode_zigzag64(value)" encode_func = "encode_sint64" @@ -1500,6 +1582,91 @@ def _generate_array_dump_content( return o +def _is_inline_encode(sub_msg_name: str) -> bool: + """Check if a sub-message type has the (inline_encode) option set.""" + sub_desc = _message_desc_map.get(sub_msg_name) + if not sub_desc: + return False + inline_opt = getattr(pb, "inline_encode", None) + if inline_opt is None: + return False + return get_opt(sub_desc, inline_opt, False) + + +def _generate_inline_encode_block( + field_number: int, sub_msg_name: str, element: str +) -> str: + """Generate inline encode code for a sub-message with (inline_encode) = true. + + Instead of calling encode_sub_message (function pointer indirection), + this inlines the sub-message's field encoding directly. Uses 1-byte + backpatch for the length (validated to be < 128 at generation time). + + Uses a local reference alias 'sub_msg' to avoid issues with this-> replacement + on complex element expressions. + + Args: + field_number: The parent field number for this sub-message + sub_msg_name: The sub-message type name + element: C++ expression for the element (e.g., "it" or "this->field[i]") + """ + sub_desc = _message_desc_map[sub_msg_name] + tag = (field_number << 3) | 2 # wire type 2 = LENGTH_DELIMITED + assert tag < 128, f"inline_encode requires single-byte tag, got {tag}" + + lines = [] + lines.append(f"auto &sub_msg = {element};") + lines.append(f"ProtoEncode::write_raw_byte(pos, {tag});") + lines.append("uint8_t *len_pos = pos;") + lines.append("ProtoEncode::reserve_byte(pos);") + + # Generate inline field encoding for each sub-message field + for field in sub_desc.field: + if field.options.deprecated: + continue + ti = create_field_type_info(field, needs_decode=False, needs_encode=True) + encode_line = ti.encode_content + # Replace this-> with sub_msg reference for the sub-message fields + encode_line = encode_line.replace("this->", "sub_msg.") + lines.extend(wrap_with_ifdef(encode_line, get_field_opt(field, pb.field_ifdef))) + + lines.append("*len_pos = static_cast(pos - len_pos - 1);") + return "\n".join(lines) + + +def _generate_inline_size_block( + field_number: int, sub_msg_name: str, element: str +) -> str: + """Generate inline size calculation for a sub-message with (inline_encode) = true. + + Uses a local reference alias 'sub_msg' to avoid issues with this-> replacement + on complex element expressions like 'this->advertisements[i]'. + + Args: + field_number: The parent field number for this sub-message + sub_msg_name: The sub-message type name + element: C++ expression for the element + """ + sub_desc = _message_desc_map[sub_msg_name] + + lines = [] + lines.append(f"auto &sub_msg = {element};") + # 1 byte tag + 1 byte length (guaranteed < 128 by validation) + lines.append("size += 2;") + + for field in sub_desc.field: + if field.options.deprecated: + continue + ti = create_field_type_info(field, needs_decode=False, needs_encode=True) + force = get_field_opt(field, pb.force, False) + size_line = ti.get_size_calculation(f"sub_msg.{ti.field_name}", force) + # Replace hardcoded this-> references (e.g., FixedArrayBytesType uses this->field_len) + size_line = size_line.replace("this->", "sub_msg.") + lines.extend(wrap_with_ifdef(size_line, get_field_opt(field, pb.field_ifdef))) + + return "\n".join(lines) + + class FixedArrayRepeatedType(TypeInfo): """Special type for fixed-size repeated fields using std::array. @@ -1526,6 +1693,10 @@ class FixedArrayRepeatedType(TypeInfo): return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast({element}), true);" # Repeated message elements use encode_sub_message (force=true is default) if isinstance(self._ti, MessageType): + if _is_inline_encode(self._ti.cpp_type): + return _generate_inline_encode_block( + self.number, self._ti.cpp_type, element + ) return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});" return ( f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);" @@ -1633,8 +1804,19 @@ class FixedArrayRepeatedType(TypeInfo): ] return f"if ({non_zero_checks}) {{\n" + "\n".join(size_lines) + "\n}" + is_inline = isinstance(self._ti, MessageType) and _is_inline_encode( + self._ti.cpp_type + ) + # When using a define, always use loop-based approach if self.is_define: + if is_inline: + o = f"for (const auto &it : {name}) {{\n" + o += indent( + _generate_inline_size_block(self.number, self._ti.cpp_type, "it") + ) + o += "\n}" + return o o = f"for (const auto &it : {name}) {{\n" o += f" {self._ti.get_size_calculation('it', True)}\n" o += "}" @@ -1642,6 +1824,14 @@ class FixedArrayRepeatedType(TypeInfo): # For fixed arrays, we always encode all elements + if is_inline: + o = f"for (const auto &it : {name}) {{\n" + o += indent( + _generate_inline_size_block(self.number, self._ti.cpp_type, "it") + ) + o += "\n}" + return o + # Special case for single-element arrays - no loop needed if self.array_size == 1: return self._ti.get_size_calculation(f"{name}[0]", True) @@ -1714,6 +1904,15 @@ class FixedArrayWithLengthRepeatedType(FixedArrayRepeatedType): def get_size_calculation(self, name: str, force: bool = False) -> str: # Calculate size only for active elements + if isinstance(self._ti, MessageType) and _is_inline_encode(self._ti.cpp_type): + o = f"for (uint16_t i = 0; i < {name}_len; i++) {{\n" + o += indent( + _generate_inline_size_block( + self.number, self._ti.cpp_type, f"{name}[i]" + ) + ) + o += "\n}" + return o o = f"for (uint16_t i = 0; i < {name}_len; i++) {{\n" o += f" {self._ti.get_size_calculation(f'{name}[i]', True)}\n" o += "}" @@ -2222,6 +2421,28 @@ def calculate_message_estimated_size(desc: descriptor.DescriptorProto) -> int: return total_size +def calculate_message_max_size(desc: descriptor.DescriptorProto) -> int | None: + """Calculate the maximum possible encoded size for a message. + + Returns None if any field has unbounded size (e.g., variable-length strings). + Used to validate that (inline_encode) messages fit in a single-byte length varint. + """ + total_size = 0 + + for field in desc.field: + if field.options.deprecated: + continue + + ti = create_field_type_info(field, needs_decode=False, needs_encode=True) + max_size = ti.get_max_encoded_size() + if max_size is None: + return None + + total_size += max_size + + return total_size + + def build_message_type( desc: descriptor.DescriptorProto, base_class_fields: dict[str, list[descriptor.FieldDescriptorProto]], @@ -2451,13 +2672,35 @@ def build_message_type( prot = "void decode(const uint8_t *buffer, size_t length);" public_content.append(prot) + # Check if this message uses inline_encode — if so, skip generating standalone + # encode/calculate_size methods since the encoding is inlined into the parent. + inline_opt = getattr(pb, "inline_encode", None) + is_inline_only = ( + message_id is None # Not a service message (no id) + and inline_opt is not None + and get_opt(desc, inline_opt, False) + ) + + # Check if this message wants speed-optimized encode/calculate_size. + # When set, __attribute__((optimize("O2"))) is added to the definitions + # so GCC inlines the small ProtoEncode helpers even under -Os. + is_speed_optimized = get_opt(desc, pb.speed_optimized, False) + speed_attr = ( + '__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)\n' + if is_speed_optimized + else "" + ) + # Only generate encode method if this message needs encoding and has fields - if needs_encode and encode: + if needs_encode and encode and not is_inline_only: # Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls encode_debug = [ - line.replace("(pos,", "(pos PROTO_ENCODE_DEBUG_ARG,") for line in encode + line.replace("(pos,", "(pos PROTO_ENCODE_DEBUG_ARG,").replace( + "(pos)", "(pos PROTO_ENCODE_DEBUG_ARG)" + ) + for line in encode ] - o = f"uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" + o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n" o += " uint8_t *__restrict__ pos = buffer.get_pos();\n" o += indent("\n".join(encode_debug)) + "\n" o += " return pos;\n" @@ -2470,8 +2713,8 @@ def build_message_type( # If no fields to encode or message doesn't need encoding, the default implementation in ProtoMessage will be used # Add calculate_size method only if this message needs encoding and has fields - if needs_encode and size_calc: - o = f"uint32_t {desc.name}::calculate_size() const {{\n" + if needs_encode and size_calc and not is_inline_only: + o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n" o += " uint32_t size = 0;\n" o += indent("\n".join(size_calc)) + "\n" o += " return size;\n" @@ -2830,6 +3073,32 @@ def main() -> None: if not enum.options.deprecated and enum.value: _enum_max_values[f".{enum.name}"] = max(v.number for v in enum.value) + # Build message descriptor map for inline_encode lookups + mt = file.message_type + _message_desc_map.update({m.name: m for m in mt if not m.options.deprecated}) + + # Validate inline_encode messages fit in single-byte length varint + inline_encode_opt = getattr(pb, "inline_encode", None) + if inline_encode_opt is not None: + for m in mt: + if m.options.deprecated: + continue + if not get_opt(m, inline_encode_opt, False): + continue + max_size = calculate_message_max_size(m) + if max_size is None: + raise ValueError( + f"Message '{m.name}' has (inline_encode) = true but contains " + f"fields with unbounded size. Inline encoding requires all " + f"fields to have bounded maximum size." + ) + if max_size >= 128: + raise ValueError( + f"Message '{m.name}' has (inline_encode) = true but max " + f"encoded size is {max_size} bytes (>= 128). Inline encoding " + f"requires sub-messages that fit in a single-byte length varint." + ) + # Build dynamic ifdef mappings early so we can emit USE_API_VARINT64 before includes enum_ifdef_map, message_ifdef_map, message_source_map, used_messages = ( build_type_usage_map(file) @@ -3048,8 +3317,6 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint content += "\n} // namespace enums\n\n" - mt = file.message_type - # Identify empty SOURCE_CLIENT messages that don't need class generation for m in mt: if m.options.deprecated: diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index 92faa05819a..5080a9fec74 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -26,12 +26,11 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" PLATFORMIO_OPTIONS = { - "build_unflags": [ - "-Os", # remove default size-opt - ], "build_flags": [ - "-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo) + "-Os", # match firmware optimization level (detects inlining regressions) "-g", # debug symbols for profiling + "-ffunction-sections", # required for dead-code stripping with -Os + "-fdata-sections", # required for dead-code stripping with -Os "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() f"-I{STUBS_DIR}", # stub headers for ESP32-only components ], diff --git a/tests/benchmarks/components/api/bench_log_response.cpp b/tests/benchmarks/components/api/bench_log_response.cpp new file mode 100644 index 00000000000..4ef57987beb --- /dev/null +++ b/tests/benchmarks/components/api/bench_log_response.cpp @@ -0,0 +1,118 @@ +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Typical log line: "[12:34:56][D][sensor:094]: 'Temperature': Sending state 23.50000 with 1 decimals of accuracy" +static constexpr const char *kTypicalLogLine = + "[12:34:56][D][sensor:094]: 'Temperature': Sending state 23.50000 with 1 decimals of accuracy"; + +// Short log line: "[12:34:56][I][app:029]: Running..." +static constexpr const char *kShortLogLine = "[12:34:56][I][app:029]: Running..."; + +// --- Encode --- + +static void Encode_LogResponse_Typical(benchmark::State &state) { + APIBuffer buffer; + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_LogResponse_Typical); + +static void Encode_LogResponse_Short(benchmark::State &state) { + APIBuffer buffer; + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_INFO; + msg.set_message(reinterpret_cast(kShortLogLine), strlen(kShortLogLine)); + uint32_t size = msg.calculate_size(); + buffer.resize(size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_LogResponse_Short); + +// --- Calculate Size --- + +static void CalculateSize_LogResponse_Typical(benchmark::State &state) { + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_LogResponse_Typical); + +// --- Calc + Encode (steady state) --- + +static void CalcAndEncode_LogResponse_Typical(benchmark::State &state) { + APIBuffer buffer; + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_LogResponse_Typical); + +// --- Calc + Encode (fresh allocation each time) --- + +static void CalcAndEncode_LogResponse_Typical_Fresh(benchmark::State &state) { + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + benchmark::DoNotOptimize(buffer.data()); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_LogResponse_Typical_Fresh); + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/core/bench_helpers.cpp b/tests/benchmarks/core/bench_helpers.cpp index d9a9d158a3e..1ce9101ff6f 100644 --- a/tests/benchmarks/core/bench_helpers.cpp +++ b/tests/benchmarks/core/bench_helpers.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include "esphome/core/helpers.h" @@ -307,4 +309,58 @@ static void Base64Decode_32Bytes(benchmark::State &state) { } BENCHMARK(Base64Decode_32Bytes); +// --- uint32_to_str() vs snprintf --- + +static void Uint32ToStr_Small(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_to_str(buf, 12345); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Uint32ToStr_Small); + +static void Snprintf_Uint32_Small(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + snprintf(buf, sizeof(buf), "%" PRIu32, static_cast(12345)); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Snprintf_Uint32_Small); + +static void Uint32ToStr_Large(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_to_str(buf, 4294967295u); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Uint32ToStr_Large); + +static void Snprintf_Uint32_Large(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + snprintf(buf, sizeof(buf), "%" PRIu32, static_cast(4294967295u)); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Snprintf_Uint32_Large); + } // namespace esphome::benchmarks diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 0893c7dcbbc..cd91c4d8cb1 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -1,11 +1,18 @@ """Tests for the packages component.""" +import logging from pathlib import Path from unittest.mock import MagicMock, patch import pytest -from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages +from esphome.components.packages import ( + CONFIG_SCHEMA, + _walk_packages, + do_packages_pass, + is_package_definition, + merge_packages, +) from esphome.components.substitutions import do_substitution_pass import esphome.config as config_module from esphome.config import resolve_extend_remove @@ -37,7 +44,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.util import OrderedDict -from esphome.yaml_util import add_context +from esphome.yaml_util import IncludeFile, add_context # Test strings TEST_DEVICE_NAME = "test_device_name" @@ -79,6 +86,44 @@ def packages_pass(config): return config +_INCLUDE_FILE = "INCLUDE_FILE" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # IncludeFile objects are package definitions + (_INCLUDE_FILE, True), + # Git URL shorthand strings are package definitions + ("github://esphome/firmware/base.yaml@main", True), + # Remote package dicts (with url key) are package definitions + ({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True), + # Plain config dicts are NOT package definitions (they are config fragments) + ({"wifi": {"ssid": "test"}}, False), + # None is not a package definition + (None, False), + # Lists are not package definitions + ([{"wifi": {"ssid": "test"}}], False), + # Empty dicts are not package definitions + ({}, False), + ], + ids=[ + "include_file", + "git_shorthand", + "remote_package", + "config_fragment", + "none", + "list", + "empty_dict", + ], +) +def test_is_package_definition(value: object, expected: bool) -> None: + """Test that is_package_definition correctly identifies package definitions.""" + if value is _INCLUDE_FILE: + value = MagicMock(spec=IncludeFile) + assert is_package_definition(value) is expected + + def test_package_unused(basic_esphome, basic_wifi) -> None: """ Ensures do_package_pass does not change a config if packages aren't used. @@ -1061,6 +1106,51 @@ def test_packages_invalid_type_raises() -> None: do_packages_pass(config) +@patch("esphome.components.packages.resolve_include") +def test_packages_include_file_resolves_to_list(mock_resolve_include) -> None: + """When packages: is an IncludeFile that resolves to a list, it is processed correctly.""" + include_file = MagicMock(spec=IncludeFile) + package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + mock_resolve_include.return_value = ([package_content], None) + + config = {CONF_PACKAGES: include_file} + result = do_packages_pass(config) + result = merge_packages(result) + + assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + + +@patch("esphome.components.packages.resolve_include") +def test_packages_include_file_resolves_to_dict(mock_resolve_include) -> None: + """When packages: is an IncludeFile that resolves to a dict, it is processed correctly.""" + include_file = MagicMock(spec=IncludeFile) + package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + mock_resolve_include.return_value = ({"network": package_content}, None) + + config = {CONF_PACKAGES: include_file} + result = do_packages_pass(config) + result = merge_packages(result) + + assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}} + + +@patch("esphome.components.packages.resolve_include") +def test_packages_include_file_resolves_to_invalid_type_raises( + mock_resolve_include, +) -> None: + """When packages: is an IncludeFile that resolves to an invalid type, cv.Invalid is raised.""" + include_file = MagicMock(spec=IncludeFile) + mock_resolve_include.return_value = ("not_a_dict_or_list", None) + + config = {CONF_PACKAGES: include_file} + with pytest.raises( + cv.Invalid, match="Packages must be a key to value mapping or list" + ) as exc_info: + do_packages_pass(config) + + assert exc_info.value.path == [CONF_PACKAGES] + + @pytest.mark.parametrize( "invalid_package", [ @@ -1107,6 +1197,134 @@ def test_invalid_package_contents_masked_by_deprecation( do_packages_pass(config) +def test_named_dict_with_include_files_no_false_deprecation_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Package errors in named dicts must not trigger the deprecated fallback.""" + good_include = MagicMock(spec=IncludeFile) + bad_include = MagicMock(spec=IncludeFile) + + config = { + CONF_PACKAGES: { + "good_pkg": good_include, + "bad_pkg": bad_include, + }, + } + + call_count = 0 + + def failing_callback(package_config: dict, context: object) -> dict: + nonlocal call_count + call_count += 1 + if call_count == 1: + # First package processes fine + return {CONF_WIFI: {CONF_SSID: "test"}} + # Second package has an error (e.g. jinja syntax error) + raise cv.Invalid("simulated jinja error in bad_pkg") + + with ( + caplog.at_level(logging.WARNING), + pytest.raises(cv.Invalid, match="simulated jinja error"), + ): + _walk_packages(config, failing_callback) + + # Must NOT emit the deprecated single-package warning + assert "deprecated" not in caplog.text.lower() + + +def test_validate_deprecated_false_raises_directly( + caplog: pytest.LogCaptureFixture, +) -> None: + """With validate_deprecated=False, errors raise directly without fallback. + + This is the codepath used for remote packages where _process_remote_package + returns already-resolved dicts that is_package_definition cannot detect. + """ + config = { + CONF_PACKAGES: { + "pkg_a": {CONF_WIFI: {CONF_SSID: "test"}}, + "pkg_b": {CONF_WIFI: {CONF_SSID: "test2"}}, + }, + } + + call_count = 0 + + def failing_callback(package_config: dict, context: object) -> dict: + nonlocal call_count + call_count += 1 + if call_count == 1: + return package_config + raise cv.Invalid("nested error") + + with ( + caplog.at_level(logging.WARNING), + pytest.raises(cv.Invalid, match="nested error"), + ): + _walk_packages(config, failing_callback, validate_deprecated=False) + + assert "deprecated" not in caplog.text.lower() + + +def test_error_on_first_declared_package_still_detected() -> None: + """When the first declared package errors, it's the last processed in reverse. + + All other entries are already resolved to dicts, but the failing entry + retains its original IncludeFile value since assignment was skipped. + """ + config = { + CONF_PACKAGES: { + "first_pkg": MagicMock(spec=IncludeFile), + "second_pkg": MagicMock(spec=IncludeFile), + "third_pkg": MagicMock(spec=IncludeFile), + }, + } + + call_count = 0 + + def fail_on_last(package_config: dict, context: object) -> dict: + nonlocal call_count + call_count += 1 + # Reverse iteration: third_pkg (1), second_pkg (2), first_pkg (3) + if call_count < 3: + return {CONF_WIFI: {CONF_SSID: "test"}} + raise cv.Invalid("error in first_pkg") + + with pytest.raises(cv.Invalid, match="error in first_pkg"): + _walk_packages(config, fail_on_last) + + +def test_deprecated_single_package_fallback_still_works( + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecated single-package form still falls back at the top level. + + When a dict's values are plain config fragments (not package definitions) + and the callback fails, the deprecated fallback wraps the dict in a list + and retries with a deprecation warning. + """ + config = { + CONF_PACKAGES: { + CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}, + }, + } + + attempt = 0 + + def fail_then_succeed(package_config: dict, context: object) -> dict: + nonlocal attempt + attempt += 1 + if attempt == 1: + # First attempt: treating as named dict fails + raise cv.Invalid("not a valid package") + # Second attempt: after fallback wraps as list, succeeds + return package_config + + with caplog.at_level(logging.WARNING): + _walk_packages(config, fail_then_succeed) + + assert "deprecated" in caplog.text.lower() + + def test_merge_packages_invalid_nested_type_raises() -> None: """Invalid nested packages type during merge raises cv.Invalid.""" config = { diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 8bddeb74094..e779f7f078b 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -50,6 +50,13 @@ button: - platform: template name: Canbus Actions on_press: + - canbus.send: + can_id: 0x601 + data: [0, 1, 2] + - canbus.send: + can_id: 0x1FFFFFFF + use_extended_id: true + data: [0, 1, 2] - canbus.send: "abc" - canbus.send: [0, 1, 2] - canbus.send: !lambda return {0, 1, 2}; diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 6c0490b6c60..68568982e44 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -3,7 +3,119 @@ #include "esphome/core/helpers.h" -namespace esphome::testing { +namespace esphome::core::testing { + +// --- format_hex_to() --- + +TEST(FormatHexTo, Basic) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF}; + char buffer[7]; // 3 * 2 + 1 + format_hex_to(buffer, data, 3); + EXPECT_STREQ(buffer, "abcdef"); +} + +TEST(FormatHexTo, SingleByte) { + const uint8_t data[] = {0x0F}; + char buffer[3]; + format_hex_to(buffer, data, 1); + EXPECT_STREQ(buffer, "0f"); +} + +TEST(FormatHexTo, ZeroLength) { + char buffer[4] = "xxx"; + format_hex_to(buffer, static_cast(sizeof(buffer)), static_cast(nullptr), 0); + EXPECT_STREQ(buffer, ""); +} + +TEST(FormatHexTo, ZeroBufferSize) { + char buffer[4] = "xxx"; + const uint8_t data[] = {0xAB}; + format_hex_to(buffer, static_cast(0), data, 1); + // Should not crash, buffer unchanged + EXPECT_EQ(buffer[0], 'x'); +} + +TEST(FormatHexTo, BufferTooSmall) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF}; + char buffer[5]; // only room for 2 bytes + format_hex_to(buffer, data, 3); + EXPECT_STREQ(buffer, "abcd"); +} + +TEST(FormatHexTo, MacAddress) { + const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + char buffer[13]; + format_hex_to(buffer, mac, 6); + EXPECT_STREQ(buffer, "aabbccddeeff"); +} + +// --- format_hex_pretty_to() --- + +TEST(FormatHexPrettyTo, BasicColon) { + const uint8_t data[] = {0xAB, 0xCD, 0xEF}; + char buffer[9]; // 3 * 3 + format_hex_pretty_to(buffer, data, 3); + EXPECT_STREQ(buffer, "AB:CD:EF"); +} + +TEST(FormatHexPrettyTo, SingleByte) { + const uint8_t data[] = {0x0F}; + char buffer[3]; + format_hex_pretty_to(buffer, data, 1); + EXPECT_STREQ(buffer, "0F"); +} + +TEST(FormatHexPrettyTo, ZeroLength) { + char buffer[4] = "xxx"; + format_hex_pretty_to(buffer, static_cast(sizeof(buffer)), static_cast(nullptr), 0); + EXPECT_STREQ(buffer, ""); +} + +TEST(FormatHexPrettyTo, ZeroBufferSize) { + char buffer[4] = "xxx"; + const uint8_t data[] = {0xAB}; + format_hex_pretty_to(buffer, static_cast(0), data, 1); + EXPECT_EQ(buffer[0], 'x'); +} + +TEST(FormatHexPrettyTo, CustomSeparator) { + const uint8_t data[] = {0xAA, 0xBB, 0xCC}; + char buffer[9]; + format_hex_pretty_to(buffer, data, 3, '-'); + EXPECT_STREQ(buffer, "AA-BB-CC"); +} + +// --- format_mac_addr_upper() --- + +TEST(FormatMacAddrUpper, Basic) { + const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}; + char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(mac, buffer); + EXPECT_STREQ(buffer, "AA:BB:CC:DD:EE:FF"); +} + +TEST(FormatMacAddrUpper, AllZeros) { + const uint8_t mac[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(mac, buffer); + EXPECT_STREQ(buffer, "00:00:00:00:00:00"); +} + +// --- format_hex_char() --- + +TEST(FormatHexChar, LowercaseDigits) { + EXPECT_EQ(format_hex_char(0), '0'); + EXPECT_EQ(format_hex_char(9), '9'); + EXPECT_EQ(format_hex_char(10), 'a'); + EXPECT_EQ(format_hex_char(15), 'f'); +} + +TEST(FormatHexChar, UppercaseDigits) { + EXPECT_EQ(format_hex_pretty_char(0), '0'); + EXPECT_EQ(format_hex_pretty_char(9), '9'); + EXPECT_EQ(format_hex_pretty_char(10), 'A'); + EXPECT_EQ(format_hex_pretty_char(15), 'F'); +} // --- small_pow10() --- @@ -12,68 +124,6 @@ TEST(SmallPow10, One) { EXPECT_EQ(small_pow10(1), 10u); } TEST(SmallPow10, Two) { EXPECT_EQ(small_pow10(2), 100u); } TEST(SmallPow10, Three) { EXPECT_EQ(small_pow10(3), 1000u); } -// --- uint32_to_str() --- - -TEST(Uint32ToStr, Zero) { - char buf[12]; - char *end = uint32_to_str_(buf, 0); - *end = '\0'; - EXPECT_STREQ(buf, "0"); - EXPECT_EQ(end - buf, 1); -} - -TEST(Uint32ToStr, SingleDigit) { - char buf[12]; - char *end = uint32_to_str_(buf, 7); - *end = '\0'; - EXPECT_STREQ(buf, "7"); -} - -TEST(Uint32ToStr, MultiDigit) { - char buf[12]; - char *end = uint32_to_str_(buf, 12345); - *end = '\0'; - EXPECT_STREQ(buf, "12345"); - EXPECT_EQ(end - buf, 5); -} - -TEST(Uint32ToStr, Large) { - char buf[12]; - char *end = uint32_to_str_(buf, 4294967295u); - *end = '\0'; - EXPECT_STREQ(buf, "4294967295"); - EXPECT_EQ(end - buf, 10); -} - -TEST(Uint32ToStr, PowersOfTen) { - char buf[12]; - char *end; - - end = uint32_to_str_(buf, 10); - *end = '\0'; - EXPECT_STREQ(buf, "10"); - - end = uint32_to_str_(buf, 100); - *end = '\0'; - EXPECT_STREQ(buf, "100"); - - end = uint32_to_str_(buf, 1000); - *end = '\0'; - EXPECT_STREQ(buf, "1000"); -} - -// --- uint32_to_str() (public, template with size check) --- - -TEST(Uint32ToStr, PublicApi) { - char buf[UINT32_MAX_STR_SIZE]; - EXPECT_EQ(uint32_to_str(buf, 0), 1u); - EXPECT_STREQ(buf, "0"); - EXPECT_EQ(uint32_to_str(buf, 12345), 5u); - EXPECT_STREQ(buf, "12345"); - EXPECT_EQ(uint32_to_str(buf, 4294967295u), 10u); - EXPECT_STREQ(buf, "4294967295"); -} - // --- frac_to_str_() --- TEST(FracToStr, OneDigit) { @@ -163,4 +213,4 @@ TEST(BufAppendSepStr, Truncation) { EXPECT_EQ(end - buf, 7); } -} // namespace esphome::testing +} // namespace esphome::core::testing diff --git a/tests/components/core/test_uint32_to_str.cpp b/tests/components/core/test_uint32_to_str.cpp new file mode 100644 index 00000000000..fc754429ecd --- /dev/null +++ b/tests/components/core/test_uint32_to_str.cpp @@ -0,0 +1,77 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +// --- uint32_to_str_unchecked() (internal, raw pointer) --- + +TEST(Uint32ToStr, InternalZero) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 0); + *end = '\0'; + EXPECT_STREQ(buf, "0"); + EXPECT_EQ(end - buf, 1); +} + +TEST(Uint32ToStr, InternalSingleDigit) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 7); + *end = '\0'; + EXPECT_STREQ(buf, "7"); +} + +TEST(Uint32ToStr, InternalMultiDigit) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 12345); + *end = '\0'; + EXPECT_STREQ(buf, "12345"); + EXPECT_EQ(end - buf, 5); +} + +TEST(Uint32ToStr, InternalMaxValue) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 4294967295u); + *end = '\0'; + EXPECT_STREQ(buf, "4294967295"); + EXPECT_EQ(end - buf, 10); +} + +TEST(Uint32ToStr, InternalPowersOfTen) { + char buf[UINT32_MAX_STR_SIZE]; + char *end; + + end = uint32_to_str_unchecked(buf, 10); + *end = '\0'; + EXPECT_STREQ(buf, "10"); + + end = uint32_to_str_unchecked(buf, 100); + *end = '\0'; + EXPECT_STREQ(buf, "100"); + + end = uint32_to_str_unchecked(buf, 1000000); + *end = '\0'; + EXPECT_STREQ(buf, "1000000"); +} + +// --- uint32_to_str() (public, span API) --- + +TEST(Uint32ToStr, SpanZero) { + char buf[UINT32_MAX_STR_SIZE]; + EXPECT_EQ(uint32_to_str(buf, 0), 1u); + EXPECT_STREQ(buf, "0"); +} + +TEST(Uint32ToStr, SpanMultiDigit) { + char buf[UINT32_MAX_STR_SIZE]; + EXPECT_EQ(uint32_to_str(buf, 12345), 5u); + EXPECT_STREQ(buf, "12345"); +} + +TEST(Uint32ToStr, SpanMaxValue) { + char buf[UINT32_MAX_STR_SIZE]; + EXPECT_EQ(uint32_to_str(buf, 4294967295u), 10u); + EXPECT_STREQ(buf, "4294967295"); +} + +} // namespace esphome::core::testing diff --git a/tests/components/globals/common.yaml b/tests/components/globals/common.yaml index 35dca0624f3..6d5721d3be8 100644 --- a/tests/components/globals/common.yaml +++ b/tests/components/globals/common.yaml @@ -4,6 +4,14 @@ esphome: - globals.set: id: glob_int value: "10" + # Set a float global with an integer literal - must emit the correct + # return type so TemplatableFn stores a direct function pointer. + - globals.set: + id: glob_float + value: "102" + - globals.set: + id: glob_float + value: !lambda "return 42;" globals: - id: glob_int diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index f84156c9d82..6328648fe34 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -20,6 +20,7 @@ lvgl: - id: lvgl_0 default_font: space16 displays: sdl0 + rotation: 180 top_layer: - id: lvgl_1 diff --git a/tests/components/mcp23016/common.yaml b/tests/components/mcp23016/common.yaml index e8e3ad9d08a..81f38b3f52e 100644 --- a/tests/components/mcp23016/common.yaml +++ b/tests/components/mcp23016/common.yaml @@ -1,6 +1,10 @@ mcp23016: - i2c_id: i2c_bus - id: mcp23016_hub + - i2c_id: i2c_bus + id: mcp23016_hub + - i2c_id: i2c_bus + id: mcp23016_hub_int + address: 0x21 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/mcp23016/test.esp32-idf.yaml b/tests/components/mcp23016/test.esp32-idf.yaml index b47e39c3898..8c3b341dce0 100644 --- a/tests/components/mcp23016/test.esp32-idf.yaml +++ b/tests/components/mcp23016/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/mcp23016/test.esp8266-ard.yaml b/tests/components/mcp23016/test.esp8266-ard.yaml index 4a98b9388ab..69b243bfd80 100644 --- a/tests/components/mcp23016/test.esp8266-ard.yaml +++ b/tests/components/mcp23016/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/mcp23016/test.rp2040-ard.yaml b/tests/components/mcp23016/test.rp2040-ard.yaml index 319a7c71a65..b8ad1e47925 100644 --- a/tests/components/mcp23016/test.rp2040-ard.yaml +++ b/tests/components/mcp23016/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/pca6416a/common.yaml b/tests/components/pca6416a/common.yaml index 9ad6e2fb158..09083c6c156 100644 --- a/tests/components/pca6416a/common.yaml +++ b/tests/components/pca6416a/common.yaml @@ -2,6 +2,10 @@ pca6416a: - id: pca6416a_hub i2c_id: i2c_bus address: 0x21 + - id: pca6416a_hub_int + i2c_id: i2c_bus + address: 0x22 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/pca6416a/test.esp32-idf.yaml b/tests/components/pca6416a/test.esp32-idf.yaml index b47e39c3898..8c3b341dce0 100644 --- a/tests/components/pca6416a/test.esp32-idf.yaml +++ b/tests/components/pca6416a/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/pca6416a/test.esp8266-ard.yaml b/tests/components/pca6416a/test.esp8266-ard.yaml index 4a98b9388ab..69b243bfd80 100644 --- a/tests/components/pca6416a/test.esp8266-ard.yaml +++ b/tests/components/pca6416a/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/pca6416a/test.rp2040-ard.yaml b/tests/components/pca6416a/test.rp2040-ard.yaml index 319a7c71a65..b8ad1e47925 100644 --- a/tests/components/pca6416a/test.rp2040-ard.yaml +++ b/tests/components/pca6416a/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/tca9555/common.yaml b/tests/components/tca9555/common.yaml index 82b4c959d81..d1a68c575a8 100644 --- a/tests/components/tca9555/common.yaml +++ b/tests/components/tca9555/common.yaml @@ -2,6 +2,10 @@ tca9555: - id: tca9555_hub i2c_id: i2c_bus address: 0x21 + - id: tca9555_hub_int + i2c_id: i2c_bus + address: 0x22 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/tca9555/test.esp32-idf.yaml b/tests/components/tca9555/test.esp32-idf.yaml index b47e39c3898..8c3b341dce0 100644 --- a/tests/components/tca9555/test.esp32-idf.yaml +++ b/tests/components/tca9555/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/tca9555/test.esp8266-ard.yaml b/tests/components/tca9555/test.esp8266-ard.yaml index 4a98b9388ab..69b243bfd80 100644 --- a/tests/components/tca9555/test.esp8266-ard.yaml +++ b/tests/components/tca9555/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/tca9555/test.rp2040-ard.yaml b/tests/components/tca9555/test.rp2040-ard.yaml index 319a7c71a65..b8ad1e47925 100644 --- a/tests/components/tca9555/test.rp2040-ard.yaml +++ b/tests/components/tca9555/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/integration/fixtures/status_flags.yaml b/tests/integration/fixtures/status_flags.yaml new file mode 100644 index 00000000000..cb118dcc84c --- /dev/null +++ b/tests/integration/fixtures/status_flags.yaml @@ -0,0 +1,141 @@ +esphome: + name: status-flags-test + +host: +api: + actions: + # Warning flag services for sensor_a + - action: set_warning_a + then: + - lambda: "id(sensor_a)->status_set_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_warning_a + then: + - lambda: "id(sensor_a)->status_clear_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Warning flag services for sensor_b + - action: set_warning_b + then: + - lambda: "id(sensor_b)->status_set_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_warning_b + then: + - lambda: "id(sensor_b)->status_clear_warning();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Error flag services for sensor_a + - action: set_error_a + then: + - lambda: "id(sensor_a)->status_set_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_error_a + then: + - lambda: "id(sensor_a)->status_clear_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Error flag services for sensor_b + - action: set_error_b + then: + - lambda: "id(sensor_b)->status_set_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + - action: clear_error_b + then: + - lambda: "id(sensor_b)->status_clear_error();" + - component.update: app_warning_bit + - component.update: app_error_bit + + # Snapshot of the status_led_light's output state for observation. + - action: snapshot_led + then: + - component.update: status_led_writes + - component.update: status_led_last_state + +logger: + +# Tracks each write to the fake status_led output. +globals: + - id: status_led_write_count + type: uint32_t + restore_value: no + initial_value: "0" + - id: status_led_last_write + type: bool + restore_value: no + initial_value: "false" + +# Fake binary output — status_led_light writes to this instead of a pin. +# Every write bumps a counter and records the last value, both of which +# are exposed below so the test can verify status_led_light's loop is +# actually reading App.get_app_state() and responding. +output: + - platform: template + id: fake_status_led + type: binary + write_action: + - globals.set: + id: status_led_write_count + value: !lambda "return id(status_led_write_count) + 1;" + - globals.set: + id: status_led_last_write + value: !lambda "return state;" + +# Actual status_led_light component under test. +light: + - platform: status_led + name: Status LED + id: status_led_light_id + output: fake_status_led + +sensor: + # Two components that the test will toggle warning/error flags on. + - platform: template + name: Sensor A + id: sensor_a + update_interval: 24h + lambda: return 1.0; + - platform: template + name: Sensor B + id: sensor_b + update_interval: 24h + lambda: return 2.0; + + # Expose App.app_state_'s STATUS_LED_WARNING / STATUS_LED_ERROR bits + # as 0.0 / 1.0. force_update ensures every manual component.update + # publishes even if the value is unchanged. + - platform: template + name: App Warning Bit + id: app_warning_bit + update_interval: 24h + force_update: true + lambda: |- + return (App.get_app_state() & STATUS_LED_WARNING) != 0 ? 1.0 : 0.0; + - platform: template + name: App Error Bit + id: app_error_bit + update_interval: 24h + force_update: true + lambda: |- + return (App.get_app_state() & STATUS_LED_ERROR) != 0 ? 1.0 : 0.0; + + # Observables for the fake status_led output. + - platform: template + name: Status LED Writes + id: status_led_writes + update_interval: 24h + force_update: true + lambda: return id(status_led_write_count); + - platform: template + name: Status LED Last State + id: status_led_last_state + update_interval: 24h + force_update: true + lambda: |- + return id(status_led_last_write) ? 1.0 : 0.0; diff --git a/tests/integration/test_status_flags.py b/tests/integration/test_status_flags.py new file mode 100644 index 00000000000..ffbc7c7f634 --- /dev/null +++ b/tests/integration/test_status_flags.py @@ -0,0 +1,209 @@ +"""Integration tests for Component::status_set/clear_warning/error propagation. + +Verifies that toggling STATUS_LED_WARNING / STATUS_LED_ERROR on individual +components correctly updates the app-wide bits on Application::app_state_, +AND that the status_led_light component actually responds to those bits +by writing to its output (the full chain from component.status_set_warning +→ App.app_state_ → status_led_light.loop() reading get_app_state()). + +Exercises the multi-component OR semantics (the app bit stays set while +any component still has the flag, and only clears when the last component +clears its bit), the independence of warning and error, and the actual +status_led_light read of the bits via a fake template output that counts +writes. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Time to let the host-mode main loop run so status_led_light.loop() can +# execute enough iterations to produce measurable write-count changes on +# the fake template output. 300 ms is well above the minimum needed. +STATUS_LED_SETTLE_S = 0.3 + + +@pytest.mark.asyncio +async def test_status_flags( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + async with run_compiled(yaml_config), api_client_connected() as client: + entities, services = await client.list_entities_services() + + # Map every custom API service by name for the test to execute. + svc = {s.name: s for s in services} + for name in ( + "set_warning_a", + "clear_warning_a", + "set_warning_b", + "clear_warning_b", + "set_error_a", + "clear_error_a", + "set_error_b", + "clear_error_b", + "snapshot_led", + ): + assert name in svc, f"service {name} not registered" + + # Track every sensor we care about. SensorTracker gives us + # expect(value) / expect_any() futures that resolve when a + # matching state arrives; much simpler than manual bookkeeping. + tracker = SensorTracker( + [ + "app_warning_bit", + "app_error_bit", + "status_led_writes", + "status_led_last_state", + ] + ) + tracker.key_to_sensor.update( + build_key_to_entity_mapping(entities, list(tracker.sensor_states.keys())) + ) + + # Swallow initial state broadcasts so the test only reacts to + # state changes triggered by our service calls. + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(tracker.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + async def call(name: str) -> None: + await client.execute_service(svc[name], {}) + + async def call_and_expect_bits( + service_name: str, *, warning: float, error: float + ) -> None: + """Execute a service and wait for both app bit sensors to match. + + Each bit-toggling service calls component.update on both + app_warning_bit and app_error_bit, so both sensors publish. + """ + futures = tracker.expect_all( + {"app_warning_bit": warning, "app_error_bit": error} + ) + await call(service_name) + await tracker.await_all(futures) + + async def snapshot_led_writes() -> int: + """Trigger a publish of the fake status_led output counter and return it.""" + future = tracker.expect_any("status_led_writes") + await call("snapshot_led") + await tracker.await_change(future, "status_led_writes") + return int(tracker.sensor_states["status_led_writes"][-1]) + + # ---- Baseline: everything clean ---- + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # ================================================================ + # Part 1 — STATUS_LED_WARNING propagation to App.app_state_ + # ================================================================ + + # Single component set/clear + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # Multi-component OR: both set, clear A, bit stays (B still has it), clear B, gone + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_b", warning=0.0, error=0.0) + + # Opposite clear order + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_b", warning=1.0, error=0.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # ================================================================ + # Part 2 — STATUS_LED_ERROR propagation (same scenarios) + # ================================================================ + + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) + + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("set_error_b", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0) + + # ================================================================ + # Part 3 — warning and error are independent + # ================================================================ + + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await call_and_expect_bits("set_error_b", warning=1.0, error=1.0) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=1.0) + await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0) + + # ================================================================ + # Part 4 — status_led_light actually reads App.app_state_ + # ================================================================ + # The fake status_led_light output increments status_led_write_count + # on every write. status_led_light::loop() writes its output on every + # iteration while an error/warning bit is set, so after holding a + # warning for ~300 ms we should see the counter move significantly. + # This is the end-to-end proof that the bits we set above actually + # reach status_led_light and drive its behavior. + + count_before_warning = await snapshot_led_writes() + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + # Let status_led_light's loop run long enough to toggle the pin + # several times (it reads get_app_state() every main loop iteration). + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_warning = await snapshot_led_writes() + assert count_after_warning > count_before_warning, ( + "status_led_light did not respond to STATUS_LED_WARNING being set: " + f"write count stayed at {count_before_warning} → {count_after_warning}. " + "The full chain Component::status_set_warning → App.app_state_ → " + "status_led_light::loop reading get_app_state() is broken." + ) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) + + # Same check for ERROR + count_before_error = await snapshot_led_writes() + await call_and_expect_bits("set_error_a", warning=0.0, error=1.0) + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_error = await snapshot_led_writes() + assert count_after_error > count_before_error, ( + "status_led_light did not respond to STATUS_LED_ERROR being set: " + f"write count stayed at {count_before_error} → {count_after_error}. " + ) + await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0) + + # ---- Set → clear → re-set round-trip ---- + # After clearing, status_led_light stops writing (steady state). + # Re-setting the flag must make it resume. This guards against a + # future idle optimization (e.g. #15642) where status_led disables + # its own loop when idle: if the re-enable path were broken, the + # second set would not produce writes. + # + # Snapshot AFTER the clear to avoid counting writes that were still + # in-flight from the error-set phase. + count_after_clear = await snapshot_led_writes() + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_idle = await snapshot_led_writes() + assert count_after_idle - count_after_clear <= 5, ( + "status_led_light kept writing after warning/error was cleared: " + f"count grew from {count_after_clear} to {count_after_idle}. " + "Expected it to stop writing once all status bits were clear." + ) + # Re-set warning — writes must resume. + await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0) + await asyncio.sleep(STATUS_LED_SETTLE_S) + count_after_reset = await snapshot_led_writes() + assert count_after_reset > count_after_idle + 5, ( + "status_led_light did not resume writing after re-setting " + f"STATUS_LED_WARNING: count went from {count_after_idle} to " + f"{count_after_reset}. If an idle optimization disabled the " + "loop, the re-enable path may be broken." + ) + await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 1a1bfffd03d..dfd4305c4d4 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -84,9 +84,9 @@ def mock_decode_pc() -> Generator[Mock, None, None]: @pytest.fixture -def mock_run_external_command() -> Generator[Mock, None, None]: - """Mock run_external_command for platformio_api.""" - with patch("esphome.platformio_api.run_external_command") as mock: +def mock_run_external_process() -> Generator[Mock, None, None]: + """Mock run_external_process for platformio_api.""" + with patch("esphome.platformio_api.run_external_process") as mock: yield mock diff --git a/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.approved.yaml b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.approved.yaml new file mode 100644 index 00000000000..7863def1906 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.approved.yaml @@ -0,0 +1,3 @@ +wifi: + password: pkg_password + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.input.yaml b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.input.yaml new file mode 100644 index 00000000000..7a3b4970db6 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/13-packages_as_included_list.input.yaml @@ -0,0 +1,4 @@ +packages: !include 13-packages_list.yaml + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/13-packages_list.yaml b/tests/unit_tests/fixtures/substitutions/13-packages_list.yaml new file mode 100644 index 00000000000..23161db3d3c --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/13-packages_list.yaml @@ -0,0 +1,2 @@ +- wifi: + password: pkg_password diff --git a/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.approved.yaml b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.approved.yaml new file mode 100644 index 00000000000..7863def1906 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.approved.yaml @@ -0,0 +1,3 @@ +wifi: + password: pkg_password + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.input.yaml b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.input.yaml new file mode 100644 index 00000000000..8b9fc5ec3aa --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/14-packages_as_included_dict.input.yaml @@ -0,0 +1,4 @@ +packages: !include 14-packages_dict.yaml + +wifi: + ssid: main_ssid diff --git a/tests/unit_tests/fixtures/substitutions/14-packages_dict.yaml b/tests/unit_tests/fixtures/substitutions/14-packages_dict.yaml new file mode 100644 index 00000000000..55e8b38a435 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/14-packages_dict.yaml @@ -0,0 +1,3 @@ +network: + wifi: + password: pkg_password diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 85536d2f1ce..e07b4accf23 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1231,6 +1231,48 @@ def test_upload_using_esptool_path_conversion( assert partitions_path.endswith("partitions.bin") +def test_upload_using_esptool_skips_missing_extra_flash_images( + tmp_path: Path, + mock_run_external_command_main: Mock, + mock_get_idedata: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A non-existent path in extra_flash_images must be filtered out with a + warning, and must not appear in the esptool command line. Only the valid + images are flashed. Regression test for + https://github.com/esphome/esphome/issues/15634. + """ + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test") + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32} + + missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin" + + mock_idedata = MagicMock(spec=platformio_api.IDEData) + mock_idedata.firmware_bin_path = tmp_path / "firmware.bin" + mock_idedata.extra_flash_images = [ + platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"), + platformio_api.FlashImage(path=missing_path, offset="0x2d0000"), + ] + mock_get_idedata.return_value = mock_idedata + + (tmp_path / "firmware.bin").touch() + (tmp_path / "bootloader.bin").touch() + # Intentionally do NOT create missing_path + + config = {CONF_ESPHOME: {"platformio_options": {}}} + + with caplog.at_level(logging.WARNING, logger="esphome.__main__"): + result = upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + assert result == 0 + assert "Skipping missing flash image" in caplog.text + assert str(missing_path) in caplog.text + + cmd_list = list(mock_run_external_command_main.call_args[0][1:]) + assert str(missing_path) not in cmd_list + assert "0x2d0000" not in cmd_list + + def test_upload_using_esptool_with_file_path( tmp_path: Path, mock_run_external_command_main: Mock, diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index e1b3908c249..67e64e5f612 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -1,7 +1,8 @@ """Tests for platformio_api.py path functions.""" +# pylint: disable=protected-access + import json -import logging import os from pathlib import Path import shutil @@ -10,7 +11,7 @@ from unittest.mock import MagicMock, Mock, call, patch import pytest -from esphome import platformio_api +from esphome import platformio_api, platformio_runner from esphome.core import CORE, EsphomeError @@ -281,13 +282,13 @@ def test_run_idedata_raises_on_invalid_json( def test_run_platformio_cli_sets_environment_variables( - setup_core: Path, mock_run_external_command: Mock + setup_core: Path, mock_run_external_process: Mock ) -> None: """Test run_platformio_cli sets correct environment variables.""" CORE.build_path = str(setup_core / "build" / "test") with patch.dict(os.environ, {}, clear=False): - mock_run_external_command.return_value = 0 + mock_run_external_process.return_value = 0 platformio_api.run_platformio_cli("test", "arg") # Check environment variables were set @@ -300,10 +301,12 @@ def test_run_platformio_cli_sets_environment_variables( assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ - # Check command was called correctly - mock_run_external_command.assert_called_once() - args = mock_run_external_command.call_args[0] - assert "platformio" in args + # Check command was called correctly — runs PlatformIO as a subprocess + # via the esphome.platformio_runner entry point. + mock_run_external_process.assert_called_once() + args = mock_run_external_process.call_args[0] + assert "-m" in args + assert "esphome.platformio_runner" in args assert "test" in args assert "arg" in args @@ -444,7 +447,7 @@ def test_patch_structhash(setup_core: Path) -> None: }, ): # Call patch_structhash - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Verify both modules had clean_build_dir patched # Check that clean_build_dir was set on both modules @@ -496,7 +499,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -546,7 +549,7 @@ def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -594,7 +597,7 @@ def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -719,7 +722,7 @@ def test_patch_file_downloader_succeeds_first_try() -> None: ), }, ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -758,7 +761,7 @@ def test_patch_file_downloader_retries_on_failure() -> None: ), patch("time.sleep") as mock_sleep, ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -799,7 +802,7 @@ def test_patch_file_downloader_raises_after_max_retries() -> None: ), patch("time.sleep") as mock_sleep, ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -847,7 +850,7 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() -> ), patch("time.sleep"), ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -882,9 +885,9 @@ def test_patch_file_downloader_idempotent() -> None: }, ): # Patch multiple times - platformio_api.patch_file_downloader() - platformio_api.patch_file_downloader() - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() + platformio_runner.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -895,19 +898,18 @@ def test_patch_file_downloader_idempotent() -> None: assert call_count == 1 -def test_platformio_log_filter_allows_non_platformio_messages() -> None: - """Test that non-platformio logger messages are allowed through.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name="esphome.core", - level=logging.INFO, - pathname="", - lineno=0, - msg="Some esphome message", - args=(), - exc_info=None, +def _filter_through_redirect(line: str) -> str: + """Write a line through RedirectText with FILTER_PLATFORMIO_LINES and return what passes.""" + import io + + from esphome.util import RedirectText + + captured = io.StringIO() + redirect = RedirectText( + captured, filter_lines=platformio_runner.FILTER_PLATFORMIO_LINES ) - assert log_filter.filter(record) is True + redirect.write(line + "\n") + return captured.getvalue() @pytest.mark.parametrize( @@ -930,19 +932,9 @@ def test_platformio_log_filter_allows_non_platformio_messages() -> None: "Memory Usage -> https://bit.ly/pio-memory-usage", ], ) -def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None: - """Test that noisy platformio messages are filtered out.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name="platformio.builder", - level=logging.INFO, - pathname="", - lineno=0, - msg=msg, - args=(), - exc_info=None, - ) - assert log_filter.filter(record) is False +def test_filter_platformio_lines_blocks_noisy_messages(msg: str) -> None: + """Test that noisy platformio output lines are filtered out by RedirectText.""" + assert _filter_through_redirect(msg) == "" @pytest.mark.parametrize( @@ -954,39 +946,6 @@ def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None: "warning: unused variable", ], ) -def test_platformio_log_filter_allows_other_platformio_messages(msg: str) -> None: - """Test that non-noisy platformio messages are allowed through.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name="platformio.builder", - level=logging.INFO, - pathname="", - lineno=0, - msg=msg, - args=(), - exc_info=None, - ) - assert log_filter.filter(record) is True - - -@pytest.mark.parametrize( - "logger_name", - [ - "PLATFORMIO.builder", - "PlatformIO.core", - "platformio.run", - ], -) -def test_platformio_log_filter_case_insensitive_logger_name(logger_name: str) -> None: - """Test that platformio logger name matching is case insensitive.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name=logger_name, - level=logging.INFO, - pathname="", - lineno=0, - msg="Found 5 compatible libraries", - args=(), - exc_info=None, - ) - assert log_filter.filter(record) is False +def test_filter_platformio_lines_allows_other_messages(msg: str) -> None: + """Test that non-noisy platformio output lines pass through RedirectText.""" + assert _filter_through_redirect(msg) == msg + "\n"