diff --git a/.clang-tidy.hash b/.clang-tidy.hash deleted file mode 100644 index 6f6339ff84..0000000000 --- a/.clang-tidy.hash +++ /dev/null @@ -1 +0,0 @@ -442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451 diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 2081264b91..494c0cebe8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -15,11 +15,6 @@ inputs: description: "Version to build" required: true example: "2023.12.0" - base_os: - description: "Base OS to use" - required: false - default: "debian" - example: "debian" runs: using: "composite" steps: @@ -60,7 +55,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true @@ -86,7 +80,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml index 7a17c222a3..f566ba4c43 100644 --- a/.github/actions/cache-esp-idf/action.yml +++ b/.github/actions/cache-esp-idf/action.yml @@ -2,8 +2,8 @@ name: Cache ESP-IDF description: > Resolve the pinned ESP-IDF version and cache the native ESP-IDF install (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF - natively (clang-tidy for IDF/Arduino and the native-IDF component build) - shares one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS + natively (clang-tidy for IDF/Arduino and the component test batches) shares + one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS defaults to "all", so all toolchains are present regardless of the chip). Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the Python venv already restored. @@ -11,6 +11,12 @@ inputs: framework: description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".' default: espidf + restore-only: + description: > + When "true", only restore -- never save the cache, even on dev. Use from + jobs that may not produce an ESP-IDF install (e.g. a component batch with + no esp32 target), so a partial/empty install is never written to the key. + default: "false" runs: using: composite steps: @@ -33,13 +39,13 @@ runs: # PRs), and PRs are restore-only -- they never push multi-GB artifacts into # their own scope / the repo quota (e.g. on a version-bump PR). - name: Cache ESP-IDF install (write on dev) - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} - name: Cache ESP-IDF install (restore-only off dev) - if: github.ref != 'refs/heads/dev' + if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index e48d6f69bd..d034227ef6 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,7 +24,7 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index c6e9a358ab..2155b67b25 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml deleted file mode 100644 index 73c437467b..0000000000 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Clang-tidy Hash CI - -on: - pull_request: - paths: - - ".clang-tidy" - - "platformio.ini" - - "requirements_dev.txt" - - "sdkconfig.defaults" - - ".clang-tidy.hash" - - "script/clang_tidy_hash.py" - - ".github/workflows/ci-clang-tidy-hash.yml" - -permissions: - contents: read # actions/checkout for the PR head - pull-requests: write # pulls.createReview / listReviews / dismissReview when the clang-tidy hash is out of date - -jobs: - verify-hash: - name: Verify clang-tidy hash - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.11" - - - name: Verify hash - run: | - python script/clang_tidy_hash.py --verify - - - if: failure() - name: Show hash details - run: | - python script/clang_tidy_hash.py - echo "## Job Failed" | tee -a $GITHUB_STEP_SUMMARY - echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY - echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY - - - if: failure() && github.event.pull_request.head.repo.full_name == github.repository - name: Request changes - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - await github.rest.pulls.createReview({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - event: 'REQUEST_CHANGES', - body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.' - }) - - - if: success() && github.event.pull_request.head.repo.full_name == github.repository - name: Dismiss review - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - let reviews = await github.rest.pulls.listReviews({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo - }); - for (let review of reviews.data) { - if (review.user.login === 'github-actions[bot]' && review.state === 'CHANGES_REQUESTED') { - await github.rest.pulls.dismissReview({ - pull_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - review_id: review.id, - message: 'Clang-tidy hash now matches configuration.' - }); - } - } diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 2a40675f3b..8301f8e9e3 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -1,28 +1,41 @@ --- name: CI for docker images -# Only run when docker paths change +# Only run on PRs that touch the docker image, its build inputs, or any code +# whose toolchain the compile smoke test exercises (core + target platforms). on: - push: - branches: [dev, beta, release] - paths: - - "docker/**" - - ".github/workflows/ci-docker.yml" - - "requirements*.txt" - - "platformio.ini" - - "script/platformio_install_deps.py" - pull_request: paths: + # Docker image and its build inputs. - "docker/**" - ".github/workflows/ci-docker.yml" - "requirements*.txt" + - "pyproject.toml" - "platformio.ini" + - "esphome/idf_component.yml" - "script/platformio_install_deps.py" + # Core, build pipeline, toolchain, and target-platform changes can change + # how a toolchain is set up or built, so re-run the per-toolchain compile + # smoke test when they change. + - "esphome/core/**" + - "esphome/writer.py" + - "esphome/build_gen/**" + - "esphome/espidf/**" + - "esphome/platformio/**" + - "esphome/components/bk72xx/**" + - "esphome/components/esp32/**" + - "esphome/components/esp8266/**" + - "esphome/components/host/**" + - "esphome/components/libretiny/**" + - "esphome/components/ln882x/**" + - "esphome/components/nrf52/**" + - "esphome/components/rp2040/**" + - "esphome/components/rtl87xx/**" + - "esphome/components/zephyr/**" permissions: - contents: read # actions/checkout only; the build does not push images + contents: read # actions/checkout only concurrency: # yamllint disable-line rule:line-length @@ -33,6 +46,9 @@ jobs: check-docker: name: Build docker containers runs-on: ${{ matrix.os }} + permissions: + contents: read # actions/checkout to load Dockerfile and build context + packages: write # push branch-tagged images to ghcr.io for local testing strategy: fail-fast: false matrix: @@ -41,8 +57,11 @@ jobs: - "ha-addon" - "docker" # - "lint" + outputs: + tag: ${{ steps.tag.outputs.tag }} + push: ${{ steps.tag.outputs.push }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -50,14 +69,149 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - name: Set TAG + - name: Determine tag and whether to push + id: tag run: | - echo "TAG=check" >> $GITHUB_ENV + # Sanitize the branch name into a valid docker tag: replace invalid + # characters, ensure the first character is valid (tags must start + # with [A-Za-z0-9_]), and cap the length at 128 characters. + branch="${{ github.head_ref || github.ref_name }}" + tag="${branch//[^a-zA-Z0-9_.-]/-}" + case "$tag" in + [a-zA-Z0-9_]*) ;; + *) tag="pr-${tag}" ;; + esac + tag="${tag:0:128}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + # Only push branch images for same-repo pull requests. Push events + # only fire for dev/beta/release, whose images are owned by the + # release pipeline -- never overwrite those from here. + if [ "${{ github.event_name }}" = "pull_request" ] \ + && [ "${{ github.repository }}" = "esphome/esphome" ] \ + && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then + echo "push=true" >> "$GITHUB_OUTPUT" + else + echo "push=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the GitHub container registry + if: steps.tag.outputs.push == 'true' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Run build run: | docker/build.py \ - --tag "${TAG}" \ + --tag "${{ steps.tag.outputs.tag }}" \ --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ - build + --registry ghcr \ + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} ${{ (matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker') && '--load' || '' }} + + # The amd64 "docker" image is also loaded locally (above) and handed to + # compile-test as an artifact, so the smoke test reuses this build instead + # of building the image a second time. Using an artifact (rather than the + # pushed image) keeps it working for fork PRs, which never push to ghcr.io. + - name: Export image for compile-test + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz + + - name: Upload compile-test image artifact + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # The tar is already gzipped, so upload it as-is. archive: false skips + # the redundant zip and makes the file name the artifact name (the + # `name` input is ignored in that mode). + path: compile-test-image.tar.gz + retention-days: 1 + archive: false + + manifest: + name: Push ${{ matrix.build_type }} manifest to ghcr.io + needs: [check-docker] + if: needs.check-docker.outputs.push == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to run docker/build.py + packages: write # buildx imagetools writes the multi-arch tag to ghcr.io + strategy: + fail-fast: false + matrix: + build_type: + - "ha-addon" + - "docker" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to the GitHub container registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + docker/build.py \ + --tag "${{ needs.check-docker.outputs.tag }}" \ + --build-type "${{ matrix.build_type }}" \ + --registry ghcr \ + manifest + + # Smoke-test the built image by compiling one minimal config per target + # platform / toolchain. This catches missing system dependencies in the image + # that only surface when a given toolchain is downloaded and run. The image is + # the amd64 "docker" build produced by check-docker (shared as an artifact). + compile-test: + name: Compile ${{ matrix.id }} + needs: check-docker + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to load the test configs + strategy: + fail-fast: false + # Cap concurrency so this smoke test doesn't hog all the shared runners. + max-parallel: 2 + matrix: + # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) + # share a toolchain bundle, so esp32 is exercised on the base variant + # across the full framework x toolchain cross-product (arduino/esp-idf + # framework, each built with the platformio and native esp-idf + # toolchains) so both toolchains stay covered regardless of which one is + # the default. + id: + - esp8266-arduino + - esp32-arduino-platformio + - esp32-arduino-esp-idf + - esp32-idf-platformio + - esp32-idf-esp-idf + - rp2040-arduino + - bk72xx-arduino + - rtl87xx-arduino + - ln882x-arduino + - nrf52 + - host + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Download image artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: compile-test-image.tar.gz + - name: Load image + run: docker load --input compile-test-image.tar.gz + - name: Compile ${{ matrix.id }} + run: | + docker run --rm \ + -v "${{ github.workspace }}/docker/test_configs:/config" \ + "ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \ + compile "${{ matrix.id }}.yaml" diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml index 43d530128c..3313ced690 100644 --- a/.github/workflows/ci-github-scripts.yml +++ b/.github/workflows/ci-github-scripts.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Run tests working-directory: .github/scripts/auto-label-pr diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 35cfce65f8..4bef082aab 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a57be34e9b..10ace8c179 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -74,7 +74,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -97,7 +97,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -124,7 +124,7 @@ jobs: if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -152,11 +152,11 @@ jobs: if: needs.determine-jobs.outputs.device-builder == 'true' steps: - name: Check out esphome (this PR) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: esphome - name: Check out esphome/device-builder - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: esphome/device-builder ref: main @@ -225,7 +225,7 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -270,8 +270,8 @@ jobs: python-linters: ${{ steps.determine.outputs.python-linters }} import-time: ${{ steps.determine.outputs.import-time }} device-builder: ${{ steps.determine.outputs.device-builder }} - native-idf: ${{ steps.determine.outputs.native-idf }} - native-idf-components: ${{ steps.determine.outputs.native-idf-components }} + esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} + esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -285,7 +285,7 @@ jobs: benchmarks: ${{ steps.determine.outputs.benchmarks }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -324,8 +324,8 @@ jobs: echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT - echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT - echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT + echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT + echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -357,7 +357,7 @@ jobs: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python 3.13 id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -409,7 +409,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -438,7 +438,7 @@ jobs: (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -456,7 +456,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 + uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6 with: run: | . venv/bin/activate @@ -496,7 +496,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -522,7 +522,6 @@ jobs: key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install - # Shared with the IDF tidy + native-IDF build jobs (same install). if: matrix.cache_idf uses: ./.github/actions/cache-esp-idf with: @@ -537,15 +536,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -583,7 +579,7 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -595,7 +591,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the Arduino tidy + native-IDF build jobs (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -607,15 +602,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -667,7 +659,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -679,7 +671,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the Arduino tidy + native-IDF build jobs (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -691,15 +682,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -755,7 +743,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -767,7 +755,6 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - # Shared with the IDF/Arduino clang-tidy jobs + native-IDF build (same install). uses: ./.github/actions/cache-esp-idf - name: Register problem matchers @@ -779,15 +766,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - # determine-jobs.clang-tidy-full-scan is true when core C++ changed - # OR the ci-run-all label forced --force-all. Independent of the - # hash check, both must produce a full scan in the job itself. + # determine-jobs.clang-tidy-full-scan is true when core C++ or a + # clang-tidy-relevant config file changed, or the ci-run-all label + # forced --force-all. if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then echo "full_scan=true" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT - elif python script/clang_tidy_hash.py --check; then - echo "full_scan=true" >> $GITHUB_OUTPUT - echo "reason=hash_changed" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -817,6 +801,10 @@ jobs: - common - determine-jobs if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 + env: + # esp32 component builds use the native ESP-IDF toolchain (default), so + # share the tidy jobs' install location -- the restore below lands here. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} @@ -838,12 +826,18 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache ESP-IDF install (restore-only) + # A batch may contain no esp32 build, so never save -- just reuse the + # shared install the dev tidy jobs already cached when present. + uses: ./.github/actions/cache-esp-idf + with: + restore-only: true - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -947,23 +941,22 @@ jobs: echo "All components in this batch are validate-only -- skipping compile stage." fi - test-native-idf: - name: Test components with native ESP-IDF + test-esp32-platformio: + name: Test esp32 components with PlatformIO runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true' + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp32-platformio == 'true' env: - ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf - # Comma-joined subset of the native-IDF representative component list, - # computed by script/determine-jobs.py (native_idf_components_to_test). + # Comma-joined subset of the esp32 PlatformIO representative component list, + # computed by script/determine-jobs.py (esp32_platformio_components_to_test). # Single source of truth -- the full list lives in - # script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS. - TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }} + # script/determine-jobs.py::ESP32_PLATFORMIO_TEST_COMPONENTS. + TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -971,66 +964,23 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Prepare build storage on /mnt - # Bind-mount the larger /mnt disk over the IDF install + build dirs BEFORE - # restoring the cache, so the ~4.5GB restore lands on the roomier volume - # instead of being shadowed by a mount set up later in the run step. - run: | - root_avail=$(df -k / | awk 'NR==2 {print $4}') - mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}') - echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB" - if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then - echo "Using /mnt for build files (more space available)" - sudo mkdir -p /mnt/esphome-idf - sudo chown $USER:$USER /mnt/esphome-idf - mkdir -p ~/.esphome-idf - sudo mount --bind /mnt/esphome-idf ~/.esphome-idf - sudo mkdir -p /mnt/test_build_components_build - sudo chown $USER:$USER /mnt/test_build_components_build - mkdir -p tests/test_build_components/build - sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build - else - echo "Using / for build files (more space available than /mnt or /mnt unavailable)" - fi - - - name: Cache ESP-IDF install - # Shared with the IDF/Arduino clang-tidy jobs (same install); restores - # into the /mnt bind-mount prepared above when present. - uses: ./.github/actions/cache-esp-idf - - - name: Run native ESP-IDF compile test + - name: Run PlatformIO compile test run: | . venv/bin/activate echo "Testing components: $TEST_COMPONENTS" echo "" - # Show disk space before validation - echo "Disk space before config validation:" - df -h - echo "" - - # Run config validation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf + # compile validates config first, so a separate config pass is + # redundant for this smoke test. ESP-IDF framework via PlatformIO: + python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio echo "" - echo "Config validation passed! Starting compilation..." + echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..." echo "" - # Show disk space before compilation - echo "Disk space before compilation:" - df -h - echo "" - - # Run compilation (auto-grouped by test_build_components.py) - python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf - - - name: Save ESPHome cache - if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} + # Arduino framework via PlatformIO (only components with an esp32-ard test are built): + python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio pre-commit-ci-lite: name: pre-commit.ci lite @@ -1041,7 +991,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1049,7 +999,7 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache env: - SKIP: pylint,clang-tidy-hash,ci-custom + SKIP: pylint,ci-custom - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() @@ -1067,7 +1017,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.base_ref }} @@ -1249,7 +1199,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1318,7 +1268,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1365,7 +1315,7 @@ jobs: - determine-jobs - device-builder - test-build-components-split - - test-native-idf + - test-esp32-platformio - pre-commit-ci-lite - memory-impact-target-branch - memory-impact-pr-branch @@ -1381,4 +1331,7 @@ jobs: # 1. The target branch has a build issue independent of this PR # 2. This PR fixes a build issue on the target branch # In either case, we only care that the PR branch builds successfully. - echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result != "failure")' + # Every other job must have succeeded or been skipped; a "cancelled" or + # "failure" result fails this check so CI is not reported green when the + # workflow was cancelled. + echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result == "success" or .result == "skipped")' diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 1bd60fd11d..9b1333734e 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 5ad0b02de1..da9c5f63d6 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e559472b60..5a448c4003 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 0e2efb1bcf..2bb6505b74 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8efc395951..3056d9e7d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read # actions/checkout to build the sdist/wheel id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index ab1ce2b587..05036f3500 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -28,10 +28,10 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Checkout Home Assistant - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: home-assistant/core path: lib/home-assistant diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b6278e6b5..ba74aff07c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ ci: autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit # Skip hooks that have issues in pre-commit CI environment - skip: [pylint, clang-tidy-hash] + skip: [pylint] repos: - repo: https://github.com/astral-sh/ruff-pre-commit @@ -59,13 +59,6 @@ repos: language: system types: [python] files: ^esphome/.+\.py$ - - id: clang-tidy-hash - name: Update clang-tidy hash - entry: python script/clang_tidy_hash.py --update-if-changed - language: python - files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt|sdkconfig\.defaults|esphome/idf_component\.yml)$ - pass_filenames: false - additional_dependencies: [] - id: ci-custom name: ci-custom entry: python script/run-in-env.py script/ci-custom.py diff --git a/AGENTS.md b/AGENTS.md index 4adc53cae9..4346ffbdae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,19 @@ This document provides essential context for AI models interacting with this pro - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations +* **Python Idioms:** + * **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead: + ```python + # Bad - looks up CONF_BLAH twice + if CONF_BLAH in config: + cg.add(var.set_blah(config[CONF_BLAH])) + + # Good - single lookup, value bound inline + if (blah := config.get(CONF_BLAH)) is not None: + cg.add(var.set_blah(blah)) + ``` + The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line. + * **C++ Field Visibility:** * **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`. * **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants: diff --git a/CODEOWNERS b/CODEOWNERS index 10128c64e5..d425614582 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -445,7 +445,7 @@ esphome/components/select/* @esphome/core esphome/components/sen0321/* @notjj esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras -esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct +esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt @@ -561,6 +561,7 @@ esphome/components/uart/packet_transport/* @clydebarrow esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli +esphome/components/ufm01/* @ljungqvist esphome/components/ultrasonic/* @ssieb @swoboda1337 esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon diff --git a/Doxyfile b/Doxyfile index 3537516996..9f4e20b977 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.6.0-dev +PROJECT_NUMBER = 2026.7.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 0000000000..a4640467c9 --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,104 @@ +# ESPHome Threat Model + +This document defines the trust boundary for the **ESPHome** repository — the +Python compiler/CLI and the device firmware it generates — so that real security +bugs can be told apart from defense-in-depth improvements. It gives contributors, +reviewers, and security researchers a clear answer to one question: +**does this issue let an _unauthenticated_ attacker do something they shouldn't?** + +Related documents: + +- Deployment guidance for operators: + https://esphome.io/guides/security_best_practices/ +- The **Device Builder dashboard** (the web UI, its authentication, ingress, + Origin/Host gates, and peer-link pairing) lives in a separate repository and + has its own threat model. If your report concerns any of that, please read and + report there instead: + https://github.com/esphome/device-builder/blob/main/docs/THREAT_MODEL.md + +## The trust boundary + +For this repository there are two trusted inputs by design: + +1. **The configuration.** Anyone who can supply or edit a YAML config is trusted + (see below). +2. **Authenticated peers of a running device** — clients holding the device's + API encryption key / password, OTA password, or web server credentials. + +The security boundary is therefore **unauthenticated network traffic vs. those +trusted inputs.** A bug that lets an unauthenticated attacker cross it is a +security bug. + +## Config authors are host-equivalent by design + +Anyone who can supply or edit a configuration is **trusted with full code +execution on the host that runs `esphome`**, on purpose. This is what the product +does, not a flaw. A config author can already, through fully supported features: + +- Run arbitrary **Python** at validation/compile time via `external_components:` + (and other component-import mechanisms) — ESPHome imports those packages as + ordinary Python. +- Run arbitrary **shell** commands through the compile/validate/flash toolchain + that ESPHome invokes as subprocesses. +- Read and write arbitrary files reachable by the process (e.g. via `!include`, + `packages:`, `dashboard_import:`, and generated build output). + +Because of this, a malicious config author is equivalent to shell access on the +host running the build. + +## What is *not* a security vulnerability + +If exploiting an issue requires the ability to supply or edit configuration, it +is **not** a vulnerability in ESPHome, because that ability already grants host +code execution. This explicitly includes, among others: + +- Template / expression injection in substitutions or any YAML string value + (e.g. Jinja `${...}` evaluation reaching Python internals). This grants no + capability a config author lacks. +- `!include` / `packages:` / `dashboard_import:` reading or fetching content + from surprising or remote locations. +- The validator or compiler crashing or behaving unexpectedly on adversarial + YAML. +- ESPHome running as root in the official container — that is the documented + deployment posture, reachable by the same caller through the features above. + +These do not warrant a CVE or coordinated disclosure. Hardening in these areas +(for example, sandboxing template evaluation as least-surprise defense-in-depth) +is welcome as a normal enhancement PR, framed as cleanliness rather than a +security fix — not as a vulnerability remediation. + +## What we do defend + +These *are* security bugs in this repo, and we want to hear about them privately: + +- Memory-safety or protocol bugs in the generated **device firmware** that are + remotely triggerable over the network (native API, web server, OTA, BLE, + captive portal, etc.) **without** valid credentials. +- Authentication or encryption bypass on the device — reaching API calls, OTA + updates, or the web server without the configured key/password. +- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth + below their documented guarantees. + +## Explicitly out of scope + +- Local attackers who already have shell access on the host that runs `esphome`. +- Supply-chain attacks against ESPHome or its dependencies. +- Operator-supplied hostile YAML (covered above — config authoring is trusted). +- Attacks that require an already-authenticated device peer (someone who already + holds the API key / OTA / web credentials). +- Anything in the dashboard / device-builder — report that in its own repository + (linked at the top). +- The legacy bundled dashboard in this repo (`esphome/dashboard/`) — it is + deprecated and being replaced by Device Builder; report dashboard issues there. +- Deployments where the operator removed protections or exposed credentials. See + the security best practices guide: + https://esphome.io/guides/security_best_practices/ + +## Reporting a vulnerability + +If you believe you've found an issue that crosses the unauthenticated boundary +above, please report it privately via GitHub Security Advisories rather than a +public issue. For issues that require config-write access, please review this +document first — they are very likely out of scope by design. For dashboard / +device-builder issues, report against that repository and consult its threat +model (linked at the top). diff --git a/docker/Dockerfile b/docker/Dockerfile index 25de9472b6..1d39644ab8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,9 @@ ARG BUILD_VERSION=dev -ARG BUILD_OS=alpine -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.0 ARG BUILD_TYPE=docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon +FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker +FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon ARG BUILD_TYPE FROM base-source-${BUILD_TYPE} AS base @@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \ # validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without # it idf_tools.py rejects the openocd install with exit 127 and aborts # the whole framework setup. -RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base libusb; \ - else \ - apt-get update \ - && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ - && rm -rf /var/lib/apt/lists/*; \ - fi +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \ + && rm -rf /var/lib/apt/lists/* ENV PIP_DISABLE_PIP_VERSION_CHECK=1 @@ -36,6 +31,9 @@ RUN \ uv pip install --no-cache-dir \ -r /requirements.txt +# Install the ESPHome Device Builder dashboard. +RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12 + RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ diff --git a/docker/build.py b/docker/build.py index 4d093cf88d..475986e905 100755 --- a/docker/build.py +++ b/docker/build.py @@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon" TYPE_LINT = "lint" TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] +REGISTRY_GHCR = "ghcr" +REGISTRY_DOCKERHUB = "dockerhub" +REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB] + parser = argparse.ArgumentParser() parser.add_argument( @@ -34,6 +38,12 @@ parser.add_argument( parser.add_argument( "--build-type", choices=TYPES, required=True, help="The type of build to run" ) +parser.add_argument( + "--registry", + choices=REGISTRIES, + action="append", + help="Restrict to specific registries (default: all). May be passed multiple times.", +) parser.add_argument( "--dry-run", action="store_true", help="Don't run any commands, just print them" ) @@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t build_parser.add_argument( "--load", help="Load the docker image locally", action="store_true" ) +build_parser.add_argument( + "--no-cache-to", + help="Don't write the build cache (avoids polluting the shared cache)", + action="store_true", +) manifest_parser = subparsers.add_parser( "manifest", help="Create a manifest from already pushed images" ) @@ -95,11 +110,14 @@ def main(): print("Command failed") sys.exit(1) + registries = args.registry or REGISTRIES + # detect channel from tag match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) major_minor_version = None if match is None: - channel = CHANNEL_DEV + # Custom tag (e.g. a branch name) -- push only the tag itself + channel = None elif match.group(2) is None: major_minor_version = match.group(1) channel = CHANNEL_RELEASE @@ -128,11 +146,18 @@ def main(): CHANNEL_DEV: "cache-dev", CHANNEL_BETA: "cache-beta", CHANNEL_RELEASE: "cache-latest", - }[channel] - cache_img = f"ghcr.io/{params.build_to}:{cache_tag}" + }.get(channel, "cache-dev") + # Cache images live alongside the pushed images; prefer GHCR when it is + # one of the selected registries, otherwise fall back to Docker Hub so a + # registry-restricted build doesn't need GHCR auth. + cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else "" + cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}" - imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push] - imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] + imgs = [] + if REGISTRY_DOCKERHUB in registries: + imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] # 3. build cmd = [ @@ -155,7 +180,9 @@ def main(): for img in imgs: cmd += ["--tag", img] if args.push: - cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"] + cmd += ["--push"] + if not args.no_cache_to: + cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"] if args.load: cmd += ["--load"] @@ -163,20 +190,22 @@ def main(): elif args.command == "manifest": manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to - targets = [f"{manifest}:{tag}" for tag in tags_to_push] - targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] - # 1. Create manifests + targets = [] + if REGISTRY_DOCKERHUB in registries: + targets += [f"{manifest}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] + # Use buildx imagetools (not `docker manifest`) so the per-arch sources, + # which buildx pushes as single-platform manifest lists, are combined + # and pushed correctly in one step. for target in targets: - cmd = ["docker", "manifest", "create", target] + cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] for arch in ARCHS: src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" if target.startswith("ghcr.io"): src = f"ghcr.io/{src}" cmd.append(src) run_command(*cmd) - # 2. Push manifests - for target in targets: - run_command("docker", "manifest", "push", target) if __name__ == "__main__": diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 1b9224244c..18baf40c29 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -27,4 +27,12 @@ if [[ -d /build ]]; then export ESPHOME_BUILD_PATH=/build fi +# The default CMD is "dashboard /config". Route the dashboard to the new +# Device Builder, but pass every other subcommand (compile, run, config, +# logs, ...) straight through to the esphome CLI so direct CLI use keeps working. +if [[ "$1" == "dashboard" ]]; then + shift + exec esphome-device-builder "$@" +fi + exec esphome "$@" diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh deleted file mode 100755 index b990469762..0000000000 --- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/with-contenv bashio -# ============================================================================== -# Installs the latest prerelease of esphome-device-builder when the -# `use_new_device_builder` config option is enabled. -# This is a temporary install-on-boot step until esphome-device-builder -# becomes a direct dependency of esphome. -# ============================================================================== - -if ! bashio::config.true 'use_new_device_builder'; then - exit 0 -fi - -bashio::log.info "Installing latest prerelease of esphome-device-builder..." -if command -v uv > /dev/null; then - uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ - esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -else - pip install --no-cache-dir --pre --upgrade esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -fi -bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types deleted file mode 100644 index 7c7cdef2d1..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types +++ /dev/null @@ -1,96 +0,0 @@ -types { - text/html html htm shtml; - text/css css; - text/xml xml; - image/gif gif; - image/jpeg jpeg jpg; - application/javascript js; - application/atom+xml atom; - application/rss+xml rss; - - text/mathml mml; - text/plain txt; - text/vnd.sun.j2me.app-descriptor jad; - text/vnd.wap.wml wml; - text/x-component htc; - - image/png png; - image/svg+xml svg svgz; - image/tiff tif tiff; - image/vnd.wap.wbmp wbmp; - image/webp webp; - image/x-icon ico; - image/x-jng jng; - image/x-ms-bmp bmp; - - font/woff woff; - font/woff2 woff2; - - application/java-archive jar war ear; - application/json json; - application/mac-binhex40 hqx; - application/msword doc; - application/pdf pdf; - application/postscript ps eps ai; - application/rtf rtf; - application/vnd.apple.mpegurl m3u8; - application/vnd.google-earth.kml+xml kml; - application/vnd.google-earth.kmz kmz; - application/vnd.ms-excel xls; - application/vnd.ms-fontobject eot; - application/vnd.ms-powerpoint ppt; - application/vnd.oasis.opendocument.graphics odg; - application/vnd.oasis.opendocument.presentation odp; - application/vnd.oasis.opendocument.spreadsheet ods; - application/vnd.oasis.opendocument.text odt; - application/vnd.openxmlformats-officedocument.presentationml.presentation - pptx; - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - xlsx; - application/vnd.openxmlformats-officedocument.wordprocessingml.document - docx; - application/vnd.wap.wmlc wmlc; - application/x-7z-compressed 7z; - application/x-cocoa cco; - application/x-java-archive-diff jardiff; - application/x-java-jnlp-file jnlp; - application/x-makeself run; - application/x-perl pl pm; - application/x-pilot prc pdb; - application/x-rar-compressed rar; - application/x-redhat-package-manager rpm; - application/x-sea sea; - application/x-shockwave-flash swf; - application/x-stuffit sit; - application/x-tcl tcl tk; - application/x-x509-ca-cert der pem crt; - application/x-xpinstall xpi; - application/xhtml+xml xhtml; - application/xspf+xml xspf; - application/zip zip; - - application/octet-stream bin exe dll; - application/octet-stream deb; - application/octet-stream dmg; - application/octet-stream iso img; - application/octet-stream msi msp msm; - - audio/midi mid midi kar; - audio/mpeg mp3; - audio/ogg ogg; - audio/x-m4a m4a; - audio/x-realaudio ra; - - video/3gpp 3gpp 3gp; - video/mp2t ts; - video/mp4 mp4; - video/mpeg mpeg mpg; - video/quicktime mov; - video/webm webm; - video/x-flv flv; - video/x-m4v m4v; - video/x-mng mng; - video/x-ms-asf asx asf; - video/x-ms-wmv wmv; - video/x-msvideo avi; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf deleted file mode 100644 index a1ebb5079a..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf +++ /dev/null @@ -1,16 +0,0 @@ -proxy_http_version 1.1; -proxy_ignore_client_abort off; -proxy_read_timeout 86400s; -proxy_redirect off; -proxy_send_timeout 86400s; -proxy_max_temp_file_size 0; - -proxy_set_header Accept-Encoding ""; -proxy_set_header Connection $connection_upgrade; -proxy_set_header Host $http_host; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-NginX-Proxy true; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header Authorization ""; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf deleted file mode 100644 index debdf83a8c..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -root /dev/null; -server_name $hostname; - -client_max_body_size 512m; - -add_header X-Content-Type-Options nosniff; -add_header X-XSS-Protection "1; mode=block"; -add_header X-Robots-Tag none; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf deleted file mode 100644 index e6789cbb9b..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -ssl_protocols TLSv1.2 TLSv1.3; -ssl_prefer_server_ciphers off; -ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; -ssl_session_timeout 10m; -ssl_session_cache shared:SSL:10m; -ssl_session_tickets off; -ssl_stapling on; -ssl_stapling_verify on; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf deleted file mode 100644 index 8e782bdc88..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf +++ /dev/null @@ -1,3 +0,0 @@ -upstream esphome { - server unix:/var/run/esphome.sock; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf deleted file mode 100644 index 497427596d..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -daemon off; -user root; -pid /var/run/nginx.pid; -worker_processes 1; -error_log /proc/1/fd/1 error; -events { - worker_connections 1024; -} - -http { - include /etc/nginx/includes/mime.types; - - access_log off; - default_type application/octet-stream; - gzip on; - keepalive_timeout 65; - sendfile on; - server_tokens off; - - tcp_nodelay on; - tcp_nopush on; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - include /etc/nginx/includes/upstream.conf; - include /etc/nginx/servers/*.conf; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep deleted file mode 100644 index 85ad51be5f..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley) diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl deleted file mode 100644 index 4fb0ca3f90..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl +++ /dev/null @@ -1,28 +0,0 @@ -server { - {{ if not .ssl }} - listen 6052 default_server; - {{ else }} - listen 6052 default_server ssl http2; - {{ end }} - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - {{ if .ssl }} - include /etc/nginx/includes/ssl_params.conf; - - ssl_certificate /ssl/{{ .certfile }}; - ssl_certificate_key /ssl/{{ .keyfile }}; - - # Redirect http requests to https on the same port. - # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/ - error_page 497 https://$http_host$request_uri; - {{ end }} - - # Clear Home Assistant Ingress header - proxy_set_header X-HA-Ingress ""; - - location / { - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl deleted file mode 100644 index 105ddde710..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 127.0.0.1:{{ .port }} default_server; - listen {{ .interface }}:{{ .port }} default_server; - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - # Set Home Assistant Ingress header - proxy_set_header X-HA-Ingress "YES"; - - location / { - allow 172.30.32.2; - allow 127.0.0.1; - deny all; - - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/dependencies.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index 111157d301..bb36cfcdb4 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -16,7 +16,7 @@ fi port=$(bashio::addon.ingress_port) -# Wait for NGINX to become available +# Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 config=$(\ diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish index 6e0f8fe23a..da450c25f9 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish @@ -2,7 +2,7 @@ # shellcheck shell=bash # ============================================================================== # Home Assistant Community Add-on: ESPHome -# Take down the S6 supervision tree when ESPHome dashboard fails +# Take down the S6 supervision tree when ESPHome Device Builder fails # ============================================================================== declare exit_code readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode - fi - [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt -elif [[ "${exit_code_service}" -ne 0 ]]; then - if [[ "${exit_code_container}" -eq 0 ]]; then - echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode - fi - exec /run/s6/basedir/bin/halt -fi diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run deleted file mode 100755 index b8251e8e01..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ /dev/null @@ -1,27 +0,0 @@ -#!/command/with-contenv bashio -# shellcheck shell=bash -# ============================================================================== -# Community Hass.io Add-ons: ESPHome -# Runs the NGINX proxy -# ============================================================================== - -# The new device builder handles HA ingress itself, so nginx is bypassed. -# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on -# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish -# stamp the container exit 143, which trips the Supervisor's SIGTERM check. -if bashio::config.true 'use_new_device_builder'; then - bashio::log.info "NGINX bypassed: new device builder serves ingress directly." - trap 'exit 0' TERM - sleep infinity & - wait - exit 0 -fi - -bashio::log.info "Waiting for ESPHome dashboard to come up..." - -while [[ ! -S /var/run/esphome.sock ]]; do - sleep 0.5 -done - -bashio::log.info "Starting NGINX..." -exec nginx diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type deleted file mode 100644 index 5883cff0cd..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/test_configs/bk72xx-arduino.yaml b/docker/test_configs/bk72xx-arduino.yaml new file mode 100644 index 0000000000..138aa9e282 --- /dev/null +++ b/docker/test_configs/bk72xx-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-bk72xx-arduino + +bk72xx: + board: generic-bk7231n-qfn32-tuya + +logger: diff --git a/docker/test_configs/esp32-arduino-esp-idf.yaml b/docker/test_configs/esp32-arduino-esp-idf.yaml new file mode 100644 index 0000000000..fbc68aff0c --- /dev/null +++ b/docker/test_configs/esp32-arduino-esp-idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-ard-idf + +esp32: + variant: esp32 + framework: + type: arduino + toolchain: esp-idf + +logger: diff --git a/docker/test_configs/esp32-arduino-platformio.yaml b/docker/test_configs/esp32-arduino-platformio.yaml new file mode 100644 index 0000000000..e216c02059 --- /dev/null +++ b/docker/test_configs/esp32-arduino-platformio.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-ard-pio + +esp32: + variant: esp32 + framework: + type: arduino + toolchain: platformio + +logger: diff --git a/docker/test_configs/esp32-idf-esp-idf.yaml b/docker/test_configs/esp32-idf-esp-idf.yaml new file mode 100644 index 0000000000..b180aa9c0a --- /dev/null +++ b/docker/test_configs/esp32-idf-esp-idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-idf-idf + +esp32: + variant: esp32 + framework: + type: esp-idf + toolchain: esp-idf + +logger: diff --git a/docker/test_configs/esp32-idf-platformio.yaml b/docker/test_configs/esp32-idf-platformio.yaml new file mode 100644 index 0000000000..5aec23e40d --- /dev/null +++ b/docker/test_configs/esp32-idf-platformio.yaml @@ -0,0 +1,10 @@ +esphome: + name: docker-test-esp32-idf-pio + +esp32: + variant: esp32 + framework: + type: esp-idf + toolchain: platformio + +logger: diff --git a/docker/test_configs/esp8266-arduino.yaml b/docker/test_configs/esp8266-arduino.yaml new file mode 100644 index 0000000000..80b52260e4 --- /dev/null +++ b/docker/test_configs/esp8266-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-esp8266-arduino + +esp8266: + board: d1_mini + +logger: diff --git a/docker/test_configs/host.yaml b/docker/test_configs/host.yaml new file mode 100644 index 0000000000..9f99069304 --- /dev/null +++ b/docker/test_configs/host.yaml @@ -0,0 +1,6 @@ +esphome: + name: docker-test-host + +host: + +logger: diff --git a/docker/test_configs/ln882x-arduino.yaml b/docker/test_configs/ln882x-arduino.yaml new file mode 100644 index 0000000000..4cff3a4883 --- /dev/null +++ b/docker/test_configs/ln882x-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-ln882x-arduino + +ln882x: + board: generic-ln882hki + +logger: diff --git a/docker/test_configs/nrf52.yaml b/docker/test_configs/nrf52.yaml new file mode 100644 index 0000000000..d6337149cc --- /dev/null +++ b/docker/test_configs/nrf52.yaml @@ -0,0 +1,8 @@ +esphome: + name: docker-test-nrf52 + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: diff --git a/docker/test_configs/rp2040-arduino.yaml b/docker/test_configs/rp2040-arduino.yaml new file mode 100644 index 0000000000..4b5df11d87 --- /dev/null +++ b/docker/test_configs/rp2040-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-rp2040-arduino + +rp2040: + variant: rp2040 + +logger: diff --git a/docker/test_configs/rtl87xx-arduino.yaml b/docker/test_configs/rtl87xx-arduino.yaml new file mode 100644 index 0000000000..e8d9cf7503 --- /dev/null +++ b/docker/test_configs/rtl87xx-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-rtl87xx-arduino + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: diff --git a/esphome/__main__.py b/esphome/__main__.py index f7d3f8e834..bda3dcbd05 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -504,6 +504,12 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True + # The dashboard pre-resolves the device and passes the IPs via + # --mdns-address-cache/--dns-address-cache; honor a cached address even when the + # device has mDNS disabled (e.g. a .local host found via ping). + if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address): + return True + if has_mdns(): return True @@ -1765,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json + if CORE.using_toolchain_esp_idf: + # Native ESP-IDF derives idedata from the build's compile_commands.json, + # so the configuration must already be compiled. + from esphome.espidf import toolchain as espidf_toolchain + + idedata = espidf_toolchain.get_idedata() + if idedata is None: + _LOGGER.error( + "No idedata available; compile the configuration first", + ) + return 1 + + print(json.dumps(idedata, indent=2) + "\n") + return 0 + if not CORE.using_toolchain_platformio: _LOGGER.error( "The idedata command is not compatible with %s toolchain", diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 9cc7a7ff12..dec6ea04de 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,8 +6,20 @@ from pathlib import Path from esphome.components.esp32 import get_esp32_variant, idf_version import esphome.config_validation as cv from esphome.core import CORE +from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags from esphome.helpers import mkdir_p, write_file_if_changed +# Replaces the IDF default C++ standard (-std=gnu++2b appended to +# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via +# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(), +# i.e. after IDF appends its default and before the options are consumed, and +# applies project-wide like PlatformIO build_unflags. +CPP_STANDARD_TEMPLATE = """\ +idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS) +list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=") +list(APPEND esphome_cxx_compile_options "-std={standard}") +idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")""" + def get_available_components() -> list[str] | None: """Get list of built-in ESP-IDF components from project_description.json. @@ -73,17 +85,18 @@ def get_project_cmakelists(minimal: bool = False) -> str: # esphome__micro-mp3) rather than just src/. Required so suppressions # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in # third-party components we don't author. - project_compile_opts = [ - flag - for flag in sorted(CORE.build_flags) - if flag.startswith("-D") - or (flag.startswith("-W") and not flag.startswith("-Wl,")) - ] + project_compile_opts = get_project_compile_flags() extra_compile_options = "\n".join( f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)' for flag in project_compile_opts ) + cpp_standard_options = ( + CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard) + if CORE.cpp_standard + else "" + ) + # Per-project list exposed as a CMake variable so converted PIO libs # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking # project-specific names into their cached CMakeLists. @@ -140,6 +153,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{cpp_standard_options} + {extra_compile_options} {managed_components_property} @@ -169,8 +184,8 @@ def get_component_cmakelists() -> str: # Extract linker options (-Wl, flags). Compile flags (-D, -W) are # emitted project-wide via idf_build_set_property in # get_project_cmakelists so they reach every component, not just src/. - link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] - link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else "" + link_opts = get_project_link_flags() + link_opts_str = "\n ".join(link_opts) if link_opts else "" return f"""\ # Auto-generated by ESPHome @@ -200,9 +215,6 @@ idf_component_register( REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} ) -# Apply C++ standard -target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20) - # ESPHome linker options target_link_options(${{COMPONENT_LIB}} PUBLIC {link_opts_str} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 16c1597ccd..a583279ea7 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -33,12 +33,27 @@ def format_ini(data: dict[str, str | list[str]]) -> str: return content +# All -std= variants a platform/framework may set by default, in both the GNU +# and strict dialects; unflagged so the cg.set_cpp_standard() value is the +# only standard left in the build. +CPP_STD_VARIANTS = [ + f"{prefix}{year}" + for year in ("11", "14", "17", "20", "23", "26", "2a", "2b", "2c") + for prefix in ("gnu++", "c++") +] + + def get_ini_content(): CORE.add_platformio_option( "lib_deps", [x.as_lib_dep for x in CORE.platformio_libraries.values()] + ["${common.lib_deps}"], ) + if CORE.cpp_standard: + for variant in CPP_STD_VARIANTS: + if variant != CORE.cpp_standard: + CORE.add_build_unflag(f"-std={variant}") + CORE.add_build_flag(f"-std={CORE.cpp_standard}") # Sort to avoid changing build flags order CORE.add_platformio_option("build_flags", sorted(CORE.build_flags)) diff --git a/esphome/components/a01nyub/a01nyub.h b/esphome/components/a01nyub/a01nyub.h index 5c0d20bd37..69636eb8e4 100644 --- a/esphome/components/a01nyub/a01nyub.h +++ b/esphome/components/a01nyub/a01nyub.h @@ -8,7 +8,7 @@ namespace esphome::a01nyub { -class A01nyubComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class A01nyubComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/a02yyuw/a02yyuw.h b/esphome/components/a02yyuw/a02yyuw.h index 693bcfd03c..2e71651301 100644 --- a/esphome/components/a02yyuw/a02yyuw.h +++ b/esphome/components/a02yyuw/a02yyuw.h @@ -8,7 +8,7 @@ namespace esphome::a02yyuw { -class A02yyuwComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class A02yyuwComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/a4988/a4988.h b/esphome/components/a4988/a4988.h index 04040241c0..f50b5926c1 100644 --- a/esphome/components/a4988/a4988.h +++ b/esphome/components/a4988/a4988.h @@ -6,7 +6,7 @@ namespace esphome::a4988 { -class A4988 : public stepper::Stepper, public Component { +class A4988 final : public stepper::Stepper, public Component { public: void set_step_pin(GPIOPin *step_pin) { step_pin_ = step_pin; } void set_dir_pin(GPIOPin *dir_pin) { dir_pin_ = dir_pin; } diff --git a/esphome/components/absolute_humidity/absolute_humidity.h b/esphome/components/absolute_humidity/absolute_humidity.h index be28d3dc50..9989bb17fc 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.h +++ b/esphome/components/absolute_humidity/absolute_humidity.h @@ -13,7 +13,7 @@ enum SaturationVaporPressureEquation { }; /// This class implements calculation of absolute humidity from temperature and relative humidity. -class AbsoluteHumidityComponent : public sensor::Sensor, public Component { +class AbsoluteHumidityComponent final : public sensor::Sensor, public Component { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h index 6bfcf0bdb5..783a9d7e24 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.h +++ b/esphome/components/ac_dimmer/ac_dimmer.h @@ -41,7 +41,7 @@ struct AcDimmerDataStore { #endif }; -class AcDimmer : public output::FloatOutput, public Component { +class AcDimmer final : public output::FloatOutput, public Component { public: void setup() override; diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 676940eca1..03de6f8b4b 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -54,7 +54,7 @@ template class Aggregator { SamplingMode mode_{SamplingMode::AVG}; }; -class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { +class ADCSensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { public: /// Update the sensor's state by reading the current ADC value. /// This method is called periodically based on the update interval. diff --git a/esphome/components/adc128s102/adc128s102.h b/esphome/components/adc128s102/adc128s102.h index f04ed87b2a..7d6355815e 100644 --- a/esphome/components/adc128s102/adc128s102.h +++ b/esphome/components/adc128s102/adc128s102.h @@ -6,9 +6,9 @@ namespace esphome::adc128s102 { -class ADC128S102 : public Component, - public spi::SPIDevice { +class ADC128S102 final : public Component, + public spi::SPIDevice { public: ADC128S102() = default; diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.h b/esphome/components/adc128s102/sensor/adc128s102_sensor.h index c840102380..3c42e709f2 100644 --- a/esphome/components/adc128s102/sensor/adc128s102_sensor.h +++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.h @@ -9,10 +9,10 @@ namespace esphome::adc128s102 { -class ADC128S102Sensor : public PollingComponent, - public Parented, - public sensor::Sensor, - public voltage_sampler::VoltageSampler { +class ADC128S102Sensor final : public PollingComponent, + public Parented, + public sensor::Sensor, + public voltage_sampler::VoltageSampler { public: ADC128S102Sensor(uint8_t channel); diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index 917d334f05..39d62b8733 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -9,7 +9,7 @@ namespace esphome::addressable_light { -class AddressableLightDisplay : public display::DisplayBuffer { +class AddressableLightDisplay final : public display::DisplayBuffer { public: light::AddressableLight *get_light() const { return this->light_; } diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h index 53f501dee2..12be0849ff 100644 --- a/esphome/components/ade7880/ade7880.h +++ b/esphome/components/ade7880/ade7880.h @@ -65,7 +65,7 @@ struct ADE7880Store { static void gpio_intr(ADE7880Store *arg); }; -class ADE7880 : public i2c::I2CDevice, public PollingComponent { +class ADE7880 final : public i2c::I2CDevice, public PollingComponent { public: void set_irq0_pin(InternalGPIOPin *pin) { this->irq0_pin_ = pin; } void set_irq1_pin(InternalGPIOPin *pin) { this->irq1_pin_ = pin; } diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.h b/esphome/components/ade7953_i2c/ade7953_i2c.h index 74d7e3e7cc..0b368a73ee 100644 --- a/esphome/components/ade7953_i2c/ade7953_i2c.h +++ b/esphome/components/ade7953_i2c/ade7953_i2c.h @@ -10,7 +10,7 @@ namespace esphome::ade7953_i2c { -class AdE7953I2c : public ade7953_base::ADE7953, public i2c::I2CDevice { +class AdE7953I2c final : public ade7953_base::ADE7953, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/ads1115/ads1115.h b/esphome/components/ads1115/ads1115.h index b1eed68aff..0b7f7ae700 100644 --- a/esphome/components/ads1115/ads1115.h +++ b/esphome/components/ads1115/ads1115.h @@ -43,7 +43,7 @@ enum ADS1115Samplerate { ADS1115_860SPS = 0b111 }; -class ADS1115Component : public Component, public i2c::I2CDevice { +class ADS1115Component final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.h b/esphome/components/ads1115/sensor/ads1115_sensor.h index 3b82c153dd..ecc8fb7af8 100644 --- a/esphome/components/ads1115/sensor/ads1115_sensor.h +++ b/esphome/components/ads1115/sensor/ads1115_sensor.h @@ -11,10 +11,10 @@ namespace esphome::ads1115 { /// Internal holder class that is in instance of Sensor so that the hub can create individual sensors. -class ADS1115Sensor : public sensor::Sensor, - public PollingComponent, - public voltage_sampler::VoltageSampler, - public Parented { +class ADS1115Sensor final : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public Parented { public: void update() override; void set_multiplexer(ADS1115Multiplexer multiplexer) { this->multiplexer_ = multiplexer; } diff --git a/esphome/components/ads1118/ads1118.h b/esphome/components/ads1118/ads1118.h index ef125a0b44..275933c70d 100644 --- a/esphome/components/ads1118/ads1118.h +++ b/esphome/components/ads1118/ads1118.h @@ -26,9 +26,9 @@ enum ADS1118Gain { ADS1118_GAIN_0P256 = 0b101, }; -class ADS1118 : public Component, - public spi::SPIDevice { +class ADS1118 final : public Component, + public spi::SPIDevice { public: ADS1118() = default; void setup() override; diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.h b/esphome/components/ads1118/sensor/ads1118_sensor.h index b929e75c62..8987dba073 100644 --- a/esphome/components/ads1118/sensor/ads1118_sensor.h +++ b/esphome/components/ads1118/sensor/ads1118_sensor.h @@ -10,10 +10,10 @@ namespace esphome::ads1118 { -class ADS1118Sensor : public PollingComponent, - public sensor::Sensor, - public voltage_sampler::VoltageSampler, - public Parented { +class ADS1118Sensor final : public PollingComponent, + public sensor::Sensor, + public voltage_sampler::VoltageSampler, + public Parented { public: void update() override; diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h index 703acd5228..8ebc8da544 100644 --- a/esphome/components/ags10/ags10.h +++ b/esphome/components/ags10/ags10.h @@ -7,7 +7,7 @@ namespace esphome::ags10 { -class AGS10Component : public PollingComponent, public i2c::I2CDevice { +class AGS10Component final : public PollingComponent, public i2c::I2CDevice { public: /** * Sets TVOC sensor. @@ -100,7 +100,7 @@ class AGS10Component : public PollingComponent, public i2c::I2CDevice { template optional> read_and_check_(uint8_t a_register); }; -template class AGS10NewI2cAddressAction : public Action, public Parented { +template class AGS10NewI2cAddressAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, new_address) @@ -116,7 +116,7 @@ enum AGS10SetZeroPointActionMode { CUSTOM_VALUE, }; -template class AGS10SetZeroPointAction : public Action, public Parented { +template class AGS10SetZeroPointAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, value) TEMPLATABLE_VALUE(AGS10SetZeroPointActionMode, mode) diff --git a/esphome/components/aht10/aht10.h b/esphome/components/aht10/aht10.h index 7b9b1761c4..e99ba6fb98 100644 --- a/esphome/components/aht10/aht10.h +++ b/esphome/components/aht10/aht10.h @@ -10,7 +10,7 @@ namespace esphome::aht10 { enum AHT10Variant { AHT10, AHT20 }; -class AHT10Component : public PollingComponent, public i2c::I2CDevice { +class AHT10Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/aic3204/aic3204.h b/esphome/components/aic3204/aic3204.h index 9b8c792824..ae99a8f4d6 100644 --- a/esphome/components/aic3204/aic3204.h +++ b/esphome/components/aic3204/aic3204.h @@ -61,7 +61,7 @@ static const uint8_t AIC3204_ADC_PTM = 0x3D; // Register 61 - ADC Power Tu static const uint8_t AIC3204_AN_IN_CHRG = 0x47; // Register 71 - Analog Input Quick Charging Config static const uint8_t AIC3204_REF_STARTUP = 0x7B; // Register 123 - Reference Power Up Config -class AIC3204 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class AIC3204 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h index 50ae03edbd..f0f8856614 100644 --- a/esphome/components/aic3204/automation.h +++ b/esphome/components/aic3204/automation.h @@ -6,7 +6,7 @@ namespace esphome::aic3204 { -template class SetAutoMuteAction : public Action { +template class SetAutoMuteAction final : public Action { public: explicit SetAutoMuteAction(AIC3204 *aic3204) : aic3204_(aic3204) {} diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h index 707e9c3f21..8105ac32eb 100644 --- a/esphome/components/airthings_ble/airthings_listener.h +++ b/esphome/components/airthings_ble/airthings_listener.h @@ -7,7 +7,7 @@ namespace esphome::airthings_ble { -class AirthingsListener : public esp32_ble_tracker::ESPBTDeviceListener { +class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.h b/esphome/components/airthings_wave_mini/airthings_wave_mini.h index 910ac90239..c41dde15c9 100644 --- a/esphome/components/airthings_wave_mini/airthings_wave_mini.h +++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.h @@ -12,7 +12,7 @@ static const char *const SERVICE_UUID = "b42e3882-ade7-11e4-89d3-123b93f75cba"; static const char *const CHARACTERISTIC_UUID = "b42e3b98-ade7-11e4-89d3-123b93f75cba"; static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID = "b42e3ef4-ade7-11e4-89d3-123b93f75cba"; -class AirthingsWaveMini : public airthings_wave_base::AirthingsWaveBase { +class AirthingsWaveMini final : public airthings_wave_base::AirthingsWaveBase { public: AirthingsWaveMini(); diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.h b/esphome/components/airthings_wave_plus/airthings_wave_plus.h index 6f51f3c65a..af355e45d6 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.h +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.h @@ -19,7 +19,7 @@ static const char *const CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e4dcc-ade7-11 static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e50d8-ade7-11e4-89d3-123b93f75cba"; -class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { +class AirthingsWavePlus final : public airthings_wave_base::AirthingsWaveBase { public: void setup() override; diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index 022d2650d2..dcb5121c60 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,7 +27,7 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class ArmAwayAction : public Action { +template class ArmAwayAction final : public Action { public: explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -39,7 +39,7 @@ template class ArmAwayAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class ArmHomeAction : public Action { +template class ArmHomeAction final : public Action { public: explicit ArmHomeAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -51,7 +51,7 @@ template class ArmHomeAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class ArmNightAction : public Action { +template class ArmNightAction final : public Action { public: explicit ArmNightAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -63,7 +63,7 @@ template class ArmNightAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class DisarmAction : public Action { +template class DisarmAction final : public Action { public: explicit DisarmAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -75,7 +75,7 @@ template class DisarmAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class PendingAction : public Action { +template class PendingAction final : public Action { public: explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -85,7 +85,7 @@ template class PendingAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class TriggeredAction : public Action { +template class TriggeredAction final : public Action { public: explicit TriggeredAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {} @@ -95,7 +95,7 @@ template class TriggeredAction : public Action { AlarmControlPanel *alarm_control_panel_; }; -template class AlarmControlPanelCondition : public Condition { +template class AlarmControlPanelCondition final : public Condition { public: AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {} bool check(const Ts &...x) override { diff --git a/esphome/components/alpha3/alpha3.h b/esphome/components/alpha3/alpha3.h index c63129031a..5a5b01ac0b 100644 --- a/esphome/components/alpha3/alpha3.h +++ b/esphome/components/alpha3/alpha3.h @@ -31,7 +31,7 @@ static const int16_t GENI_RESPONSE_POWER_OFFSET = 12; static const int16_t GENI_RESPONSE_MOTOR_POWER_OFFSET = 16; // not sure static const int16_t GENI_RESPONSE_MOTOR_SPEED_OFFSET = 20; -class Alpha3 : public esphome::ble_client::BLEClientNode, public PollingComponent { +class Alpha3 final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void update() override; diff --git a/esphome/components/am2315c/am2315c.h b/esphome/components/am2315c/am2315c.h index 5a959af4c3..73dc0d8758 100644 --- a/esphome/components/am2315c/am2315c.h +++ b/esphome/components/am2315c/am2315c.h @@ -27,7 +27,7 @@ namespace esphome::am2315c { -class AM2315C : public PollingComponent, public i2c::I2CDevice { +class AM2315C final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void update() override; diff --git a/esphome/components/am2320/am2320.h b/esphome/components/am2320/am2320.h index ddb5c6f165..f92156b154 100644 --- a/esphome/components/am2320/am2320.h +++ b/esphome/components/am2320/am2320.h @@ -6,7 +6,7 @@ namespace esphome::am2320 { -class AM2320Component : public PollingComponent, public i2c::I2CDevice { +class AM2320Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/am43/cover/am43_cover.h b/esphome/components/am43/cover/am43_cover.h index aa48aced15..be7af59ade 100644 --- a/esphome/components/am43/cover/am43_cover.h +++ b/esphome/components/am43/cover/am43_cover.h @@ -14,7 +14,7 @@ namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; -class Am43Component : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component { +class Am43Component final : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h index 9198a5cbcb..944681bb60 100644 --- a/esphome/components/am43/sensor/am43_sensor.h +++ b/esphome/components/am43/sensor/am43_sensor.h @@ -14,7 +14,7 @@ namespace esphome::am43 { namespace espbt = esphome::esp32_ble_tracker; -class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent { +class Am43 final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void update() override; diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index c768f1f82d..a4df00ff05 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -7,7 +7,7 @@ namespace esphome::analog_threshold { -class AnalogThresholdBinarySensor : public Component, public binary_sensor::BinarySensor { +class AnalogThresholdBinarySensor final : public Component, public binary_sensor::BinarySensor { public: void dump_config() override; void setup() override; diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index ca800ad931..64cddbf09c 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -5,7 +5,7 @@ namespace esphome::animation { -class Animation : public image::Image { +class Animation final : public image::Image { public: Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type, image::Transparency transparent); @@ -35,7 +35,7 @@ class Animation : public image::Image { int loop_current_iteration_; }; -template class AnimationNextFrameAction : public Action { +template class AnimationNextFrameAction final : public Action { public: AnimationNextFrameAction(Animation *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->next_frame(); } @@ -44,7 +44,7 @@ template class AnimationNextFrameAction : public Action { Animation *parent_; }; -template class AnimationPrevFrameAction : public Action { +template class AnimationPrevFrameAction final : public Action { public: AnimationPrevFrameAction(Animation *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->prev_frame(); } @@ -53,7 +53,7 @@ template class AnimationPrevFrameAction : public Action { Animation *parent_; }; -template class AnimationSetFrameAction : public Action { +template class AnimationSetFrameAction final : public Action { public: AnimationSetFrameAction(Animation *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, frame) diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index a3e175be28..49b1100c37 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; static const uint16_t ANOVA_SERVICE_UUID = 0xFFE0; static const uint16_t ANOVA_CHARACTERISTIC_UUID = 0xFFE1; -class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { +class Anova final : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/apds9306/apds9306.h b/esphome/components/apds9306/apds9306.h index 093ec55bc6..f971290cdd 100644 --- a/esphome/components/apds9306/apds9306.h +++ b/esphome/components/apds9306/apds9306.h @@ -39,7 +39,7 @@ enum AmbientLightGain : uint8_t { }; static const uint8_t AMBIENT_LIGHT_GAIN_VALUES[] = {1, 3, 6, 9, 18}; -class APDS9306 : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class APDS9306 final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; float get_setup_priority() const override { return setup_priority::BUS; } diff --git a/esphome/components/apds9960/apds9960.h b/esphome/components/apds9960/apds9960.h index 2823294207..bfa64bcc74 100644 --- a/esphome/components/apds9960/apds9960.h +++ b/esphome/components/apds9960/apds9960.h @@ -12,7 +12,7 @@ namespace esphome::apds9960 { -class APDS9960 : public PollingComponent, public i2c::I2CDevice { +class APDS9960 final : public PollingComponent, public i2c::I2CDevice { #ifdef USE_SENSOR SUB_SENSOR(red) SUB_SENSOR(green) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2b1458e2ae..acdf24e747 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -375,7 +375,7 @@ void APIConnection::finalize_iterator_sync_() { void APIConnection::process_iterator_batch_(ComponentIterator &iterator) { size_t initial_size = this->deferred_batch_.size(); - size_t max_batch = this->get_max_batch_size_(); + size_t max_batch = MAX_INITIAL_PER_BATCH; while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) { iterator.advance(); } @@ -418,16 +418,6 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp // Set common fields that are shared by all entity types msg.key = entity->get_object_id_hash(); - // API 1.14+ clients compute object_id client-side from the entity name - // For older clients, we must send object_id for backward compatibility - // See: https://github.com/esphome/backlog/issues/76 - // TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then - // Buffer must remain in scope until encode_to_buffer is called - char object_id_buf[OBJECT_ID_MAX_LEN]; - if (!conn->client_supports_api_version(1, 14)) { - msg.object_id = entity->get_object_id_to(object_id_buf); - } - if (entity->has_own_name()) { msg.name = entity->get_name(); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 804cd9ddd1..92f7065730 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -43,10 +43,7 @@ class APIServer; // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending -// API 1.14+ clients compute object_id client-side, so messages are smaller and we can fit more per batch -// TODO: Remove MAX_INITIAL_PER_BATCH_LEGACY before 2026.7.0 - all clients should support API 1.14 by then -static constexpr size_t MAX_INITIAL_PER_BATCH_LEGACY = 24; // For clients < API 1.14 (includes object_id) -static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= API 1.14 (no object_id) +static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); @@ -481,13 +478,6 @@ class APIConnection final : public APIServerConnectionBase { inline bool check_voice_assistant_api_connection_() const; #endif - // Get the max batch size based on client API version - // API 1.14+ clients don't receive object_id, so messages are smaller and more fit per batch - // TODO: Remove this method before 2026.7.0 and use MAX_INITIAL_PER_BATCH directly - size_t get_max_batch_size_() const { - return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY; - } - // Send keepalive ping or disconnect unresponsive client. // Cold path — extracted from loop() to reduce instruction cache pressure. void __attribute__((noinline)) check_keepalive_(uint32_t now); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index fbc8115091..16b5762f68 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -342,7 +342,7 @@ class APIServer final : public Component, extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class APIConnectedCondition : public Condition { +template class APIConnectedCondition final : public Condition { TEMPLATABLE_VALUE(bool, state_subscription_only) public: bool check(const Ts &...x) override { diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index aef046fbb0..9e0faf9881 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -104,7 +104,7 @@ class ActionResponse { template using ActionResponseCallback = std::function; #endif -template class HomeAssistantServiceCallAction : public Action { +template class HomeAssistantServiceCallAction final : public Action { public: explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) { this->flags_.is_event = is_event; diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 29eadda927..ea57d0944b 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -164,7 +164,8 @@ template class UserServiceTrig // Specialization for NONE - no extra trigger arguments template -class UserServiceTrigger : public UserServiceBase, public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {} @@ -175,8 +176,8 @@ class UserServiceTrigger : public UserServ // Specialization for OPTIONAL - call_id and return_response trigger arguments template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {} @@ -189,8 +190,8 @@ class UserServiceTrigger : public User // Specialization for ONLY - just call_id trigger argument template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_ONLY) {} @@ -201,8 +202,8 @@ class UserServiceTrigger : public UserServ // Specialization for STATUS - just call_id trigger argument (reports success/error without data) template -class UserServiceTrigger : public UserServiceBase, - public Trigger { +class UserServiceTrigger final : public UserServiceBase, + public Trigger { public: UserServiceTrigger(const char *name, const std::array &arg_names) : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_STATUS) {} @@ -221,7 +222,7 @@ class UserServiceTrigger : public UserSe namespace esphome::api { -template class APIRespondAction : public Action { +template class APIRespondAction final : public Action { public: explicit APIRespondAction(APIServer *parent) : parent_(parent) {} @@ -286,7 +287,7 @@ template class APIRespondAction : public Action { // Action to unregister a service call after execution completes // Automatically appended to the end of action lists for non-none response modes -template class APIUnregisterServiceCallAction : public Action { +template class APIUnregisterServiceCallAction final : public Action { public: explicit APIUnregisterServiceCallAction(APIServer *parent) : parent_(parent) {} diff --git a/esphome/components/aqi/aqi_sensor.h b/esphome/components/aqi/aqi_sensor.h index 2e526ca825..aa64fa5a4d 100644 --- a/esphome/components/aqi/aqi_sensor.h +++ b/esphome/components/aqi/aqi_sensor.h @@ -6,7 +6,7 @@ namespace esphome::aqi { -class AQISensor : public sensor::Sensor, public Component { +class AQISensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/as3935_i2c/as3935_i2c.h b/esphome/components/as3935_i2c/as3935_i2c.h index c43ec4afd5..c15f2d6e3e 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.h +++ b/esphome/components/as3935_i2c/as3935_i2c.h @@ -5,7 +5,7 @@ namespace esphome::as3935_i2c { -class I2CAS3935Component : public as3935::AS3935Component, public i2c::I2CDevice { +class I2CAS3935Component final : public as3935::AS3935Component, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/as3935_spi/as3935_spi.h b/esphome/components/as3935_spi/as3935_spi.h index 935707a18c..053e34b3d0 100644 --- a/esphome/components/as3935_spi/as3935_spi.h +++ b/esphome/components/as3935_spi/as3935_spi.h @@ -8,9 +8,9 @@ namespace esphome::as3935_spi { enum AS3935RegisterMasks { SPI_READ_M = 0x40 }; -class SPIAS3935Component : public as3935::AS3935Component, - public spi::SPIDevice { +class SPIAS3935Component final : public as3935::AS3935Component, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/as5600/as5600.h b/esphome/components/as5600/as5600.h index 414633f978..a385322b70 100644 --- a/esphome/components/as5600/as5600.h +++ b/esphome/components/as5600/as5600.h @@ -43,7 +43,7 @@ enum AS5600MagnetStatus : uint8_t { MAGNET_WEAK = 6, // 0b110 / magnet too weak }; -class AS5600Component : public Component, public i2c::I2CDevice { +class AS5600Component final : public Component, public i2c::I2CDevice { public: /// Set up the internal sensor array. void setup() override; diff --git a/esphome/components/as5600/sensor/as5600_sensor.h b/esphome/components/as5600/sensor/as5600_sensor.h index 0086fe54cc..170ff6d86b 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.h +++ b/esphome/components/as5600/sensor/as5600_sensor.h @@ -9,7 +9,7 @@ namespace esphome::as5600 { -class AS5600Sensor : public PollingComponent, public Parented, public sensor::Sensor { +class AS5600Sensor final : public PollingComponent, public Parented, public sensor::Sensor { public: void update() override; void dump_config() override; diff --git a/esphome/components/as7341/as7341.h b/esphome/components/as7341/as7341.h index 8bc157fe79..2d72987f1c 100644 --- a/esphome/components/as7341/as7341.h +++ b/esphome/components/as7341/as7341.h @@ -73,7 +73,7 @@ enum AS7341Gain { AS7341_GAIN_512X, }; -class AS7341Component : public PollingComponent, public i2c::I2CDevice { +class AS7341Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h index e7f8ee3692..594395e96d 100644 --- a/esphome/components/at581x/at581x.h +++ b/esphome/components/at581x/at581x.h @@ -12,7 +12,7 @@ namespace esphome::at581x { -class AT581XComponent : public Component, public i2c::I2CDevice { +class AT581XComponent final : public Component, public i2c::I2CDevice { public: #ifdef USE_SWITCH void set_rf_power_switch(switch_::Switch *s) { diff --git a/esphome/components/at581x/automation.h b/esphome/components/at581x/automation.h index eb8b1b2562..a732d2bcc7 100644 --- a/esphome/components/at581x/automation.h +++ b/esphome/components/at581x/automation.h @@ -7,12 +7,12 @@ namespace esphome::at581x { -template class AT581XResetAction : public Action, public Parented { +template class AT581XResetAction final : public Action, public Parented { public: void play(const Ts &...x) { this->parent_->reset_hardware_frontend(); } }; -template class AT581XSettingsAction : public Action, public Parented { +template class AT581XSettingsAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int8_t, hw_frontend_reset) TEMPLATABLE_VALUE(int, frequency) diff --git a/esphome/components/at581x/switch/rf_switch.h b/esphome/components/at581x/switch/rf_switch.h index 47367fad45..0e251b8baa 100644 --- a/esphome/components/at581x/switch/rf_switch.h +++ b/esphome/components/at581x/switch/rf_switch.h @@ -5,7 +5,7 @@ namespace esphome::at581x { -class RFSwitch : public switch_::Switch, public Parented { +class RFSwitch final : public switch_::Switch, public Parented { protected: void write_state(bool state) override; }; diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 8f62f05bc1..3dde5f1868 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -18,7 +18,7 @@ struct ParseResult { int raw_offset; }; -class ATCMiThermometer : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/atm90e26/atm90e26.h b/esphome/components/atm90e26/atm90e26.h index 657f8f3c43..0381d8e5c1 100644 --- a/esphome/components/atm90e26/atm90e26.h +++ b/esphome/components/atm90e26/atm90e26.h @@ -6,9 +6,9 @@ namespace esphome::atm90e26 { -class ATM90E26Component : public PollingComponent, - public spi::SPIDevice { +class ATM90E26Component final : public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index 5fa224b353..c636e5065a 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -13,9 +13,9 @@ namespace esphome::atm90e32 { -class ATM90E32Component : public PollingComponent, - public spi::SPIDevice { +class ATM90E32Component final : public PollingComponent, + public spi::SPIDevice { public: static const uint8_t PHASEA = 0; static const uint8_t PHASEB = 1; diff --git a/esphome/components/atm90e32/button/atm90e32_button.h b/esphome/components/atm90e32/button/atm90e32_button.h index 0cfce62293..988c6d5c16 100644 --- a/esphome/components/atm90e32/button/atm90e32_button.h +++ b/esphome/components/atm90e32/button/atm90e32_button.h @@ -6,7 +6,7 @@ namespace esphome::atm90e32 { -class ATM90E32GainCalibrationButton : public button::Button, public Parented { +class ATM90E32GainCalibrationButton final : public button::Button, public Parented { public: ATM90E32GainCalibrationButton() = default; @@ -14,7 +14,7 @@ class ATM90E32GainCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearGainCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearGainCalibrationButton() = default; @@ -22,7 +22,7 @@ class ATM90E32ClearGainCalibrationButton : public button::Button, public Parente void press_action() override; }; -class ATM90E32OffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32OffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32OffsetCalibrationButton() = default; @@ -30,7 +30,7 @@ class ATM90E32OffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearOffsetCalibrationButton() = default; @@ -38,7 +38,7 @@ class ATM90E32ClearOffsetCalibrationButton : public button::Button, public Paren void press_action() override; }; -class ATM90E32PowerOffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32PowerOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32PowerOffsetCalibrationButton() = default; @@ -46,7 +46,7 @@ class ATM90E32PowerOffsetCalibrationButton : public button::Button, public Paren void press_action() override; }; -class ATM90E32ClearPowerOffsetCalibrationButton : public button::Button, public Parented { +class ATM90E32ClearPowerOffsetCalibrationButton final : public button::Button, public Parented { public: ATM90E32ClearPowerOffsetCalibrationButton() = default; diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 2ddce577ef..091f496e33 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -395,11 +395,11 @@ async def to_code(config): ) if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") - add_idf_component(name="esphome/micro-mp3", ref="0.2.1") + add_idf_component(name="esphome/micro-mp3", ref="0.3.0") _emit_memory_pair( data.mp3.buffer_memory, - "CONFIG_MP3_DECODER_PREFER_PSRAM", - "CONFIG_MP3_DECODER_PREFER_INTERNAL", + "CONFIG_MICRO_MP3_PREFER_PSRAM", + "CONFIG_MICRO_MP3_PREFER_INTERNAL", ) if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") diff --git a/esphome/components/audio_adc/automation.h b/esphome/components/audio_adc/automation.h index e74e023203..fc7af25622 100644 --- a/esphome/components/audio_adc/automation.h +++ b/esphome/components/audio_adc/automation.h @@ -6,7 +6,7 @@ namespace esphome::audio_adc { -template class SetMicGainAction : public Action { +template class SetMicGainAction final : public Action { public: explicit SetMicGainAction(AudioAdc *audio_adc) : audio_adc_(audio_adc) {} diff --git a/esphome/components/audio_dac/automation.h b/esphome/components/audio_dac/automation.h index 67bbc78ac2..9c5348271c 100644 --- a/esphome/components/audio_dac/automation.h +++ b/esphome/components/audio_dac/automation.h @@ -6,7 +6,7 @@ namespace esphome::audio_dac { -template class MuteOffAction : public Action { +template class MuteOffAction final : public Action { public: explicit MuteOffAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} @@ -16,7 +16,7 @@ template class MuteOffAction : public Action { AudioDac *audio_dac_; }; -template class MuteOnAction : public Action { +template class MuteOnAction final : public Action { public: explicit MuteOnAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} @@ -26,7 +26,7 @@ template class MuteOnAction : public Action { AudioDac *audio_dac_; }; -template class SetVolumeAction : public Action { +template class SetVolumeAction final : public Action { public: explicit SetVolumeAction(AudioDac *audio_dac) : audio_dac_(audio_dac) {} diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.h b/esphome/components/audio_file/media_source/audio_file_media_source.h index 2c6189f272..d269f77c35 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.h +++ b/esphome/components/audio_file/media_source/audio_file_media_source.h @@ -23,7 +23,9 @@ namespace esphome::audio_file { // (the orchestrator calls set_listener() on us with a MediaSourceListener*). // - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded // audio and state changes (we call decoder_->set_listener(this) in setup()). -class AudioFileMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { +class AudioFileMediaSource final : public Component, + public media_source::MediaSource, + public micro_decoder::DecoderListener { public: void setup() override; void loop() override; diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h index e4bd69e9e6..f794aa1f02 100644 --- a/esphome/components/audio_http/audio_http_media_source.h +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -23,7 +23,9 @@ namespace esphome::audio_http { // - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded // audio and state changes (we call decoder_->set_listener(this) in setup()). // The two set_listener() methods live on different base classes and serve opposite directions. -class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { +class AudioHTTPMediaSource final : public Component, + public media_source::MediaSource, + public micro_decoder::DecoderListener { public: void setup() override; void loop() override; diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h index 94d232777c..43bd379925 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h @@ -7,7 +7,7 @@ namespace esphome::axs15231 { -class AXS15231Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class AXS15231Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ballu/ballu.h b/esphome/components/ballu/ballu.h index 8a45d39c70..cb40f415ad 100644 --- a/esphome/components/ballu/ballu.h +++ b/esphome/components/ballu/ballu.h @@ -10,7 +10,7 @@ namespace esphome::ballu { const float YKR_K_002E_TEMP_MIN = 16.0; const float YKR_K_002E_TEMP_MAX = 32.0; -class BalluClimate : public climate_ir::ClimateIR { +class BalluClimate final : public climate_ir::ClimateIR { public: BalluClimate() : climate_ir::ClimateIR(YKR_K_002E_TEMP_MIN, YKR_K_002E_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index 1e5ff84883..d83257f9f3 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -16,7 +16,7 @@ struct BangBangClimateTargetTempConfig { float default_temperature_high{NAN}; }; -class BangBangClimate : public climate::Climate, public Component { +class BangBangClimate final : public climate::Climate, public Component { public: BangBangClimate(); void setup() override; diff --git a/esphome/components/bedjet/bedjet_hub.h b/esphome/components/bedjet/bedjet_hub.h index 9f25f7a466..32ddd94cff 100644 --- a/esphome/components/bedjet/bedjet_hub.h +++ b/esphome/components/bedjet/bedjet_hub.h @@ -33,7 +33,7 @@ static const espbt::ESPBTUUID BEDJET_NAME_UUID = espbt::ESPBTUUID::from_raw("000 /** * Hub component connecting to the BedJet device over Bluetooth. */ -class BedJetHub : public esphome::ble_client::BLEClientNode, public PollingComponent { +class BedJetHub final : public esphome::ble_client::BLEClientNode, public PollingComponent { public: /* BedJet functionality exposed to `BedJetClient` children and/or accessible from action lambdas. */ diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index f59e67eeb7..6f81b87289 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -12,7 +12,7 @@ namespace esphome::bedjet { -class BedJetClimate : public climate::Climate, public BedJetClient, public PollingComponent { +class BedJetClimate final : public climate::Climate, public BedJetClient, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/bedjet/fan/bedjet_fan.h b/esphome/components/bedjet/fan/bedjet_fan.h index 03f42f1438..814a87d8b9 100644 --- a/esphome/components/bedjet/fan/bedjet_fan.h +++ b/esphome/components/bedjet/fan/bedjet_fan.h @@ -12,7 +12,7 @@ namespace esphome::bedjet { -class BedJetFan : public fan::Fan, public BedJetClient, public PollingComponent { +class BedJetFan final : public fan::Fan, public BedJetClient, public PollingComponent { public: void update() override; void dump_config() override; diff --git a/esphome/components/bedjet/sensor/bedjet_sensor.h b/esphome/components/bedjet/sensor/bedjet_sensor.h index 0c3f713579..c387e9d5fd 100644 --- a/esphome/components/bedjet/sensor/bedjet_sensor.h +++ b/esphome/components/bedjet/sensor/bedjet_sensor.h @@ -7,7 +7,7 @@ namespace esphome::bedjet { -class BedjetSensor : public BedJetClient, public Component { +class BedjetSensor final : public BedJetClient, public Component { public: void dump_config() override; diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 4ed640a3bc..909634e266 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -19,7 +19,7 @@ enum RGBOrder : uint8_t { ORDER_BRG, }; -class BekenSPILEDStripLightOutput : public light::AddressableLight { +class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/bh1750/bh1750.h b/esphome/components/bh1750/bh1750.h index 39dbd1d6a9..092a21359b 100644 --- a/esphome/components/bh1750/bh1750.h +++ b/esphome/components/bh1750/bh1750.h @@ -13,7 +13,7 @@ enum BH1750Mode : uint8_t { }; /// This class implements support for the i2c-based BH1750 ambient light sensor. -class BH1750Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class BH1750Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/bh1900nux/bh1900nux.h b/esphome/components/bh1900nux/bh1900nux.h index 61d1bac268..f1d62d1647 100644 --- a/esphome/components/bh1900nux/bh1900nux.h +++ b/esphome/components/bh1900nux/bh1900nux.h @@ -6,7 +6,7 @@ namespace esphome::bh1900nux { -class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class BH1900NUXSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/binary/fan/binary_fan.h b/esphome/components/binary/fan/binary_fan.h index 17157dd29c..601f4cb641 100644 --- a/esphome/components/binary/fan/binary_fan.h +++ b/esphome/components/binary/fan/binary_fan.h @@ -6,7 +6,7 @@ namespace esphome::binary { -class BinaryFan : public Component, public fan::Fan { +class BinaryFan final : public Component, public fan::Fan { public: void setup() override; void dump_config() override; diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index f6be7e162e..32707e8b0c 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -6,7 +6,7 @@ namespace esphome::binary { -class BinaryLightOutput : public light::LightOutput { +class BinaryLightOutput final : public light::LightOutput { public: void set_output(output::BinaryOutput *output) { output_ = output; } light::LightTraits get_traits() override { diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 1875910aff..d5a85ca9c4 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -18,7 +18,7 @@ struct MultiClickTriggerEvent { uint32_t max_length; }; -class PressTrigger : public Trigger<> { +class PressTrigger final : public Trigger<> { public: explicit PressTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { @@ -28,7 +28,7 @@ class PressTrigger : public Trigger<> { } }; -class ReleaseTrigger : public Trigger<> { +class ReleaseTrigger final : public Trigger<> { public: explicit ReleaseTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { @@ -40,7 +40,7 @@ class ReleaseTrigger : public Trigger<> { bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); -class ClickTrigger : public Trigger<> { +class ClickTrigger final : public Trigger<> { public: explicit ClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length) : min_length_(min_length), max_length_(max_length) { @@ -61,7 +61,7 @@ class ClickTrigger : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -class DoubleClickTrigger : public Trigger<> { +class DoubleClickTrigger final : public Trigger<> { public: explicit DoubleClickTrigger(BinarySensor *parent, uint32_t min_length, uint32_t max_length) : min_length_(min_length), max_length_(max_length) { @@ -127,7 +127,7 @@ class MultiClickTriggerBase : public Trigger<>, public Component { /// Template wrapper that provides inline std::array storage for timing events. /// N is set by code generation to match the exact number of timing events configured in YAML. -template class MultiClickTrigger : public MultiClickTriggerBase { +template class MultiClickTrigger final : public MultiClickTriggerBase { public: MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) : MultiClickTriggerBase(parent) { @@ -140,14 +140,14 @@ template class MultiClickTrigger : public MultiClickTriggerBase { std::array timing_storage_{}; }; -class StateTrigger : public Trigger { +class StateTrigger final : public Trigger { public: explicit StateTrigger(BinarySensor *parent) { parent->add_on_state_callback([this](bool state) { this->trigger(state); }); } }; -class StateChangeTrigger : public Trigger, optional > { +class StateChangeTrigger final : public Trigger, optional > { public: explicit StateChangeTrigger(BinarySensor *parent) { parent->add_full_state_callback( @@ -155,7 +155,7 @@ class StateChangeTrigger : public Trigger, optional > { } }; -template class BinarySensorCondition : public Condition { +template class BinarySensorCondition final : public Condition { public: BinarySensorCondition(BinarySensor *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { return this->parent_->state == this->state_; } @@ -165,7 +165,7 @@ template class BinarySensorCondition : public Condition { bool state_; }; -template class BinarySensorPublishAction : public Action { +template class BinarySensorPublishAction final : public Action { public: explicit BinarySensorPublishAction(BinarySensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(bool, state) @@ -179,7 +179,7 @@ template class BinarySensorPublishAction : public Action BinarySensor *sensor_; }; -template class BinarySensorInvalidateAction : public Action { +template class BinarySensorInvalidateAction final : public Action { public: explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.h b/esphome/components/binary_sensor_map/binary_sensor_map.h index 60224242db..bb2c273957 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.h +++ b/esphome/components/binary_sensor_map/binary_sensor_map.h @@ -29,7 +29,7 @@ struct BinarySensorMapChannel { * * Each binary sensor has configured parameters that each mapping type uses to compute the single numerical result */ -class BinarySensorMap : public sensor::Sensor, public Component { +class BinarySensorMap final : public sensor::Sensor, public Component { public: void dump_config() override; diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h index 821aac476c..54de9f9b0c 100644 --- a/esphome/components/bl0906/bl0906.h +++ b/esphome/components/bl0906/bl0906.h @@ -53,7 +53,7 @@ class BL0906; using ActionCallbackFuncPtr = void (BL0906::*)(); -class BL0906 : public PollingComponent, public uart::UARTDevice { +class BL0906 final : public PollingComponent, public uart::UARTDevice { SUB_SENSOR(voltage) SUB_SENSOR(current_1) SUB_SENSOR(current_2) @@ -103,7 +103,7 @@ class BL0906 : public PollingComponent, public uart::UARTDevice { std::vector action_queue_{}; }; -template class ResetEnergyAction : public Action, public Parented { +template class ResetEnergyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->enqueue_action_(&BL0906::reset_energy_); } }; diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index b4f6d42e71..333bca3715 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -56,7 +56,7 @@ union DataPacket { // NOLINT(altera-struct-pack-align) }; } __attribute__((packed)); -class BL0939 : public PollingComponent, public uart::UARTDevice { +class BL0939 final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor_1(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h index 14cb69d0b0..007fa990d5 100644 --- a/esphome/components/bl0940/bl0940.h +++ b/esphome/components/bl0940/bl0940.h @@ -33,7 +33,7 @@ struct DataPacket { uint8_t checksum; // Packet checksum } __attribute__((packed)); -class BL0940 : public PollingComponent, public uart::UARTDevice { +class BL0940 final : public PollingComponent, public uart::UARTDevice { public: // Sensor setters void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } diff --git a/esphome/components/bl0940/button/calibration_reset_button.h b/esphome/components/bl0940/button/calibration_reset_button.h index d528992d58..f5a4f50886 100644 --- a/esphome/components/bl0940/button/calibration_reset_button.h +++ b/esphome/components/bl0940/button/calibration_reset_button.h @@ -7,7 +7,7 @@ namespace esphome::bl0940 { class BL0940; // Forward declaration of BL0940 class -class CalibrationResetButton : public button::Button, public Component, public Parented { +class CalibrationResetButton final : public button::Button, public Component, public Parented { public: void dump_config() override; diff --git a/esphome/components/bl0940/number/calibration_number.h b/esphome/components/bl0940/number/calibration_number.h index 062890d918..186a34c583 100644 --- a/esphome/components/bl0940/number/calibration_number.h +++ b/esphome/components/bl0940/number/calibration_number.h @@ -6,7 +6,7 @@ namespace esphome::bl0940 { -class CalibrationNumber : public number::Number, public Component { +class CalibrationNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/bl0942/bl0942.h b/esphome/components/bl0942/bl0942.h index c366878637..f926dd022d 100644 --- a/esphome/components/bl0942/bl0942.h +++ b/esphome/components/bl0942/bl0942.h @@ -83,7 +83,7 @@ enum LineFrequency : uint8_t { LINE_FREQUENCY_60HZ = 60, }; -class BL0942 : public PollingComponent, public uart::UARTDevice { +class BL0942 final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { this->current_sensor_ = current_sensor; } diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 01590d1d53..94eeb83b3e 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -23,7 +23,7 @@ class Automation { }; // implement on_connect automation. -class BLEClientConnectTrigger : public Trigger<>, public BLEClientNode { +class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientConnectTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -37,7 +37,7 @@ class BLEClientConnectTrigger : public Trigger<>, public BLEClientNode { }; // on_disconnect automation -class BLEClientDisconnectTrigger : public Trigger<>, public BLEClientNode { +class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientDisconnectTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -61,7 +61,7 @@ class BLEClientDisconnectTrigger : public Trigger<>, public BLEClientNode { } }; -class BLEClientPasskeyRequestTrigger : public Trigger<>, public BLEClientNode { +class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode { public: explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -71,7 +71,7 @@ class BLEClientPasskeyRequestTrigger : public Trigger<>, public BLEClientNode { } }; -class BLEClientPasskeyNotificationTrigger : public Trigger, public BLEClientNode { +class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientNode { public: explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -82,7 +82,7 @@ class BLEClientPasskeyNotificationTrigger : public Trigger, public BLE } }; -class BLEClientNumericComparisonRequestTrigger : public Trigger, public BLEClientNode { +class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientNode { public: explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -94,7 +94,7 @@ class BLEClientNumericComparisonRequestTrigger : public Trigger, publi }; // implement the ble_client.ble_write action. -template class BLEClientWriteAction : public Action, public BLEClientNode { +template class BLEClientWriteAction final : public Action, public BLEClientNode { public: BLEClientWriteAction(BLEClient *ble_client) { ble_client->register_ble_node(this); @@ -231,7 +231,7 @@ template class BLEClientWriteAction : public Action, publ esp_gatt_write_type_t write_type_{}; }; -template class BLEClientPasskeyReplyAction : public Action { +template class BLEClientPasskeyReplyAction final : public Action { public: BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -268,7 +268,7 @@ template class BLEClientPasskeyReplyAction : public Action class BLEClientNumericComparisonReplyAction : public Action { +template class BLEClientNumericComparisonReplyAction final : public Action { public: BLEClientNumericComparisonReplyAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -301,7 +301,7 @@ template class BLEClientNumericComparisonReplyAction : public Ac } value_{.simple = false}; }; -template class BLEClientRemoveBondAction : public Action { +template class BLEClientRemoveBondAction final : public Action { public: BLEClientRemoveBondAction(BLEClient *ble_client) { parent_ = ble_client; } @@ -315,7 +315,7 @@ template class BLEClientRemoveBondAction : public Action BLEClient *parent_{nullptr}; }; -template class BLEClientConnectAction : public Action, public BLEClientNode { +template class BLEClientConnectAction final : public Action, public BLEClientNode { public: BLEClientConnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); @@ -364,7 +364,7 @@ template class BLEClientConnectAction : public Action, pu std::tuple var_{}; }; -template class BLEClientDisconnectAction : public Action, public BLEClientNode { +template class BLEClientDisconnectAction final : public Action, public BLEClientNode { public: BLEClientDisconnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index ca523251ef..f27bef332b 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -44,7 +44,7 @@ class BLEClientNode { uint64_t address_; }; -class BLEClient : public BLEClientBase { +class BLEClient final : public BLEClientBase { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ble_client/output/ble_binary_output.h b/esphome/components/ble_client/output/ble_binary_output.h index 299de9b860..8ea700529b 100644 --- a/esphome/components/ble_client/output/ble_binary_output.h +++ b/esphome/components/ble_client/output/ble_binary_output.h @@ -11,7 +11,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEBinaryOutput : public output::BinaryOutput, public BLEClientNode, public Component { +class BLEBinaryOutput final : public output::BinaryOutput, public BLEClientNode, public Component { public: void dump_config() override; void loop() override {} diff --git a/esphome/components/ble_client/sensor/automation.h b/esphome/components/ble_client/sensor/automation.h index 84430cb7d9..e805ebdb59 100644 --- a/esphome/components/ble_client/sensor/automation.h +++ b/esphome/components/ble_client/sensor/automation.h @@ -7,7 +7,7 @@ namespace esphome::ble_client { -class BLESensorNotifyTrigger : public Trigger, public BLESensor { +class BLESensorNotifyTrigger final : public Trigger, public BLESensor { public: explicit BLESensorNotifyTrigger(BLESensor *sensor) { sensor_ = sensor; } void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.h b/esphome/components/ble_client/sensor/ble_rssi_sensor.h index 570a5b423c..e1590dbdeb 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.h +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.h @@ -12,7 +12,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEClientRSSISensor : public sensor::Sensor, public PollingComponent, public BLEClientNode { +class BLEClientRSSISensor final : public sensor::Sensor, public PollingComponent, public BLEClientNode { public: void loop() override; void update() override; diff --git a/esphome/components/ble_client/switch/ble_switch.h b/esphome/components/ble_client/switch/ble_switch.h index 9be6d06b1c..42b450243a 100644 --- a/esphome/components/ble_client/switch/ble_switch.h +++ b/esphome/components/ble_client/switch/ble_switch.h @@ -12,7 +12,7 @@ namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; -class BLEClientSwitch : public switch_::Switch, public Component, public BLEClientNode { +class BLEClientSwitch final : public switch_::Switch, public Component, public BLEClientNode { public: void dump_config() override; void loop() override {} diff --git a/esphome/components/ble_client/text_sensor/automation.h b/esphome/components/ble_client/text_sensor/automation.h index d4114cd1ba..8a81610668 100644 --- a/esphome/components/ble_client/text_sensor/automation.h +++ b/esphome/components/ble_client/text_sensor/automation.h @@ -7,7 +7,7 @@ namespace esphome::ble_client { -class BLETextSensorNotifyTrigger : public Trigger, public BLETextSensor { +class BLETextSensorNotifyTrigger final : public Trigger, public BLETextSensor { public: explicit BLETextSensorNotifyTrigger(BLETextSensor *sensor) { sensor_ = sensor; } void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, diff --git a/esphome/components/ble_nus/ble_nus.h b/esphome/components/ble_nus/ble_nus.h index f1afd54af9..82e2db6900 100644 --- a/esphome/components/ble_nus/ble_nus.h +++ b/esphome/components/ble_nus/ble_nus.h @@ -11,7 +11,7 @@ namespace esphome::ble_nus { -class BLENUS : public uart::UARTComponent, public Component { +class BLENUS final : public uart::UARTComponent, public Component { enum TxStatus { TX_DISABLED, TX_ENABLED, diff --git a/esphome/components/ble_presence/ble_presence_device.h b/esphome/components/ble_presence/ble_presence_device.h index 76e8079948..e17e26ff1c 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -8,9 +8,9 @@ namespace esphome::ble_presence { -class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener, - public Component { +class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener, + public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.h b/esphome/components/ble_rssi/ble_rssi_sensor.h index a876fa51d2..8e804ab8e7 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.h +++ b/esphome/components/ble_rssi/ble_rssi_sensor.h @@ -8,7 +8,7 @@ namespace esphome::ble_rssi { -class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index b7c56a283a..ce28fb0d67 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -13,6 +13,7 @@ from esphome.components.mipi import ( import esphome.config_validation as cv from esphome.config_validation import update_interval from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BUSY_PIN, CONF_CS_PIN, CONF_DATA_RATE, @@ -129,7 +130,23 @@ def customise_schema(config): }, extra=cv.ALLOW_EXTRA, )(config) - return model_schema(config)(config) + model = MODELS[config[CONF_MODEL]] + config = model_schema(config)(config) + width, height = model.get_dimensions(config) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_rotation=True, + byte_order=cv.UNDEFINED, + has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True + or config.get(CONF_PAGES) is not None + or config.get(CONF_LAMBDA) is not None + or config.get(CONF_SHOW_TEST_CARD) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=0, + ) + return config CONFIG_SCHEMA = customise_schema @@ -197,6 +214,9 @@ async def to_code(config): if busy_pin := config.get(CONF_BUSY_PIN): busy = await cg.gpio_pin_expression(busy_pin) cg.add(var.set_busy_pin(busy)) + if enable_pin := config.get(CONF_ENABLE_PIN): + enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] + cg.add(var.set_enable_pins(enable)) cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) if CONF_RESET_DURATION in config: cg.add(var.set_reset_duration(config[CONF_RESET_DURATION])) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index a2ca311b30..3214f932bf 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -38,6 +38,10 @@ bool EPaperBase::init_buffer_(size_t buffer_length) { } void EPaperBase::setup_pins_() const { + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } this->dc_pin_->setup(); // OUTPUT this->dc_pin_->digital_write(false); diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 2992ca5afd..8e2fd78e62 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -50,6 +50,7 @@ class EPaperBase : public Display, float get_setup_priority() const override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; } + void set_enable_pins(std::vector enable_pins) { this->enable_pins_ = std::move(enable_pins); } void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; } void set_transform(uint8_t transform) { this->transform_ = transform; @@ -177,6 +178,7 @@ class EPaperBase : public Display, GPIOPin *dc_pin_{}; GPIOPin *busy_pin_{}; GPIOPin *reset_pin_{}; + std::vector enable_pins_{}; bool waiting_for_idle_{}; uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index bad33a6a02..13f1035045 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -10,11 +10,11 @@ class SSD1677(EpaperModel): # fmt: off def get_init_sequence(self, config: dict): - width, _height = self.get_dimensions(config) + _width, height = self.get_dimensions(config) return ( (0x18, 0x80), # Select internal Temp sensor (0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2 - (0x01, (width - 1) % 256, (width - 1) // 256, 0x02), # Set column gate limit + (0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1) (0x3C, 0x01), # Set border waveform (0x11, 3), # Set transform ) @@ -51,3 +51,16 @@ ssd1677.extend( height=480, mirror_x=True, ) + +ssd1677.extend( + "seeed-reterminal-sticky", + width=800, + height=480, + mirror_x=True, + enable_pin=47, + cs_pin=15, + dc_pin=16, + reset_pin=17, + busy_pin=18, + data_rate="10MHz", +) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7e7b127814..ec33d9d271 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable, Iterable import contextlib from dataclasses import dataclass import itertools @@ -6,6 +7,7 @@ import os from pathlib import Path import re import subprocess +from typing import Any from esphome import yaml_util import esphome.codegen as cg @@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.espidf.component import generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache @@ -66,6 +69,7 @@ from .const import ( KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, KEY_REPO, @@ -496,6 +500,32 @@ def get_esp32_variant(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT] +def variant_filtered_enum( + by_variant: dict[str, Iterable[Any]], **kwargs: Any +) -> Callable[[Any], Any]: + """Build a ``one_of`` validator whose valid set depends on the active variant. + + ``by_variant`` maps each ESP32 variant constant to the iterable of values that + are valid on that variant. At validation time the value is checked against the + set allowed for the current target variant. For schema extraction the inverted + ``{value: [variants, ...]}`` map is returned instead, so the language-schema + dump can tag every option with the variants that accept it and frontends can + filter to the user's selected variant. + """ + by_value: dict[str, list[str]] = {} + for variant, values in by_variant.items(): + for value in values: + by_value.setdefault(str(value), []).append(variant) + + @schema_extractor("variant_enum") + def validator(value: Any) -> Any: + if value is SCHEMA_EXTRACT: + return by_value + return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value) + + return validator + + def get_board(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD] @@ -568,6 +598,59 @@ def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType): CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS][name] = value +@dataclass +class NetworkSdkconfigData: + """Inputs for the network-related esp32 sdkconfig flags, reconciled at FINAL. + + Components call the request_*() helpers below (and esp32's own to_code fills + in enable_lwip_dhcp_server) instead of setting the WiFi/Ethernet/Bluetooth + sdkconfig flags directly; the single _reconcile_network_sdkconfig() coroutine + then decides the final values so they no longer depend on call order. + """ + + wifi: bool = False # WiFi component active (STA and/or AP) + wifi_ap: bool = False # WiFi AP mode configured + ethernet: bool = False # Ethernet component active + bluetooth: bool = False # any BLE component active + ble_42: bool = False # BLE 4.2 features needed + software_coexistence: bool = False # WiFi/BT software coexistence requested + # esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset) + enable_lwip_dhcp_server: bool | None = None + + +def _network_sdkconfig() -> NetworkSdkconfigData: + data = CORE.data[KEY_ESP32] + if KEY_NETWORK_SDKCONFIG not in data: + data[KEY_NETWORK_SDKCONFIG] = NetworkSdkconfigData() + return data[KEY_NETWORK_SDKCONFIG] + + +def request_wifi(ap: bool = False) -> None: + """Request the WiFi stack. Pass ap=True when AP mode is configured.""" + net = _network_sdkconfig() + net.wifi = True + if ap: + net.wifi_ap = True + + +def request_ethernet() -> None: + """Request the Ethernet stack.""" + _network_sdkconfig().ethernet = True + + +def request_bluetooth(ble_42: bool = False) -> None: + """Request the Bluetooth controller. Pass ble_42=True for 4.2 features.""" + net = _network_sdkconfig() + net.bluetooth = True + if ble_42: + net.ble_42 = True + + +def request_software_coexistence() -> None: + """Request WiFi/BT software coexistence (only valid alongside WiFi).""" + _network_sdkconfig().software_coexistence = True + + def add_idf_component( *, name: str, @@ -935,7 +1018,7 @@ def _resolve_toolchain(value: ConfigType) -> ConfigType: # Runs before _detect_variant so downstream validators can rely on # CORE.toolchain instead of re-resolving it from the config dict. if CORE.toolchain is None: - CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF) return value @@ -1506,65 +1589,12 @@ FRAMEWORK_SCHEMA = cv.Schema( ) -# Remove this class in 2026.7.0 -class _FrameworkMigrationWarning: - shown = False - - -def _show_framework_migration_message(name: str, variant: str) -> None: - """Show a message about the framework default change and how to switch back to Arduino.""" - # Remove this function in 2026.7.0 - if _FrameworkMigrationWarning.shown: - return - _FrameworkMigrationWarning.shown = True - - from esphome.log import AnsiFore, color - - message = ( - color( - AnsiFore.BOLD_CYAN, - f"💡 NOTICE: {name} does not have a framework specified.", - ) - + "\n\n" - + f"Starting with ESPHome 2026.1.0, the default framework for {variant} is ESP-IDF.\n" - + "(We've been warning about this change since ESPHome 2025.8.0)\n" - + "\n" - + "Why we made this change:\n" - + color(AnsiFore.GREEN, " ✨ Smaller firmware binaries\n") - + color(AnsiFore.GREEN, " ⚡ Faster compile times\n") - + color(AnsiFore.GREEN, " 🚀 Better performance and newer features\n") - + color(AnsiFore.GREEN, " 🔧 More actively maintained by ESPHome\n") - + "\n" - + "To continue using Arduino, add this to your YAML under 'esp32:':\n" - + color(AnsiFore.WHITE, " framework:\n") - + color(AnsiFore.WHITE, " type: arduino\n") - + "\n" - + "To silence this message with ESP-IDF, explicitly set:\n" - + color(AnsiFore.WHITE, " framework:\n") - + color(AnsiFore.WHITE, " type: esp-idf\n") - + "\n" - + "Migration guide: " - + color( - AnsiFore.BLUE, - "https://esphome.io/guides/esp32_arduino_to_idf/", - ) - ) - _LOGGER.warning(message) - - def _set_default_framework(config): config = config.copy() if CONF_FRAMEWORK not in config: config[CONF_FRAMEWORK] = FRAMEWORK_SCHEMA({}) if CONF_TYPE not in config[CONF_FRAMEWORK]: - variant = config[CONF_VARIANT] config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ESP_IDF - # Show migration message for variants that previously defaulted to Arduino - # Remove this message in 2026.7.0 - if variant in ARDUINO_ALLOWED_VARIANTS: - _show_framework_migration_message( - config.get(CONF_NAME, "This device"), variant - ) return config @@ -1615,8 +1645,14 @@ FLASH_SIZES = [ ] CONF_FLASH_SIZE = "flash_size" +CONF_FLASH_MODE = "flash_mode" +CONF_FLASH_FREQUENCY = "flash_frequency" CONF_CPU_FREQUENCY = "cpu_frequency" CONF_PARTITIONS = "partitions" +FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"] +FLASH_FREQUENCIES = [ + f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16) +] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -1630,6 +1666,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of( *FLASH_SIZES, upper=True ), + cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True), + cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( + *FLASH_FREQUENCIES, upper=True + ), cv.Optional(CONF_PARTITIONS): cv.Any( cv.file_, cv.ensure_list( @@ -1808,6 +1848,61 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_network_sdkconfig() -> None: + """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. + + Single decision point for flags that multiple components used to set + directly (and sometimes with conflicting values). Runs at FINAL priority so + every request_*() call (made from the various components' to_code at their + own priorities) is seen first. A user-supplied sdkconfig_options value + always takes precedence. + """ + net = CORE.data[KEY_ESP32].get(KEY_NETWORK_SDKCONFIG, NetworkSdkconfigData()) + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + is_arduino = CORE.using_arduino + + def set_opt(name: str, value: SdkconfigValueType) -> None: + # User sdkconfig_options (applied during to_code) win. + if name not in opts: + add_idf_sdkconfig_option(name, value) + + # Bluetooth: only ever enable when requested. The IDF default is off and + # nothing sets these False today, so never write False here. + if net.bluetooth: + set_opt("CONFIG_BT_ENABLED", True) + if net.ble_42: + set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + + # WiFi stack: disable only when Ethernet is present and WiFi is not. WiFi + # relies on the IDF default (enabled), so it is never written True here. + wifi_disabled = net.ethernet and not net.wifi + if wifi_disabled: + set_opt("CONFIG_ESP_WIFI_ENABLED", False) + + # Software coexistence: enable when requested (the schema only allows it + # alongside WiFi). Disable only in the Ethernet-without-WiFi case. + if net.software_coexistence: + set_opt("CONFIG_SW_COEXIST_ENABLE", True) + elif wifi_disabled: + set_opt("CONFIG_SW_COEXIST_ENABLE", False) + + # SoftAP support: drop it when WiFi is used without AP mode (IDF only). + if not is_arduino and net.wifi and not net.wifi_ap: + set_opt("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) + + # LWIP DHCP server: a WiFi-AP-mode / enable_lwip_dhcp_server concern (not + # coexistence). Disable when WiFi has no AP (IDF) or the enable_lwip_dhcp_server + # option is set to false, unless Arduino+Ethernet needs the symbols to compile. + wifi_wants_dhcps_off = not is_arduino and net.wifi and not net.wifi_ap + dhcp_server_disabled_by_option = net.enable_lwip_dhcp_server is False + arduino_eth_exclusion = is_arduino and net.ethernet + if ( + wifi_wants_dhcps_off or dhcp_server_disabled_by_option + ) and not arduino_eth_exclusion: + set_opt("CONFIG_LWIP_DHCPS", False) + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_yaml_idf_components(components: list[ConfigType]): """Add IDF components from YAML config with final priority to override code-added components.""" @@ -1866,6 +1961,12 @@ async def to_code(config): "board_upload.maximum_size", int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, ) + if flash_mode := config.get(CONF_FLASH_MODE): + cg.add_platformio_option("board_build.flash_mode", flash_mode) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + cg.add_platformio_option( + "board_build.f_flash", f"{flash_frequency[:-3]}000000L" + ) if CONF_SOURCE in conf: cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) @@ -2016,6 +2117,14 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) + if flash_mode := config.get(CONF_FLASH_MODE): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True + ) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True + ) # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 # from y to n. PlatformIO uses sections.ld.in (for rev <3) or @@ -2118,14 +2227,12 @@ async def to_code(config): for component_name in advanced.get(CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, []): include_builtin_idf_component(component_name) - # DHCP server: only disable if explicitly set to false - # WiFi component handles its own optimization when AP mode is not used - # When using Arduino with Ethernet, DHCP server functions must be available - # for the Network library to compile, even if not actively used - if advanced.get(CONF_ENABLE_LWIP_DHCP_SERVER) is False and not ( - conf[CONF_TYPE] == FRAMEWORK_ARDUINO and "ethernet" in CORE.loaded_integrations - ): - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + # DHCP server (CONFIG_LWIP_DHCPS) is reconciled in _reconcile_network_sdkconfig + # together with the WiFi component's own AP-mode optimization; record the user's + # advanced tristate (True/False/None) for it to consume at FINAL priority. + _network_sdkconfig().enable_lwip_dhcp_server = advanced.get( + CONF_ENABLE_LWIP_DHCP_SERVER + ) if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: @@ -2344,6 +2451,9 @@ async def to_code(config): # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call CORE.add_job(_set_libc_picolibc_newlib_compat) + # FINAL priority: runs after every network/coexistence request_*() call + CORE.add_job(_reconcile_network_sdkconfig) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 6062631d98..729b0c89ab 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1240,6 +1240,43 @@ ESP32_BOARD_PINS = { "LED_BUILTINB": 4, }, "sensesiot_weizen": {}, + # Source: https://wiki.seeedstudio.com/XIAO_ESP32C3_Getting_Started/ + # The XIAO ESP32-C3 has no user-controllable LED (only a hardwired charge + # LED), so LED/LED_BUILTIN are intentionally omitted. The Ax keys override + # the incorrect ESP32_BASE_PINS A* fallback (which otherwise makes pin: A0 + # resolve to phantom GPIO36 and pin: A1/A2 raise cv.Invalid). + "seeed_xiao_esp32c3": { + "D0": 2, + "D1": 3, + "D2": 4, + "D3": 5, + "D4": 6, + "D5": 7, + "D6": 21, + "D7": 20, + "D8": 8, + "D9": 9, + "D10": 10, + "MTDO": 7, + "MTCK": 6, + "MTDI": 5, + "MTMS": 4, + "BOOT": 9, + "TX": 21, + "RX": 20, + "SDA": 6, + "SCL": 7, + "SCK": 8, + "MISO": 9, + "MOSI": 10, + "A0": 2, + "A1": 3, + "A2": 4, + "A3": 5, + }, + # Source: https://wiki.seeedstudio.com/xiao_esp32c6_getting_started/ + # The Ax keys override the incorrect ESP32_BASE_PINS A* fallback (which + # otherwise makes pin: A0 resolve to phantom GPIO36). "seeed_xiao_esp32c6": { "D0": 0, "D1": 1, @@ -1257,10 +1294,59 @@ ESP32_BOARD_PINS = { "MTDI": 5, "MTMS": 4, "BOOT": 9, - "LED": 8, - "LED_BUILTIN": 8, + "LED": 15, # Bugfix: was GPIO8; the yellow user LED is GPIO15 + "LED_BUILTIN": 15, # Bugfix: was GPIO8; the yellow user LED is GPIO15 "RF_SWITCH_EN": 3, "RF_ANT_SELECT": 14, + "TX": 16, + "RX": 17, + "SDA": 22, + "SCL": 23, + "SCK": 19, + "MISO": 20, + "MOSI": 18, + "A0": 0, + "A1": 1, + "A2": 2, + }, + # Source: https://wiki.seeedstudio.com/xiao_esp32s3_getting_started/ + # LED (GPIO21) is active-LOW; BOOT=GPIO0 is the standard ESP32-S3 strapping + # pin. The Ax keys override the incorrect ESP32_BASE_PINS A* fallback for the + # published silkscreen set. A6/A7 are intentionally absent (D6/D7 = GPIO43/44 + # have no ADC); because ESP32_BASE_PINS already defines A6=34/A7=35, pin: A6/A7 + # still resolve to those classic-ESP32 phantom values via the base-pins + # fallback (a disclosed residual, not fixable without editing ESP32_BASE_PINS). + "seeed_xiao_esp32s3": { + "D0": 1, + "D1": 2, + "D2": 3, + "D3": 4, + "D4": 5, + "D5": 6, + "D6": 43, + "D7": 44, + "D8": 7, + "D9": 8, + "D10": 9, + "BOOT": 0, + "LED": 21, + "LED_BUILTIN": 21, + "TX": 43, + "RX": 44, + "SDA": 5, + "SCL": 6, + "SCK": 7, + "MISO": 8, + "MOSI": 9, + "A0": 1, + "A1": 2, + "A2": 3, + "A3": 4, + "A4": 5, + "A5": 6, + "A8": 7, + "A9": 8, + "A10": 9, }, "sg-o_airMon": {}, "sparkfun_lora_gateway_1-channel": {"MISO": 12, "MOSI": 13, "SCK": 14, "SS": 16}, diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 322054ea91..83fcfd233e 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -16,6 +16,7 @@ KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" KEY_IDF_VERSION = "idf_version" +KEY_NETWORK_SDKCONFIG = "network_sdkconfig" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index b329f6b82b..f1a38f9e76 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env): flash_size = env.BoardConfig().get("upload.flash_size", "4MB") chip = env.BoardConfig().get("build.mcu", "esp32") + # PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right + # artifact names and partition offsets, including custom partition tables). The merge + # below is only a fallback and cannot honor custom layouts, so don't overwrite an image + # PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt, + # and PlatformIO's combined-image builder runs before us in that batch, so an existing + # file here is current. + output_path = firmware_path.with_suffix(".factory.bin") + if output_path.exists(): + print(f"{output_path.name} already created by PlatformIO - skipping merge") + return + sections = [] flasher_args_path = build_dir / "flasher_args.json" @@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env): print("No valid flash sections found — skipping .factory.bin creation.") return - output_path = firmware_path.with_suffix(".factory.bin") python_exe = f'"{env.subst("$PYTHONEXE")}"' cmd = [ python_exe, diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script index af12275a0b..8728e02a34 100644 --- a/esphome/components/esp32/pre_build.py.script +++ b/esphome/components/esp32/pre_build.py.script @@ -1,3 +1,5 @@ +import os + Import("env") # noqa: F821 # Remove custom_sdkconfig from the board config as it causes @@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board: del board._manifest["espidf"]["custom_sdkconfig"] if not board._manifest["espidf"]: del board._manifest["espidf"] + +# Referenced by rules in esphome/idf_component.yml; an unset env var is a +# fatal error there. Always 0: in PlatformIO builds arduino is not a managed +# IDF component. +os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0") diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c7b6b40394..c9fb42fde4 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -8,7 +8,12 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_USE_PSRAM -from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + const, + get_esp32_variant, + request_bluetooth, +) from esphome.components.esp32.const import VARIANT_ESP32C2 import esphome.config_validation as cv from esphome.const import ( @@ -599,8 +604,7 @@ async def to_code(config): max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + request_bluetooth(ble_42=True) # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for # heap allocations and use dynamic (heap-based) environment memory tables diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 8052c13596..7a59cce19b 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -1,6 +1,6 @@ import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import CONF_BLE_ID import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TX_POWER, CONF_TYPE, CONF_UUID @@ -86,5 +86,4 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + request_bluetooth(ble_42=True) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index d45f2d9df2..ea2a9667d7 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,7 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv from esphome.config_validation import UNDEFINED @@ -632,7 +632,7 @@ async def to_code(config): ) cg.add_define("USE_ESP32_BLE_SERVER") cg.add_define("USE_ESP32_BLE_ADVERTISING") - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + request_bluetooth() @automation.register_action( diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index d758b400c4..e4139bed65 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -6,7 +6,11 @@ import logging from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble, ota -from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + request_bluetooth, + request_software_coexistence, +) from esphome.components.esp32_ble import ( IDF_MAX_CONNECTIONS, BTLoggers, @@ -315,9 +319,9 @@ async def to_code(config): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) - add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) + request_bluetooth() if config.get(CONF_SOFTWARE_COEXISTENCE): - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", True) + request_software_coexistence() # https://github.com/espressif/esp-idf/issues/4101 # https://github.com/espressif/esp-idf/issues/2503 # Match arduino CONFIG_BTU_TASK_STACK_SIZE diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 94e20ea6c9..7f420f27d8 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -257,7 +257,7 @@ async def to_code(config): esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 183820256f..e6fcc018d9 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -338,6 +338,14 @@ void ESP32ImprovComponent::process_incoming_data_() { this->incoming_data_.clear(); return; } + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled, so we can't provision. Respond immediately + // instead of letting the client wait out its provisioning timeout. + ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); + this->incoming_data_.clear(); + return; + } wifi::WiFiAP sta{}; sta.set_ssid(command.ssid.c_str()); sta.set_password(command.password.c_str()); diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index dd10a32fd6..db94f0ec6d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -492,6 +492,15 @@ def _parse_register(config, regex, line): STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") +# Structured crash handler output (crash_handler.cpp) from a previous boot: +# PC: 0x40220060 +# EXCVADDR: 0x0000008A +# BT0: 0x40212345 +STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile( + r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})" +) +STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state): "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") ) - # ESP8266 PC/EXCVADDR + # ESP8266 PC/EXCVADDR (legacy Arduino postmortem) _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) + # ESP8266 structured crash handler (crash_handler.cpp) from previous boot + _parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line) + _parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line) + match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) if match is not None: diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 784f5dee8c..f6afc30ff2 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -540,6 +540,7 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: add_idf_sdkconfig_option, idf_version, include_builtin_idf_component, + request_ethernet, ) if config[CONF_TYPE] in SPI_ETHERNET_TYPES: @@ -586,10 +587,9 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: ) cg.add(var.add_phy_register(reg)) - # Disable WiFi when using Ethernet to save memory - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) - # Also disable WiFi/BT coexistence since WiFi is disabled - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + # Register Ethernet with the esp32 sdkconfig reconciler, which disables the + # WiFi stack and WiFi/BT coexistence when Ethernet is used without WiFi. + request_ethernet() # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) include_builtin_idf_component("esp_eth") diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index d99dffdc08..a26a235da7 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -50,6 +50,11 @@ async def new_fastled_light(config): ref="d44c800a9e876a8394caefc2ce4915dd96dac77b", ) cg.add_library("SPI", None) + # FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with + # esphome's RMT channels (remote_transmitter etc., priority 0): the IDF + # driver rejects FastLED's channel and show() then hangs ~3s with no + # output. Override to 0 so it shares the interrupt. See #17063. + cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0") else: cg.add_library("fastled/FastLED", "3.9.16") await light.register_light(var, config) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 206df2c844..4ee703f363 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -22,7 +22,9 @@ void ImprovSerialComponent::setup() { if (wifi::global_wifi_component->has_sta()) { this->state_ = improv::STATE_PROVISIONED; - } else { + } else if (!wifi::global_wifi_component->is_disabled()) { + // Respect Wi-Fi's disabled state; forcing a scan while disabled throws + // the wifi component into an invalid state from which it cannot recover. wifi::global_wifi_component->start_scanning(); } } @@ -230,6 +232,13 @@ bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) { bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command) { switch (command.command) { case improv::WIFI_SETTINGS: { + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled, so we can't provision. Respond immediately + // instead of letting the client wait out its provisioning timeout. + ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); + return true; + } wifi::WiFiAP sta{}; sta.set_ssid(command.ssid.c_str()); sta.set_password(command.password.c_str()); @@ -245,6 +254,14 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command return true; } case improv::GET_CURRENT_STATE: + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled; report the Improv "stopped" state so a client can tell + // the user that provisioning is unavailable. Reported transiently without + // disturbing our internal provisioning state machine, so a later `wifi.enable` + // still reports the correct state. + this->send_current_state_(improv::STATE_STOPPED); + return true; + } this->set_state_(this->state_); if (this->state_ == improv::STATE_PROVISIONED) { std::vector url = this->build_rpc_settings_response_(improv::GET_CURRENT_STATE); @@ -299,6 +316,10 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command void ImprovSerialComponent::set_state_(improv::State state) { this->state_ = state; + this->send_current_state_(state); +} + +void ImprovSerialComponent::send_current_state_(improv::State state) { this->tx_header_[TX_TYPE_IDX] = TYPE_CURRENT_STATE; this->tx_header_[TX_DATA_IDX] = state; this->write_data_(); diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index c58c42f0d8..70f9214e2d 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -57,6 +57,7 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { bool parse_improv_payload_(improv::ImprovCommand &command); void set_state_(improv::State state); + void send_current_state_(improv::State state); void set_error_(improv::Error error); void send_response_(std::vector &response); void on_wifi_connect_timeout_(); diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index a035525101..684da0202e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -175,6 +175,10 @@ void Logger::process_messages_() { #ifdef USE_ESPHOME_TASK_LOG_BUFFER // Process any buffered messages when available if (this->log_buffer_.has_messages()) { + // Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering + // and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here. + // Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path. + RecursionGuard guard(this->main_task_recursion_guard_); logger::TaskLogBuffer::LogMessage *message; uint16_t text_length; while (this->log_buffer_.borrow_message_main_loop(message, text_length)) { diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 022d629960..9137412abe 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -75,10 +76,14 @@ from .schemas import ( BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, + SET_STATE_SCHEMA, + STATE_SCHEMA, STYLE_REMAP, + STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, container_schema, + container_schema_value, obj_dict, ) from .styles import styles_to_code, theme_to_code @@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA page_spec, ) +# These style schemas live in .schemas but are imported here so they land in +# this module's namespace, where script/build_language_schema.py registers them +# as *named* schemas and emits `extends` references — instead of inlining the +# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the +# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in +# this file; this tuple keeps the imports live (and self-documents why). +_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA) + # Widget registration happens via WidgetType.__init__ in individual widget files # The imports below trigger creation of the widget types # Action registration (lvgl.{widget}.update) happens automatically @@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict: FINAL_VALIDATE_SCHEMA = final_validation -LVGL_SCHEMA = cv.All( - container_schema( - obj_spec, - cv.polling_component_schema("1s") - .extend( - { - **{ - cv.Optional(event): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) - ), - } - ) - for event in df.LV_SCREEN_EVENT_TRIGGERS - + df.LV_DISPLAY_EVENT_TRIGGERS - }, - cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), - cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), - cv.GenerateID(df.CONF_DISPLAYS): display_schema, - cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), - cv.Optional( - df.CONF_DEFAULT_FONT, default="montserrat_14" - ): lvalid.lv_font, - cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, - cv.Optional( - df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False - ): cv.boolean, - cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, - cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(CONF_ROTATION): validate_rotation, - cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( - *df.LV_LOG_LEVELS, upper=True - ), - cv.Optional(CONF_BYTE_ORDER): cv.one_of( - "big_endian", "little_endian", lower=True - ), - cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( - FULL_STYLE_SCHEMA - ) - ), - cv.Optional(CONF_ON_IDLE): validate_automation( +# The options accepted at the top level of an `lvgl:` block, on top of the base +# object schema that `container_schema(obj_spec, ...)` supplies. Held in a +# module-level name (rather than inline) so the schema-extractor wrapper on +# CONFIG_SCHEMA below can hand the language-schema dumper the same composed +# schema the runtime validates against. +LVGL_TOP_LEVEL_SCHEMA = ( + cv.polling_component_schema("1s") + .extend( + { + **{ + cv.Optional(event): validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), - cv.Required(CONF_TIMEOUT): cv.templatable( - cv.positive_time_period_milliseconds + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) ), } - ), - cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), - **{ - cv.Optional(x): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), - }, - single=True, - ) - for x in SIMPLE_TRIGGERS - }, - cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), - cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, - cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), - cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), - cv.Optional( - df.CONF_TRANSPARENCY_KEY, default=0x000400 - ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, - cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, - cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, - cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, - cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, - cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), - cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, - } - ) - .extend(DISP_BG_SCHEMA), - ), + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS + }, + cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), + cv.GenerateID(df.CONF_DISPLAYS): display_schema, + cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, + cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, + cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, + cv.Optional(CONF_ROTATION): validate_rotation, + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( + *df.LV_LOG_LEVELS, upper=True + ), + cv.Optional(CONF_BYTE_ORDER): cv.one_of( + "big_endian", "little_endian", lower=True + ), + cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( + cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( + FULL_STYLE_SCHEMA + ) + ), + cv.Optional(CONF_ON_IDLE): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), + cv.Required(CONF_TIMEOUT): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), + cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, + cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, + cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + cv.Optional(df.CONF_THEME): _theme_schema, + cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, + cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, + cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, + cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, + cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), + cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + } + ) + .extend(DISP_BG_SCHEMA) +) + + +LVGL_SCHEMA = cv.All( + container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, ) +@schema_extractor("schema") def lvgl_config_schema(config): """ Can't use cv.ensure_list here because it converts an empty config to an empty list, rather than a default config. """ + if config is SCHEMA_EXTRACT: + # CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema + # closure, so the language-schema dumper can't see the top-level `lvgl:` + # fields (it would emit an empty schema). Hand it the same composed + # obj + top-level schema the runtime validates against, plus the + # `widgets:` key (added per-value by append_layout_schema at runtime, so + # otherwise invisible to the dumper). Validation of real configs (the + # branches below) is unchanged. + return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend( + {cv.Optional(df.CONF_WIDGETS): any_widget_schema()} + ) if not config or isinstance(config, dict): return [LVGL_SCHEMA(config)] return cv.Schema([LVGL_SCHEMA])(config) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bdaa91f15c..d7df628907 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -22,7 +22,11 @@ from esphome.const import ( ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger -from esphome.schema_extractors import EnableSchemaExtraction +from esphome.schema_extractors import ( + SCHEMA_EXTRACT, + EnableSchemaExtraction, + schema_extractor, +) from . import defines as df, lv_validation as lvalid from .defines import ( @@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[ ] = {} +def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema: + """ + Build the static schema that :func:`container_schema` validates against, i.e. + everything except the value-dependent ``append_layout_schema`` applied at + validation time. + + Factored out and exposed so the language-schema dumper can extract a + representative schema for a widget — and for the top-level ``lvgl:`` block, + whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the + :func:`container_schema` validator closure. + """ + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + return schema.extend(widget_type.schema) + + def container_schema( widget_type: WidgetType, extras: Any = None ) -> Callable[[Any], Any]: @@ -649,12 +672,7 @@ def container_schema( def get_schema() -> cv.Schema: nonlocal cached_schema if cached_schema is None: - schema = obj_schema(widget_type).extend( - {cv.GenerateID(): cv.declare_id(widget_type.w_type)} - ) - if extras: - schema = schema.extend(extras) - cached_schema = schema.extend(widget_type.schema) + cached_schema = container_schema_value(widget_type, extras) return cached_schema def validator(value: Any) -> Any: @@ -678,7 +696,23 @@ def any_widget_schema(extras=None): :return: A validator for the Widgets key """ + @schema_extractor("schema") def validator(value): + if value is SCHEMA_EXTRACT: + # The widgets: list is built per-value at validation time, so the + # language-schema dumper sees nothing. Enumerate every registered + # widget type as an optional key (a widget item is really a + # single-key mapping; over-listing them lets editors complete any + # widget — `esphome config` enforces exactly one). extras carries the + # layout child options where applicable. + return cv.ensure_list( + cv.Schema( + { + cv.Optional(name): container_schema_value(widget_type, extras) + for name, widget_type in WIDGET_TYPES.items() + } + ) + ) if isinstance(value, dict): # Convert to list is_dict = True diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index c3b744c919..129befe600 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay +CONF_PAD_HEIGHT = "pad_height" +CONF_PAD_WIDTH = "pad_width" CONF_PIXEL_MODE = "pixel_mode" CONF_USE_AXIS_FLIPS = "use_axis_flips" @@ -202,6 +204,8 @@ def dimension_schema(rounding): rounding ), cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), + cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding), + cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding), } ), ) @@ -311,6 +315,36 @@ class DriverChip: name = name.upper() self.name = name self.initsequence = initsequence + if CONF_NATIVE_WIDTH in defaults: + if CONF_WIDTH not in defaults: + defaults[CONF_WIDTH] = ( + defaults[CONF_NATIVE_WIDTH] + - defaults.get(CONF_OFFSET_WIDTH, 0) + - defaults.get(CONF_PAD_WIDTH, 0) + ) + else: + native_width = ( + defaults.get(CONF_WIDTH, 0) + + defaults.get(CONF_OFFSET_WIDTH, 0) + + defaults.get(CONF_PAD_WIDTH, 0) + ) + if native_width != 0: + defaults[CONF_NATIVE_WIDTH] = native_width + if CONF_NATIVE_HEIGHT in defaults: + if CONF_HEIGHT not in defaults: + defaults[CONF_HEIGHT] = ( + defaults[CONF_NATIVE_HEIGHT] + - defaults.get(CONF_OFFSET_HEIGHT, 0) + - defaults.get(CONF_PAD_HEIGHT, 0) + ) + else: + native_height = ( + defaults.get(CONF_HEIGHT, 0) + + defaults.get(CONF_OFFSET_HEIGHT, 0) + + defaults.get(CONF_PAD_HEIGHT, 0) + ) + if native_height != 0: + defaults[CONF_NATIVE_HEIGHT] = native_height self.defaults = defaults DriverChip.models[name] = self @@ -336,18 +370,6 @@ class DriverChip: initsequence = list(kwargs.pop("initsequence", self.initsequence)) initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() - if ( - CONF_WIDTH in defaults - and CONF_OFFSET_WIDTH in kwargs - and CONF_NATIVE_WIDTH not in defaults - ): - defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] - if ( - CONF_HEIGHT in defaults - and CONF_OFFSET_HEIGHT in kwargs - and CONF_NATIVE_HEIGHT not in defaults - ): - defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) @@ -385,13 +407,16 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + def get_dimensions( + self, config, swap: bool = True + ) -> tuple[int, int, int, int, int, int]: """ Return the dimensions of the current model. :param config: The current configuration :param swap: If width/height should be swapped when axes are swapped. - :return: + :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] @@ -400,33 +425,71 @@ class DriverChip: height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] offset_height = dimensions[CONF_OFFSET_HEIGHT] - return width, height, offset_width, offset_height - (width, height) = dimensions - return width, height, 0, 0 + if CONF_PAD_WIDTH in dimensions: + pad_width = dimensions[CONF_PAD_WIDTH] + native_width = width + offset_width + pad_width + else: + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + if native_width == 0: + pad_width = 0 + native_width = width + offset_width + else: + pad_width = native_width - width - offset_width + if CONF_PAD_HEIGHT in dimensions: + pad_height = dimensions[CONF_PAD_HEIGHT] + native_height = height + offset_height + pad_height + else: + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if native_height == 0: + pad_height = 0 + native_height = height + offset_height + else: + pad_height = native_height - height - offset_height + if ( + pad_width + offset_width >= native_width + or pad_height + offset_height >= native_height + ): + raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS]) + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS]) + + return width, height, offset_width, offset_height, pad_width, pad_height + + # Must be a tuple + width, height = dimensions + return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) offset_width = self.get_default(CONF_OFFSET_WIDTH, 0) offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0) + pad_width = self.get_default( + CONF_PAD_WIDTH, native_width - width - offset_width + ) + pad_height = self.get_default( + CONF_PAD_HEIGHT, native_height - height - offset_height + ) + + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS]) # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where # the offset is asymmetric if transform.get(CONF_MIRROR_X): - native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) - offset_width = native_width - width - offset_width + offset_width, pad_width = pad_width, offset_width if transform.get(CONF_MIRROR_Y): - native_height = self.get_default( - CONF_NATIVE_HEIGHT, height + offset_height * 2 - ) - offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + offset_height, pad_height = pad_height, offset_height + # Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height - return width, height, offset_width, offset_height + pad_width, pad_height = pad_height, pad_width + return width, height, offset_width, offset_height, pad_width, pad_height def get_base_transform(self, config): transform = config.get( @@ -450,20 +513,8 @@ class DriverChip: def get_transform(self, config) -> dict[str, bool]: transform = self.get_base_transform(config) - can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True + transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform def swap_xy_schema(self): @@ -498,8 +549,8 @@ class DriverChip: return madctl def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - # This takes into account rotation if it can be implemented in the transform + # Add the MADCTL command to the sequence based on the base configuration. + # Rotation is not applied here, it will be done at runtime. transform = self.get_transform(config) madctl = self.get_madctl(transform, config) sequence.append((MADCTL, madctl & 0xFF)) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 46e7a7d5a7..896140b4b1 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -172,7 +172,9 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -206,7 +208,9 @@ async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) sequence = model.get_sequence(config) diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 2298f76cd4..53fac9b534 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -71,6 +71,7 @@ DriverChip( swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ + (0x01,), (0x60, 0x71, 0x23, 0xa2), (0x60, 0x71, 0x23, 0xa3), (0x60, 0x71, 0x23, 0xa4), diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 3c33c26726..1eacc31fc5 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -235,7 +235,9 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) display.add_metadata( config[CONF_ID], width, @@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height) cg.add(var.set_model(model.name)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 8c6ffff500..abb7eaa458 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -27,7 +27,7 @@ from esphome.components.mipi import ( requires_buffer, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN -from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( @@ -121,7 +121,9 @@ def denominator(config): """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - _width, height, _offset_width, _offset_height = model.get_dimensions(config) + _width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) if frac is None or frac > 0.75 or height < 32: return 1 try: @@ -169,11 +171,22 @@ def model_schema(config): ] if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) + # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. + spi_mode = model.get_default(CONF_SPI_MODE) + if not spi_mode: + if bus_mode == TYPE_OCTAL or ( + bus_mode == TYPE_SINGLE + and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) + ): + spi_mode = "MODE3" + else: + spi_mode = "MODE0" + schema = ( display.FULL_DISPLAY_SCHEMA.extend( spi.spi_device_schema( cs_pin_required=False, - default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0", + default_mode=spi_mode, default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), mode=bus_mode, ) @@ -279,8 +292,8 @@ def customise_schema(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, _offset_width, _offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) display.add_metadata( config[CONF_ID], @@ -313,14 +326,17 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + # Always call this to check dimensions during validation + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) - width, height, _offset_width, _offset_height = model.get_dimensions(config) - buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here fraction = min(20000.0, buffer_size // 4) / buffer_size @@ -347,8 +363,8 @@ def get_instance(config): CONF_MIRROR_Y, CONF_SWAP_XY, } - width, height, offset_width, offset_height = model.get_dimensions( - config, not has_hardware_transform + width, height, offset_width, offset_height, pad_width, pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) @@ -374,6 +390,8 @@ def get_instance(config): height, offset_width, offset_height, + pad_width, + pad_height, madctl, has_hardware_transform, ] diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 5023cf8089..a594e48209 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -126,17 +131,6 @@ class MipiSpi : public display::Display, return HEIGHT; } - // If hardware rotation is in use, the actual display width/height changes with rotation - int get_width_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_width(); - return WIDTH; - } - int get_height_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_height(); - return HEIGHT; - } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -233,14 +227,25 @@ class MipiSpi : public display::Display, } void dump_config() override { - 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); + internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), + this->get_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); } protected: /* METHODS */ + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } // convenience functions to write commands with or without data void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); } void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); } @@ -330,20 +335,34 @@ class MipiSpi : public display::Display, this->write_command_(MADCTL_CMD, madctl); } - uint16_t get_offset_width_() { + uint16_t get_offset_width_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_HEIGHT; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return OFFSET_HEIGHT; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_270_DEGREES: + return PAD_HEIGHT; + default: + break; + } } return OFFSET_WIDTH; } - uint16_t get_offset_height_() { + uint16_t get_offset_height_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_WIDTH; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_HEIGHT; + case display::DISPLAY_ROTATION_270_DEGREES: + return OFFSET_WIDTH; + default: + break; + } } return OFFSET_HEIGHT; } @@ -396,7 +415,7 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8); } } else { - for (size_t y = 0; y != static_cast(h); y++) { + for (size_t y = 0; y != h; y++) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -492,19 +511,23 @@ class MipiSpi : public display::Display, * @tparam BUFFERPIXEL Color depth of the buffer * @tparam DISPLAYPIXEL Color depth of the display * @tparam BUS_TYPE The type of the interface bus (single, quad, octal) - * @tparam ROTATION The rotation of the display * @tparam WIDTH Width of the display in pixels * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer). * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template -class MipiSpiBuffer : public MipiSpi { + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, + uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> +class MipiSpiBuffer + : public MipiSpi { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and @@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi::dump_config(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi::setup(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup(); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index ae6accb907..5df7a275df 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -179,6 +179,9 @@ ILI9342 = DriverChip( # M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation ILI9341.extend( "M5CORE2", + # Reset native dimensions due to axis swap. + native_width=320, + native_height=240, width=320, height=240, mirror_x=False, @@ -786,3 +789,28 @@ ST7796.extend( dc_pin=0, invert_colors=True, ) + +ST7789V.extend( + "GEEKMAGIC-SMALLTV", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=2, + dc_pin=0, +) +ST7789V.extend( + "GEEKMAGIC-SMALLTV-PRO", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=4, + dc_pin=2, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee46f931de..3c719b0f5e 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -269,3 +269,16 @@ ST7789V.extend( cs_pin=14, dc_pin={"number": 15, "ignore_strapping_warning": True}, ) + +ST7789V.extend( + "WAVESHARE-ESP32-S3-GEEK", + cs_pin=10, + dc_pin=8, + reset_pin=9, + width=135, + height=240, + offset_width=52, + offset_height=40, + invert_colors=True, + data_rate="40MHz", +) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index f6e0f98857..492dfcaafe 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -14,7 +14,11 @@ DEPENDENCIES = ["uart"] modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) +ModbusServer = modbus_ns.class_("ModbusServerHub", Modbus) +ModbusClient = modbus_ns.class_("ModbusClientHub", Modbus) ModbusDevice = modbus_ns.class_("ModbusDevice") +ModbusClientDevice = modbus_ns.class_("ModbusClientDevice") +ModbusServerDevice = modbus_ns.class_("ModbusServerDevice") MULTI_CONF = True CONF_ROLE = "role" @@ -22,29 +26,43 @@ CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" CONF_TURNAROUND_TIME = "turnaround_time" -ModbusRole = modbus_ns.enum("ModbusRole") -MODBUS_ROLES = { - "client": ModbusRole.CLIENT, - "server": ModbusRole.SERVER, -} +MODBUS_ROLES = ["client", "server"] -CONFIG_SCHEMA = ( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(Modbus), - cv.Optional(CONF_ROLE, default="client"): cv.enum(MODBUS_ROLES), - cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - cv.Optional( - CONF_SEND_WAIT_TIME, default="250ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_TURNAROUND_TIME, default="100ms" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean, - } - ) - .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.typed_schema( + { + "client": cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusClient), + cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, + cv.Optional( + CONF_SEND_WAIT_TIME, default="2000ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_TURNAROUND_TIME, default="600ms" + ): cv.positive_time_period_milliseconds, + # Remove before 2026.10.0 + cv.Optional(CONF_DISABLE_CRC): cv.invalid( + "'disable_crc' has been removed. The parser no longer requires it — remove this option." + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + "server": cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusServer), + cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, + # Remove before 2026.10.0 + cv.Optional(CONF_DISABLE_CRC): cv.invalid( + "'disable_crc' has been removed. The parser no longer requires it — remove this option." + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + }, + key=CONF_ROLE, + default_type="client", ) @@ -55,19 +73,19 @@ async def to_code(config): await uart.register_uart_device(var, config) - cg.add(var.set_role(config[CONF_ROLE])) if CONF_FLOW_CONTROL_PIN in config: pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) cg.add(var.set_flow_control_pin(pin)) - cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) - cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) - cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC])) + if config[CONF_ROLE] == "client": + cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) + cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME])) -def modbus_device_schema(default_address): +def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"): + hub_type = ModbusClient if role == "client" else ModbusServer schema = { - cv.GenerateID(CONF_MODBUS_ID): cv.use_id(Modbus), + cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type), } if default_address is None: schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t @@ -98,8 +116,18 @@ def final_validate_modbus_device( ) -async def register_modbus_device(var, config): +async def register_modbus_client_device(var, config): + parent = await cg.get_variable(config[CONF_MODBUS_ID]) + cg.add(var.set_parent(parent)) + cg.add(var.set_address(config[CONF_ADDRESS])) + + +async def register_modbus_server_device(var, config): parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_parent(parent)) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) + + +async def register_modbus_device(var, config): + return await register_modbus_client_device(var, config) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 679ec34c0f..136fc73db6 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -37,9 +37,36 @@ void Modbus::setup() { } void Modbus::loop() { - // First process all available incoming data. - this->receive_and_parse_modbus_bytes_(); + // Receive any available bytes from UART + this->receive_bytes_(); + // Parse bytes into frames and process them + this->parse_modbus_frames(); +} + +void ModbusClientHub::loop() { + // Call base class to receive bytes and parse frames + this->Modbus::loop(); + + // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response + if (this->waiting_for_response_.has_value()) { + ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); + uint8_t expected_address = wfr.frame.data.get()[0]; + if (this->last_receive_check_ - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && + (this->rx_buffer_.empty() || this->rx_buffer_[0] != expected_address)) { + ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", expected_address, + this->last_receive_check_ - this->last_send_); + if (wfr.device) + wfr.device->on_modbus_no_response(); + this->waiting_for_response_.reset(); + } + } + + // If there's no response pending and there's commands in the buffer + this->send_next_frame_(); +} + +bool Modbus::timeout_() { // If the response frame is finished (including interframe delay) - we timeout. // The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts // when the buffer is filling the back half of the response @@ -47,250 +74,307 @@ void Modbus::loop() { (uint16_t) this->frame_delay_ms_, (uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_ : 0)); + + return this->last_receive_check_ - this->last_modbus_byte_ > timeout; +} + +int32_t Modbus::tx_delay_remaining() { // We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps // It's critical in all timestamp comparisons that the left timestamp comes before the right one in time // If we use a cached value in place of millis() and last_modbus_byte_ is updated inside our loop // then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout // So in this component we don't use any cached timestamp values to avoid these annoying bugs - if (millis() - this->last_modbus_byte_ > timeout) { - this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); - } + const uint32_t now = millis(); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ - (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ - (now - this->last_modbus_byte_))}); +} - // If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response - if (this->waiting_for_response_ != 0 && - millis() - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ && - (this->rx_buffer_.empty() || this->rx_buffer_[0] != this->waiting_for_response_)) { - ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send", - this->waiting_for_response_, millis() - this->last_send_); - this->waiting_for_response_ = 0; - } - - // If there's no response pending and there's commands in the buffer - this->send_next_frame_(); +int32_t ModbusClientHub::tx_delay_remaining() { + const uint32_t now = millis(); + return std::max({(int32_t) 0, + (int32_t) (this->last_send_tx_offset_ + this->frame_delay_ms_ + this->turnaround_delay_ms_ - + (now - this->last_send_)), + (int32_t) (this->frame_delay_ms_ + this->turnaround_delay_ms_ - (now - this->last_modbus_byte_))}); } bool Modbus::tx_blocked() { - const uint32_t now = millis(); - - // We block transmission in any of these case: + // We block transmission in any of these cases: // 1. There are bytes in the UART Rx buffer // 2. There are bytes in our Rx buffer - // 3. We're waiting for a response - // 4. The last sent byte isn't more than frame_delay ms ago (i.e. wait to tell receivers that our previous Tx is done) - // 5. The last received byte isn't more than frame_delay ms ago (i.e. wait to be sure there isn't more Rx coming) - // 6. If we're a client - also wait for the turnaround delay, to give the servers time to process the previous message - return this->available() || !this->rx_buffer_.empty() || (this->waiting_for_response_ != 0) || - (now - this->last_send_ < this->last_send_tx_offset_ + this->frame_delay_ms_ + - (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)) || - (now - this->last_modbus_byte_ < - this->frame_delay_ms_ + (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)); + // 3. The last sent byte isn't more than tx_delay ms ago (i.e. wait to tell receivers that our previous Tx is done) + // 4. The last received byte isn't more than tx_delay ms ago (i.e. wait to be sure there isn't more Rx coming) + // N.B. We allow a small delay (MODBUS_TX_MAX_DELAY_MS) to avoid looping on small delays. This gets handled by + // send_frame_. + return this->available() || !this->rx_buffer_.empty() || this->tx_delay_remaining() > MODBUS_TX_MAX_DELAY_MS; } -bool Modbus::tx_buffer_empty() { return this->tx_buffer_.empty(); } +bool ModbusClientHub::tx_blocked() { + // We block transmission in any of these case: + // 1. We're waiting for a response + // 2. Any of the base class tx_blocked conditions + return (this->waiting_for_response_.has_value()) || this->Modbus::tx_blocked(); +} -void Modbus::receive_and_parse_modbus_bytes_() { - // Read all available bytes in batches to reduce UART call overhead. - size_t avail = this->available(); - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; +bool ModbusClientHub::tx_buffer_empty() { return this->tx_buffer_.empty(); } + +void Modbus::receive_bytes_() { + this->last_receive_check_ = millis(); + size_t bytes = this->available(); + + if (bytes) { + size_t buffer_size = this->rx_buffer_.size(); + this->last_modbus_byte_ = this->last_receive_check_; + this->rx_buffer_.resize(buffer_size + bytes); + if (!this->read_array(this->rx_buffer_.data() + buffer_size, bytes)) { + this->rx_buffer_.resize(buffer_size); + return; } - avail -= to_read; - for (size_t i = 0; i < to_read; i++) { - if (this->rx_buffer_.empty()) { - ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i], - millis() - this->last_send_); - } else { - ESP_LOGVV(TAG, "Received byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i], - millis() - this->last_send_); - } - - // If the bytes in the rx buffer do not parse, clear out the buffer - if (!this->parse_modbus_byte_(buf[i])) { - this->clear_rx_buffer_(LOG_STR("parse failed"), true); - } - this->last_modbus_byte_ = millis(); + if (buffer_size == 0) { + ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) of %zu bytes %" PRIu32 "ms after last send", + this->rx_buffer_[0], this->rx_buffer_[0], this->rx_buffer_.size(), millis() - this->last_send_); } } } -bool Modbus::parse_modbus_byte_(uint8_t byte) { - size_t at = this->rx_buffer_.size(); - this->rx_buffer_.push_back(byte); - const uint8_t *raw = &this->rx_buffer_[0]; +void ModbusClientHub::parse_modbus_frames() { + if (!this->rx_buffer_.empty()) { + size_t size; + do { + size = this->rx_buffer_.size(); + if (!this->parse_modbus_server_frame_()) + this->clear_rx_buffer_(LOG_STR("parse failed"), true); + } while (!this->rx_buffer_.empty() && size > this->rx_buffer_.size()); + if (this->timeout_()) + this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); + } +} - // Byte 0: modbus address (match all) - if (at == 0) - return true; - // Byte 1: function code - if (at == 1) - return true; - // Byte 2: Size (with modbus rtu function code 4/3) - // See also https://en.wikipedia.org/wiki/Modbus - if (at == 2) - return true; - - uint8_t address = raw[0]; - uint8_t function_code = raw[1]; - - uint8_t data_len = raw[2]; - uint8_t data_offset = 3; - - // Per https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf Ch 5 User-Defined function codes - if (((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT) && - (function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END)) || - ((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT) && - (function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END))) { - // Handle user-defined function, since we don't know how big this ought to be, - // ideally we should delegate the entire length detection to whatever handler is - // installed, but wait, there is the CRC, and if we get a hit there is a good - // chance that this is a complete message ... admittedly there is a small chance is - // isn't but that is quite small given the purpose of the CRC in the first place - - data_len = at - 2; - data_offset = 1; - - uint16_t computed_crc = crc16(raw, data_offset + data_len); - uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8); - - if (computed_crc != remote_crc) - return true; - - ESP_LOGD(TAG, "User-defined function %02X found", function_code); - - } else { - // data starts at 2 and length is 4 for read registers commands - if (this->role == ModbusRole::SERVER) { - if (function_code == ModbusFunctionCode::READ_COILS || - function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS || - function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || - function_code == ModbusFunctionCode::READ_INPUT_REGISTERS || - function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - data_offset = 2; - data_len = 4; - } else if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - if (at < 6) { - return true; - } - data_offset = 2; - // starting address (2 bytes) + quantity of registers (2 bytes) + byte count itself (1 byte) + actual byte count - data_len = 2 + 2 + 1 + raw[6]; +void ModbusServerHub::parse_modbus_frames() { + while (!this->rx_buffer_.empty()) { + size_t size = this->rx_buffer_.size(); + ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size); + bool retry_as_client = false; + if (this->expecting_peer_response_ != 0) { + if (!this->parse_modbus_server_frame_()) { + ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse", + this->expecting_peer_response_); + this->expecting_peer_response_ = 0; + retry_as_client = true; + } else if (this->timeout_() && size == this->rx_buffer_.size()) { + // If we timed out and the above parse attempt did not consume data, stop expecting a response + ESP_LOGV(TAG, + "Stop expecting peer response from %" PRIu8 " due to timeout after partial response, and retry parse", + this->expecting_peer_response_); + this->expecting_peer_response_ = 0; + retry_as_client = true; } } else { - // the response for write command mirrors the requests and data starts at offset 2 instead of 3 for read commands - if (function_code == ModbusFunctionCode::WRITE_SINGLE_COIL || - function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - data_offset = 2; - data_len = 4; - } - } - - // Error ( msb indicates error ) - // response format: Byte[0] = device address, Byte[1] function code | 0x80 , Byte[2] exception code, Byte[3-4] crc - if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) { - data_offset = 2; - data_len = 1; - } - - // Byte data_offset..data_offset+data_len-1: Data - if (at < data_offset + data_len) - return true; - - // Byte 3+data_len: CRC_LO (over all bytes) - if (at == data_offset + data_len) - return true; - - // Byte data_offset+len+1: CRC_HI (over all bytes) - uint16_t computed_crc = crc16(raw, data_offset + data_len); - uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8); - if (computed_crc != remote_crc) { - if (this->disable_crc_) { - ESP_LOGD(TAG, "CRC check failed %" PRIu32 "ms after last send; ignoring", millis() - this->last_send_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc, - format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size())); - } else { - ESP_LOGW(TAG, "CRC check failed %" PRIu32 "ms after last send", millis() - this->last_send_); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc, - format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size())); - return false; - } + if (!this->parse_modbus_client_frame_()) + this->clear_rx_buffer_(LOG_STR("parse failed"), true); } + // Stop if the buffer didn't shrink (no frame consumed) and no mode switch triggered a retry + if (!retry_as_client && size <= this->rx_buffer_.size()) + break; } - std::vector data(this->rx_buffer_.begin() + data_offset, this->rx_buffer_.begin() + data_offset + data_len); - bool found = false; - for (auto *device : this->devices_) { - if (device->address_ == address) { - found = true; - if (this->role == ModbusRole::SERVER) { - if (function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || - function_code == ModbusFunctionCode::READ_INPUT_REGISTERS) { - device->on_modbus_read_registers(function_code, uint16_t(data[1]) | (uint16_t(data[0]) << 8), - uint16_t(data[3]) | (uint16_t(data[2]) << 8)); - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - device->on_modbus_write_registers(function_code, data); - } - } else { // We're a client - // Is it an error response? - if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) { - uint8_t exception = raw[2]; - ESP_LOGW(TAG, - "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 - "ms after last send", - function_code, exception, address, millis() - this->last_send_); - if (this->waiting_for_response_ == address) { - device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception); - } else { - // Ignore modbus exception not related to a pending command - ESP_LOGD(TAG, "Ignoring error - not expecting a response from %" PRIu8 "", address); - } - } else { // Not an error response - if (this->waiting_for_response_ == address) { - device->on_modbus_data(data); - } else { - // Ignore modbus response not related to a pending command - ESP_LOGW(TAG, "Ignoring response - not expecting a response from %" PRIu8 ", %" PRIu32 "ms after last send", - address, millis() - this->last_send_); - } - } - } - } + if (this->timeout_()) + this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true); +} + +uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const { + // Custom functions could be any length - we have to rely on the CRC to determine completeness. + // If a CRC match is never found, the buffer will eventually overflow and be cleared. + const uint8_t *raw = &this->rx_buffer_[0]; + const size_t size = this->rx_buffer_.size(); + for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) { + if (crc16(raw, len) == 0) + return len; + } + return 0; +} + +bool Modbus::parse_modbus_server_frame_() { + size_t size = this->rx_buffer_.size(); + uint16_t frame_length = helpers::server_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size()); + + if (size < frame_length) + return true; + + uint8_t address = this->rx_buffer_[0]; + uint8_t function_code = this->rx_buffer_[1]; + + if (helpers::is_function_code_custom(function_code)) { + frame_length = this->find_custom_frame_end_(frame_length); + if (frame_length == 0) + return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size + ESP_LOGD(TAG, "User-defined function %02X found", function_code); + } else { + if (crc16(&this->rx_buffer_[0], frame_length) != 0) + return false; } - if (!found && this->role == ModbusRole::CLIENT) { - ESP_LOGW(TAG, "Got frame from unknown address %" PRIu8 ", %" PRIu32 "ms after last send", address, - millis() - this->last_send_); - } + // Process before clearing: process_modbus_server_frame (receiving a response or peer message) never sends a reply + // synchronously. We can safely point directly into rx_buffer_ and avoid a copy. + uint8_t data_offset = helpers::server_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); + const uint8_t *data = this->rx_buffer_.data() + data_offset; + uint16_t data_len = frame_length - 2 - data_offset; - this->clear_rx_buffer_(LOG_STR("parse succeeded")); - - if (this->waiting_for_response_ == address) - this->waiting_for_response_ = 0; + this->process_modbus_server_frame(address, function_code, data, data_len); + this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); return true; } -void Modbus::send_next_frame_() { - if (this->tx_buffer_.empty()) +bool ModbusServerHub::parse_modbus_client_frame_() { + size_t size = this->rx_buffer_.size(); + uint16_t frame_length = helpers::client_frame_length(this->rx_buffer_.data(), this->rx_buffer_.size()); + + if (size < frame_length) + return true; + + uint8_t address = this->rx_buffer_[0]; + uint8_t function_code = this->rx_buffer_[1]; + + if (helpers::is_function_code_custom(function_code)) { + frame_length = this->find_custom_frame_end_(frame_length); + if (frame_length == 0) + return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size + ESP_LOGD(TAG, "User-defined function %02X found", function_code); + } else { + if (crc16(&this->rx_buffer_[0], frame_length) != 0) + return false; + } + + // Clear before processing: process_modbus_client_frame_ dispatches to a server device which sends + // a response immediately. We need to clear the rx buffer first so the response doesn't snag tx_blocked. + // This requires copying the frame data to a local buffer beforehand. + uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size()); + uint16_t data_len = frame_length - 2 - data_offset; + uint8_t data[MAX_FRAME_SIZE] = {}; + std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len); + this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length); + + this->process_modbus_client_frame_(address, function_code, data, data_len); + + return true; +} + +void ModbusClientHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) { + if (!this->waiting_for_response_.has_value()) { + ESP_LOGW(TAG, + "Received unexpected frame from address %" PRIu8 ", function code 0x%X, %" PRIu32 "ms after last send", + address, function_code, this->last_modbus_byte_ - this->last_send_); return; + } else { // We are waiting for a response + // Check if the response matches the expected address and function code - if (this->tx_blocked()) - return; + ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); + uint8_t expected_address = wfr.frame.data.get()[0]; + uint8_t expected_function_code = wfr.frame.data.get()[1]; + if (expected_address != address || expected_function_code != (function_code & FUNCTION_CODE_MASK)) { + ESP_LOGW(TAG, + "Received incorrect frame address %" PRIu8 " <> %" PRIu8 " or function code 0x%X <> 0x%X, %" PRIu32 + "ms after last send", + address, expected_address, (function_code & FUNCTION_CODE_MASK), expected_function_code, + this->last_modbus_byte_ - this->last_send_); + // Invalidate the waiting device so it won't process this response. + if (wfr.device) + wfr.device->on_modbus_no_response(); + wfr.interrupted = true; + wfr.device = nullptr; + return; + } - const ModbusDeviceCommand &frame = this->tx_buffer_.front(); + if (wfr.interrupted) { + ESP_LOGW(TAG, + "Ignoring response from %" PRIu8 " - transmission interrupted by previous unexpected response, %" PRIu32 + "ms after last send", + address, this->last_modbus_byte_ - this->last_send_); + return; + } else { // We have a valid device waiting for this response - if (this->role == ModbusRole::CLIENT) { - this->waiting_for_response_ = frame.data.get()[0]; + ModbusClientDevice *device = wfr.device; + this->waiting_for_response_.reset(); + // Is it an error response? + if (helpers::is_function_code_exception(function_code)) { + uint8_t exception = len > 0 ? data[0] : 0; + ESP_LOGW(TAG, + "Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32 "ms after last send", + function_code, exception, address, this->last_modbus_byte_ - this->last_send_); + if (device) + device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception); + + } else if (device) { // Not an error response + // on_modbus_data is existing public API taking const std::vector& + device->on_modbus_data(std::vector(data, data + len)); + } else { // Not an error response, but no device to respond to + ESP_LOGV(TAG, "Ignoring response from %" PRIu8 " - no callback device set, %" PRIu32 "ms after last send", + address, this->last_modbus_byte_ - this->last_send_); + } + } + } +} + +void ModbusServerHub::process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *, uint16_t) { + for (auto *device : this->devices_) { + if (device->address_ == address) { + ESP_LOGE(TAG, "Unexpected response from address %" PRIu8 ", which is mapped to this device.", address); + } + } + + if (this->expecting_peer_response_ == address) { + ESP_LOGV(TAG, "Expected response from peer %" PRIu8 " received", address); + } else { + ESP_LOGV(TAG, "Unexpected response from peer %" PRIu8 " received", address); + } + + // This always resets, even if the address doesn't match. + // If an unexpected response is received, we can't trust that a correct response will follow (it shouldn't). + this->expecting_peer_response_ = 0; +} + +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) { + bool found = false; + + for (auto *device : this->devices_) { + if (device->address_ == address) { + found = true; + + if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS || + static_cast(function_code) == ModbusFunctionCode::READ_INPUT_REGISTERS) { + device->on_modbus_read_registers(function_code, helpers::get_data(data, 0), + helpers::get_data(data, 2)); + } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER || + static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + device->on_modbus_write_registers(function_code, std::vector(data, data + len)); + } else { + ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); + device->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + } + } + } + + if (!found) { + this->expecting_peer_response_ = address; + ESP_LOGV(TAG, "Request to peer %" PRIu8 " received", address); + } +} + +bool Modbus::send_frame_(const ModbusFrame &frame) { + if (this->tx_blocked()) { + ESP_LOGE(TAG, "Attempted to send while transmission blocked"); + return false; + } + if (frame.size > MAX_FRAME_SIZE) { + ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %" PRIu16 " bytes", MAX_FRAME_SIZE); + return false; + } + + const int32_t tx_delay_remaining = this->tx_delay_remaining(); + if (tx_delay_remaining > 0) { + delay(tx_delay_remaining); } if (this->flow_control_pin_ != nullptr) { @@ -304,123 +388,190 @@ void Modbus::send_next_frame_() { this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; } + uint32_t now = millis(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send", format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), - millis() - this->last_send_); - this->last_send_ = millis(); + ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send, %" PRIu32 "ms after last receive", + format_hex_pretty_to(hex_buf, frame.data.get(), frame.size), now - this->last_send_, + now - this->last_modbus_byte_); + this->last_send_ = now; + return true; +} + +void ModbusClientHub::send_next_frame_() { + if (this->tx_buffer_.empty()) { + return; + } + + if (this->tx_blocked()) { + return; + } + + ModbusDeviceCommand &command = this->tx_buffer_.front(); + + if (this->send_frame_(command.frame)) { + this->waiting_for_response_ = std::move(command); + } else { + if (command.device) + command.device->on_modbus_not_sent(); + } + this->tx_buffer_.pop_front(); + if (!this->tx_buffer_.empty()) { ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); } } -void Modbus::dump_config() { +void ModbusClientHub::dump_config() { ESP_LOGCONFIG(TAG, "Modbus:\n" - " Send Wait Time: %d ms\n" - " Turnaround Time: %d ms\n" - " Frame Delay: %d ms\n" - " Long Rx Buffer Delay: %d ms\n" - " CRC Disabled: %s", + " Send Wait Time: %" PRIu16 " ms\n" + " Turnaround Time: %" PRIu16 " ms\n" + " Frame Delay: %" PRIu16 " ms\n" + " Long Rx Buffer Delay: %" PRIu16 " ms", this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_, - this->long_rx_buffer_delay_ms_, YESNO(this->disable_crc_)); + this->long_rx_buffer_delay_ms_); LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); } +void ModbusServerHub::dump_config() { + ESP_LOGCONFIG(TAG, + "Modbus:\n" + " Frame Delay: %" PRIu16 " ms\n" + " Long Rx Buffer Delay: %" PRIu16 " ms", + this->frame_delay_ms_, this->long_rx_buffer_delay_ms_); + LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_); +} + float Modbus::get_setup_priority() const { // After UART bus return setup_priority::BUS - 1.0f; } -void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len, const uint8_t *payload) { - static const size_t MAX_VALUES = 128; - - // Only check max number of registers for standard function codes - // Some devices use non standard codes like 0x43 - if (number_of_entities > MAX_VALUES && function_code <= ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - ESP_LOGE(TAG, "send too many values %d max=%zu", number_of_entities, MAX_VALUES); +void ModbusServerHub::send(uint8_t address, uint8_t function_code, const std::vector &payload) { + const uint16_t len = static_cast(2 + payload.size()); + if (len > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); return; } - - uint8_t data[MAX_FRAME_SIZE]; - size_t pos = 0; - - data[pos++] = address; - data[pos++] = function_code; - if (this->role == ModbusRole::CLIENT) { - data[pos++] = start_address >> 8; - data[pos++] = start_address >> 0; - if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && - function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - data[pos++] = number_of_entities >> 8; - data[pos++] = number_of_entities >> 0; - } - } - - if (payload != nullptr) { - if (this->role == ModbusRole::SERVER || function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || - function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { // Write multiple - data[pos++] = payload_len; // Byte count is required for write - } else { - payload_len = 2; // Write single register or coil - } - if (payload_len + pos + 2 > MAX_FRAME_SIZE) { // Check if payload fits (accounting for CRC) - ESP_LOGE(TAG, "Payload too large to send: %d bytes", payload_len); - return; - } - for (int i = 0; i < payload_len; i++) { - data[pos++] = payload[i]; - } - } - - this->queue_raw_(data, pos); + uint8_t raw_frame[MAX_RAW_SIZE]; + raw_frame[0] = address; + raw_frame[1] = function_code; + std::memcpy(raw_frame + 2, payload.data(), payload.size()); + this->send_raw_(raw_frame, len); } -// Helper function for lambdas -// Send raw command. Except CRC everything must be contained in payload -void Modbus::send_raw(const std::vector &payload) { - if (payload.empty()) { +// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. +void ModbusClientHub::queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device) { + if (pdu_len == 0) { + if (device) + device->on_modbus_not_sent(); return; } - // Frame size: payload + CRC(2) - if (payload.size() + 2 > MAX_FRAME_SIZE) { - ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %d bytes", MAX_FRAME_SIZE); - return; - } - // Use stack buffer - Modbus frames are small and bounded - uint8_t data[MAX_FRAME_SIZE]; - std::memcpy(data, payload.data(), payload.size()); - - this->queue_raw_(data, payload.size()); -} - -// Assume data and length is valid and append CRC, then queue for sending. Used internally to avoid unnecessary copying -// of data into vectors -void Modbus::queue_raw_(const uint8_t *data, uint16_t len) { if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) { - this->tx_buffer_.emplace_back(data, len); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; +#endif + ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); + this->tx_buffer_.emplace_back(device, address, pdu, pdu_len); } else { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; #endif - ESP_LOGE(TAG, "Write buffer full, dropped: %s", format_hex_pretty_to(hex_buf, data, len)); + ESP_LOGE(TAG, "Write buffer full, dropped: %" PRIu8 ":%s", address, format_hex_pretty_to(hex_buf, pdu, pdu_len)); + if (device) + device->on_modbus_not_sent(); } } -void Modbus::clear_rx_buffer_(const LogString *reason, bool warn) { - size_t at = this->rx_buffer_.size(); - if (at > 0) { +void ModbusClientHub::clear_tx_queue_for_address(uint8_t address, bool clear_sent) { + // Remove any pending commands for this address from the tx buffer + auto &tx_buffer = this->tx_buffer_; + tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [address](const ModbusDeviceCommand &cmd) { return cmd.frame.data[0] == address; }), + tx_buffer.end()); + + if (clear_sent && this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { + if (this->waiting_for_response_.value().frame.data[0] == address) { + ESP_LOGV(TAG, "Clearing waiting for response for address %" PRIu8, address); + // Invalidate the waiting device so it won't process a response. + this->waiting_for_response_.value().device = nullptr; + } + } +} +void ModbusClientHub::clear_tx_queue_for_device(ModbusClientDevice *device) { + // Remove any pending commands for this address from the tx buffer + auto &tx_buffer = this->tx_buffer_; + tx_buffer.erase(std::remove_if(tx_buffer.begin(), tx_buffer.end(), + [device](const ModbusDeviceCommand &cmd) { return cmd.device == device; }), + tx_buffer.end()); + + if (this->waiting_for_response_.has_value() && this->waiting_for_response_.value().device) { + if (this->waiting_for_response_.value().device == device) { + ESP_LOGV(TAG, "Clearing waiting for response"); + // Invalidate the waiting device so it won't process a response. + this->waiting_for_response_.value().device = nullptr; + } + } +} + +void ModbusClientHub::send_raw(const std::vector &payload, ModbusClientDevice *device) { + if (payload.size() < 2) { + if (device) + device->on_modbus_not_sent(); + return; + } + this->queue_raw_(payload[0], payload.data() + 1, static_cast(payload.size() - 1), device); +} + +// Send raw command for server replies immediately. Except CRC everything must be contained in payload +void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { + if (len == 0) { + return; + } + if (len > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server send frame too large (%" PRIu16 " bytes)", len); + return; + } + + // In the rare case that the server is blocked (frame delay has not elapsed), we delay the send. + // This should only happen at low baud rates with long frame delays. + if (this->tx_blocked()) { + // Stash the raw payload in a single member buffer so the deferred callback can rebuild the frame + // without a heap allocation. Only one server reply is ever in flight, and the named timeout ensures + // only one deferred send is pending, so a single buffer is sufficient. + std::memcpy(this->deferred_payload_.data(), payload, len); + this->deferred_payload_len_ = len; + this->set_timeout("deferred_send", this->tx_delay_remaining(), [this]() { + ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, + this->deferred_payload_len_ - 1); + this->send_frame_(frame); + }); + } else { + ModbusFrame frame(payload[0], payload + 1, len - 1); + this->send_frame_(frame); + } +} + +void Modbus::clear_rx_buffer_(const LogString *reason, bool warn, size_t bytes_to_clear) { + size_t bytes = this->rx_buffer_.size(); + if (bytes_to_clear > 0 && bytes >= bytes_to_clear) + bytes = bytes_to_clear; + if (bytes > 0) { if (warn) { - ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), + ESP_LOGW(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), millis() - this->last_send_); } else { - ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason), + ESP_LOGV(TAG, "Clearing buffer of %zu bytes - %s %" PRIu32 "ms after last send", bytes, LOG_STR_ARG(reason), millis() - this->last_send_); } - this->rx_buffer_.clear(); + if (bytes == this->rx_buffer_.size()) { + this->rx_buffer_.clear(); + } else { + this->rx_buffer_.erase(this->rx_buffer_.begin(), this->rx_buffer_.begin() + bytes); + } } } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 26f64401be..86337442c6 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -4,33 +4,32 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/modbus/modbus_definitions.h" +#include "esphome/components/modbus/modbus_helpers.h" +#include #include #include #include -#include +#include +#include namespace esphome::modbus { static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15; +static constexpr uint16_t MODBUS_TX_MAX_DELAY_MS = 5; -enum ModbusRole { - CLIENT, - SERVER, -}; - -class ModbusDevice; - -struct ModbusDeviceCommand { +struct ModbusFrame { // Frame with exact-size allocation to avoid std::vector overhead std::unique_ptr data; uint16_t size; // Modbus RTU max is 256 bytes - ModbusDeviceCommand(const uint8_t *src, uint16_t len) : data(std::make_unique(len + 2)), size(len + 2) { - std::memcpy(this->data.get(), src, len); - auto crc = crc16(data.get(), len); - data[len + 0] = crc >> 0; - data[len + 1] = crc >> 8; + ModbusFrame(uint8_t address, const uint8_t *pdu, uint16_t pdu_len) + : data(std::make_unique(pdu_len + 3)), size(pdu_len + 3) { + data[0] = address; + memcpy(data.get() + 1, pdu, pdu_len); + auto crc = crc16(data.get(), pdu_len + 1); + data[pdu_len + 1] = crc >> 0; + data[pdu_len + 2] = crc >> 8; } }; @@ -39,86 +38,197 @@ class Modbus : public uart::UARTDevice, public Component { Modbus() = default; void setup() override; - void loop() override; - void dump_config() override; - - void register_device(ModbusDevice *device) { this->devices_.push_back(device); } - float get_setup_priority() const override; - bool tx_buffer_empty(); - bool tx_blocked(); + virtual bool tx_blocked(); - void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len = 0, const uint8_t *payload = nullptr); - void send_raw(const std::vector &payload); - void set_role(ModbusRole role) { this->role = role; } void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; } - void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; } - void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } - void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; } - - ModbusRole role; protected: - bool parse_modbus_byte_(uint8_t byte); - void receive_and_parse_modbus_bytes_(); - void clear_rx_buffer_(const LogString *reason, bool warn = false); - void send_next_frame_(); - void queue_raw_(const uint8_t *data, uint16_t len); + void receive_bytes_(); + bool timeout_(); + virtual int32_t tx_delay_remaining(); + virtual void parse_modbus_frames() = 0; + bool parse_modbus_server_frame_(); + virtual void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, + uint16_t len) = 0; + void clear_rx_buffer_(const LogString *reason, bool warn = false, size_t bytes_to_clear = 0); + bool send_frame_(const ModbusFrame &frame); + // Scans forward from min_length to find a frame boundary by CRC match for custom function codes. + // Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE. + uint16_t find_custom_frame_end_(uint16_t min_length) const; uint32_t last_modbus_byte_{0}; + uint32_t last_receive_check_{0}; uint32_t last_send_{0}; uint32_t last_send_tx_offset_{0}; uint16_t frame_delay_ms_{5}; uint16_t long_rx_buffer_delay_ms_{0}; - uint16_t send_wait_time_{250}; - uint16_t turnaround_delay_ms_{100}; - uint8_t waiting_for_response_{0}; - bool disable_crc_{false}; GPIOPin *flow_control_pin_{nullptr}; std::vector rx_buffer_; - std::vector devices_; +}; + +class ModbusClientDevice; +class ModbusServerDevice; + +struct ModbusDeviceCommand { + ModbusClientDevice *device; + ModbusFrame frame; + bool interrupted{false}; + + ModbusDeviceCommand(ModbusClientDevice *device, uint8_t address, const uint8_t *src, uint16_t len) + : device(device), frame(address, src, len) {} +}; + +class ModbusClientHub : public Modbus { + public: + ModbusClientHub() = default; + void dump_config() override; + void loop() override; + void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; } + void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; } + bool tx_buffer_empty(); + bool tx_blocked() override; + ESPDEPRECATED("Use send_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") + void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, + uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { + this->send_pdu(address, + helpers::create_client_pdu((ModbusFunctionCode) function_code, start_address, number_of_entities, + payload, payload_len), + device); + }; + void send_pdu(uint8_t address, const StaticVector &pdu, ModbusClientDevice *device = nullptr) { + this->queue_raw_(address, pdu.data(), pdu.size(), device); + } + void send_raw(const std::vector &payload, ModbusClientDevice *device = nullptr); + void clear_tx_queue_for_address(uint8_t address, bool clear_sent = true); + void clear_tx_queue_for_device(ModbusClientDevice *device); + + protected: + int32_t tx_delay_remaining() override; + void parse_modbus_frames() override; + // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. + void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void send_next_frame_(); + void queue_raw_(uint8_t address, const uint8_t *pdu, uint16_t pdu_len, ModbusClientDevice *device = nullptr); + + uint16_t send_wait_time_{2000}; + uint16_t turnaround_delay_ms_{0}; + std::optional waiting_for_response_; + // std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many // requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling // may change at run time. std::deque tx_buffer_; }; -class ModbusDevice { +class ModbusServerHub : public Modbus { public: - void set_parent(Modbus *parent) { parent_ = parent; } - void set_address(uint8_t address) { address_ = address; } - virtual void on_modbus_data(const std::vector &data) = 0; - virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} - virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; - virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; - void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, - const uint8_t *payload = nullptr) { - this->parent_->send(this->address_, function, start_address, number_of_entities, payload_len, payload); - } - void send_raw(const std::vector &payload) { this->parent_->send_raw(payload); } - void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { - std::vector error_response; - error_response.reserve(3); - error_response.push_back(this->address_); - error_response.push_back(function_code | FUNCTION_CODE_EXCEPTION_MASK); - error_response.push_back(static_cast(exception_code)); - this->send_raw(error_response); - } - // If more than one device is connected block sending a new command before a response is received - ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") - bool waiting_for_response() { return !ready_for_immediate_send(); } - bool ready_for_immediate_send() { return parent_->tx_buffer_empty() && !parent_->tx_blocked(); } + ModbusServerHub() = default; + void dump_config() override; + void send(uint8_t address, uint8_t function_code, const std::vector &payload); + ESPDEPRECATED("Use ModbusServerDevice::send_raw instead. Removed in 2026.10.0", "2026.4.0") + void send_raw(const std::vector &payload) { + this->send_raw_(payload.data(), static_cast(payload.size())); + }; + void register_device(ModbusServerDevice *device) { this->devices_.push_back(device); } protected: - friend Modbus; + friend class ModbusServerDevice; - Modbus *parent_; - uint8_t address_; + void parse_modbus_frames() override; + bool parse_modbus_client_frame_(); + // Parsers need to handle standard (ModbusFunctionCode) and custom (uint8_t) function codes, so we use uint8_t here. + void process_modbus_server_frame(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) override; + void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len); + void send_raw_(const uint8_t *payload, uint16_t len); + uint8_t expecting_peer_response_{0}; + std::vector devices_; + + // Holds the raw payload of a single reply deferred for sending when tx was blocked at send time. + // Only one server reply can be in flight at once, so a single fixed buffer avoids heap allocation. + std::array deferred_payload_; + uint16_t deferred_payload_len_{0}; +}; + +class ModbusClientDevice { + public: + ModbusClientDevice() = default; + ModbusClientDevice(ModbusClientHub *parent, uint8_t address) : parent_(parent), address_(address) {} + virtual ~ModbusClientDevice() { + if (this->parent_ != nullptr) + this->clear_tx_queue_for_device(); + } + ModbusClientDevice(const ModbusClientDevice &) = delete; + ModbusClientDevice &operator=(const ModbusClientDevice &) = delete; + ModbusClientDevice(ModbusClientDevice &&) = delete; + ModbusClientDevice &operator=(ModbusClientDevice &&) = delete; + void set_parent(ModbusClientHub *parent) { this->parent_ = parent; } + void set_address(uint8_t address) { this->address_ = address; } + virtual void on_modbus_data(const std::vector &data) {} + virtual void on_modbus_error(uint8_t function_code, uint8_t exception_code) {} + virtual void on_modbus_not_sent() {} + virtual void on_modbus_no_response() {} + void send(uint8_t function, uint16_t start_address, uint16_t number_of_entities, uint8_t payload_len = 0, + const uint8_t *payload = nullptr) { + this->parent_->send_pdu(this->address_, + helpers::create_client_pdu((ModbusFunctionCode) function, start_address, number_of_entities, + payload, payload_len), + this); + } + void send_pdu(const StaticVector &pdu) { this->parent_->send_pdu(this->address_, pdu, this); } + void send_raw(const std::vector &payload) { this->parent_->send_raw(payload, this); } + inline void clear_tx_queue_for_address(bool clear_sent = true) { + this->parent_->clear_tx_queue_for_address(this->address_, clear_sent); + } + inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } + + // If more than one device is connected block sending a new command before a response is received + ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0") + bool waiting_for_response() { return !this->ready_for_immediate_send(); } + bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); } + + protected: + ModbusClientHub *parent_{nullptr}; + uint8_t address_{0}; +}; + +// This is for compatibility with external components using the former class name +using ModbusDevice = ModbusClientDevice; + +class ModbusServerDevice { + public: + ModbusServerDevice() = default; + ModbusServerDevice(ModbusServerHub *parent, uint8_t address) : parent_(parent), address_(address) {} + virtual ~ModbusServerDevice() = default; + ModbusServerDevice(const ModbusServerDevice &) = delete; + ModbusServerDevice &operator=(const ModbusServerDevice &) = delete; + ModbusServerDevice(ModbusServerDevice &&) = delete; + ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; + void set_parent(ModbusServerHub *parent) { this->parent_ = parent; } + void set_address(uint8_t address) { this->address_ = address; } + virtual void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers){}; + virtual void on_modbus_write_registers(uint8_t function_code, const std::vector &data){}; + void send(uint8_t function, const std::vector &payload) { + this->parent_->send(this->address_, function, payload); + } + void send_raw(const std::vector &payload) { + this->parent_->send_raw_(payload.data(), static_cast(payload.size())); + } + void send_error(uint8_t function_code, ModbusExceptionCode exception_code) { + uint8_t error_response[3] = {this->address_, uint8_t(function_code | FUNCTION_CODE_EXCEPTION_MASK), + static_cast(exception_code)}; + this->parent_->send_raw_(error_response, 3); + } + + protected: + friend ModbusServerHub; + + ModbusServerHub *parent_{nullptr}; + uint8_t address_{0}; }; } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_definitions.h b/esphome/components/modbus/modbus_definitions.h index fb8c011259..49172b9dca 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -14,7 +14,8 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT = 100; // 0x64 const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E enum class ModbusFunctionCode : uint8_t { - CUSTOM = 0x00, + INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions). + CUSTOM = 0x00, // The CUSTOM alias should be removed in future. READ_COILS = 0x01, READ_DISCRETE_INPUTS = 0x02, READ_HOLDING_REGISTERS = 0x03, @@ -35,19 +36,11 @@ enum class ModbusFunctionCode : uint8_t { READ_FIFO_QUEUE = 0x18, // not implemented }; -/*Allow comparison operators between ModbusFunctionCode and uint8_t*/ +/*Allow direct comparison operators between ModbusFunctionCode and uint8_t*/ inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) == rhs; } inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast(rhs); } inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast(lhs) == rhs); } inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast(rhs)); } -inline bool operator<(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) < rhs; } -inline bool operator<(uint8_t lhs, ModbusFunctionCode rhs) { return lhs < static_cast(rhs); } -inline bool operator<=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) <= rhs; } -inline bool operator<=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs <= static_cast(rhs); } -inline bool operator>(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) > rhs; } -inline bool operator>(uint8_t lhs, ModbusFunctionCode rhs) { return lhs > static_cast(rhs); } -inline bool operator>=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast(lhs) >= rhs; } -inline bool operator>=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs >= static_cast(rhs); } // 4.3 MODBUS Data model enum class ModbusRegisterType : uint8_t { @@ -75,12 +68,21 @@ enum class ModbusExceptionCode : uint8_t { }; // 6.12 16 (0x10) Write Multiple registers: -const uint8_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B +static constexpr uint16_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B + +// 6.1 01 (0x01) Read Coils +// 6.2 02 (0x02) Read Discrete Inputs +static constexpr uint16_t MAX_NUM_OF_COILS_TO_READ = 2000; // 0x7D0 +static constexpr uint16_t MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000; // 0x7D0 // 6.3 03 (0x03) Read Holding Registers // 6.4 04 (0x04) Read Input Registers -const uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +static constexpr uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D +// Smallest possible frame is 4 bytes (custom function with no data): address(1) + function(1) + CRC(2) +static constexpr uint16_t MIN_FRAME_SIZE = 4; +static constexpr uint16_t MAX_PDU_SIZE = 253; // Max PDU size is 256 - address(1) - CRC(2) = 253 +static constexpr uint16_t MAX_RAW_SIZE = 254; // Max RAW size is 255 - CRC(2) = 254 static constexpr uint16_t MAX_FRAME_SIZE = 256; /// End of Modbus definitions } // namespace esphome::modbus diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 89dc3c08bc..4cddfca104 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -1,10 +1,83 @@ #include "modbus_helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; +uint16_t server_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; + if (is_function_code_exception(frame[1])) { + return 5; // address(1) + function(1) + exception(1) + CRC(2) + } + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. + case ModbusFunctionCode::READ_FILE_RECORD: + case ModbusFunctionCode::WRITE_FILE_RECORD: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); + case ModbusFunctionCode::MASK_WRITE_REGISTER: + return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) + case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_NUM_OF_REGISTERS_TO_READ * 2)) : 0); + case ModbusFunctionCode::READ_FIFO_QUEUE: + // address(1) + function(1) + fifo address(2) CRC(2) + return 6; + default: + return MIN_FRAME_SIZE; // unknown length + } +} + +uint16_t client_frame_length(const uint8_t *frame, size_t size) { + if (size < 2) + return MIN_FRAME_SIZE; + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + // address(1) + function(1) + start address(2) + quantity(2) + CRC(2) + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + return 8; // address(1) + function(1) + output/register address(2) + value(2) + CRC(2) + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + start address(2) + quantity(2) + byte count(1) + data + CRC(2) + return 9 + (size > 6 ? std::min(frame[6], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); + // Unsupported function codes. Included here to prevent parser failures. Excluding Serial Line specific functions. + case ModbusFunctionCode::READ_FILE_RECORD: + case ModbusFunctionCode::WRITE_FILE_RECORD: + // address(1) + function(1) + byte count(1) + data + CRC(2) + return 5 + (size > 2 ? std::min(frame[2], uint8_t(MAX_FRAME_SIZE - 5)) : 0); + case ModbusFunctionCode::MASK_WRITE_REGISTER: + return 10; // address(1) + function(1) + reference address(2) + AND mask(2) + OR mask(2) + CRC(2) + case ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS: + // address(1) + function(1) + read start address(2) + read quantity(2) + write start address(2) + + // write quantity(2) + byte count(1) + data + CRC(2) + return 13 + (size > 10 ? std::min(frame[10], uint8_t(MAX_NUM_OF_REGISTERS_TO_WRITE * 2)) : 0); + case ModbusFunctionCode::READ_FIFO_QUEUE: + // address(1) + function(1) + fifo address(2) CRC(2) + return 6; + default: + return MIN_FRAME_SIZE; // unknown length + } +} + static size_t required_payload_size(SensorValueType sensor_value_type) { switch (sensor_value_type) { case SensorValueType::U_WORD: @@ -67,7 +140,7 @@ void number_to_payload(std::vector &data, int64_t value, SensorValueTy } int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { + uint32_t bitmask, bool *error_return) { int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits // Validate offset against the buffer for all types, including RAW/unsupported, so @@ -75,6 +148,8 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens if (static_cast(offset) > data.size()) { ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu", static_cast(sensor_value_type), static_cast(offset), data.size()); + if (error_return) + *error_return = true; return value; } @@ -87,6 +162,8 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens ESP_LOGE(TAG, "not enough data for value type=%u offset=%u size=%zu required=%zu", static_cast(sensor_value_type), static_cast(offset), data.size(), required_size); + if (error_return) + *error_return = true; return value; } @@ -136,4 +213,102 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens } return value; } + +StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, + uint16_t number_of_entities, const uint8_t *values, + size_t values_len) { + if (is_function_code_read(static_cast(function_code))) { + if (values != nullptr || values_len > 0) { + ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", + static_cast(function_code)); + } + } else if (is_function_code_write(static_cast(function_code))) { + if (values == nullptr || values_len == 0) { + ESP_LOGE(TAG, "No values provided for write function code %02X", static_cast(function_code)); + return {}; + } + } else { + ESP_LOGE(TAG, "Unsupported function code %02X for client PDU creation", static_cast(function_code)); + return {}; + } + + if (number_of_entities == 0) { + ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); + return {}; + } + + switch (function_code) { + case ModbusFunctionCode::READ_COILS: + if (number_of_entities > MAX_NUM_OF_COILS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum coils to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_COILS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + if (number_of_entities > MAX_NUM_OF_DISCRETE_INPUTS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum discrete inputs to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to read %u for function code %02X", + number_of_entities, MAX_NUM_OF_REGISTERS_TO_READ, static_cast(function_code)); + return {}; + } + break; + case ModbusFunctionCode::WRITE_SINGLE_COIL: + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + break; // number_of_entities is ignored for single write, so no need to validate + case ModbusFunctionCode::WRITE_MULTIPLE_COILS: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: + if (number_of_entities > MAX_NUM_OF_REGISTERS_TO_WRITE) { + ESP_LOGE(TAG, "number_of_entities %u exceeds maximum registers to write %u for function code %02X", + number_of_entities, MAX_NUM_OF_REGISTERS_TO_WRITE, static_cast(function_code)); + return {}; + } + break; + default: + ESP_LOGE(TAG, "Unsupported function code %u for client PDU creation", static_cast(function_code)); + return {}; + } + + StaticVector pdu; + pdu.push_back(static_cast(function_code)); + pdu.push_back(start_address >> 8); + pdu.push_back(start_address >> 0); + if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL && + function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + pdu.push_back(number_of_entities >> 8); + pdu.push_back(number_of_entities >> 0); + } + + if (is_function_code_write(static_cast(function_code))) { + if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || + function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + // 6 bytes of overhead (fc + start_addr×2 + qty×2 + byte_count) leave MAX_PDU_SIZE-6 bytes for values + static constexpr size_t MAX_WRITE_MULTIPLE_VALUES_LEN = MAX_PDU_SIZE - 6; + if (values_len > MAX_WRITE_MULTIPLE_VALUES_LEN) { + ESP_LOGE(TAG, "values_len %zu exceeds PDU capacity %zu, dropping request", values_len, + MAX_WRITE_MULTIPLE_VALUES_LEN); + return {}; + } + pdu.push_back(values_len); // Byte count is required for write multiple + for (size_t i = 0; i < values_len; i++) + pdu.push_back(values[i]); + } else { + // Write single register or coil (2 bytes) + if (values_len < 2) { + ESP_LOGE(TAG, "values_len %zu too small for write-single command (need 2), dropping request", values_len); + return {}; + } + pdu.push_back(values[0]); + pdu.push_back(values[1]); + } + } + return pdu; +} } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 84897bcad3..b637d872cf 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -9,6 +9,58 @@ namespace esphome::modbus::helpers { +inline bool is_function_code_read(uint8_t function_code) { + ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == ModbusFunctionCode::READ_COILS || + masked_function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS || + masked_function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS || + masked_function_code == ModbusFunctionCode::READ_INPUT_REGISTERS; +} + +inline bool is_function_code_write(uint8_t function_code) { + ModbusFunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); + return masked_function_code == ModbusFunctionCode::WRITE_SINGLE_COIL || + masked_function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER || + masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS || + masked_function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS; +} + +inline bool is_function_code_exception(uint8_t function_code) { + return (static_cast(function_code) & FUNCTION_CODE_EXCEPTION_MASK) != 0; +} + +inline bool is_function_code_custom(uint8_t function_code) { + uint8_t masked_function_code = function_code & FUNCTION_CODE_MASK; + return (masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT && + masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END) || + (masked_function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT && + masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END); +} + +// Returns the expected length of a server response frame based on the function code +// If the frame is too short to determine the length, returns the minimum length +uint16_t server_frame_length(const uint8_t *frame, size_t size); + +// Returns the expected length of a client request frame based on the function code +// If the frame is too short to determine the length, returns the minimum length +uint16_t client_frame_length(const uint8_t *frame, size_t size); + +inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { + if (size < 2) + return 0; + switch (static_cast(frame[1])) { + case ModbusFunctionCode::READ_COILS: + case ModbusFunctionCode::READ_DISCRETE_INPUTS: + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: + return 3; // address(1) + function(1) + byte count(1) + data + CRC(2) + default: + return 2; + } +} + +inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; } + enum class SensorValueType : uint8_t { RAW = 0x00, // variable length U_WORD = 0x1, // 1 Register unsigned @@ -41,21 +93,21 @@ inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_t case ModbusRegisterType::READ: return ModbusFunctionCode::READ_INPUT_REGISTERS; default: - return ModbusFunctionCode::CUSTOM; + return ModbusFunctionCode::INVALID; } } -inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { +inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type, bool multiple = false) { switch (reg_type) { case ModbusRegisterType::COIL: - return ModbusFunctionCode::WRITE_SINGLE_COIL; - case ModbusRegisterType::DISCRETE_INPUT: - return ModbusFunctionCode::CUSTOM; + return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_COILS : ModbusFunctionCode::WRITE_SINGLE_COIL; case ModbusRegisterType::HOLDING: - return ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS; + return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER; + // These register types can't be written (per spec) case ModbusRegisterType::READ: + case ModbusRegisterType::DISCRETE_INPUT: default: - return ModbusFunctionCode::CUSTOM; + return ModbusFunctionCode::INVALID; } } @@ -112,31 +164,31 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { * @param buffer_offset offset in bytes. * @return value of type T extracted from buffer */ -template T get_data(const std::vector &data, size_t buffer_offset) { +template T get_data(const uint8_t *data, size_t buffer_offset) { if (sizeof(T) == sizeof(uint8_t)) { return T(data[buffer_offset]); } if (sizeof(T) == sizeof(uint16_t)) { return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); } - if (sizeof(T) == sizeof(uint32_t)) { return static_cast(get_data(data, buffer_offset)) << 16 | static_cast(get_data(data, buffer_offset + 2)); } - if (sizeof(T) == sizeof(uint64_t)) { return static_cast(get_data(data, buffer_offset)) << 32 | (static_cast(get_data(data, buffer_offset + 4))); } - static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) || sizeof(T) == sizeof(uint64_t), "Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported."); - return T{}; } +template T get_data(const std::vector &data, size_t buffer_offset) { + return get_data(data.data(), buffer_offset); +} + /** Extract coil data from modbus response buffer * Responses for coil are packed into bytes . * coil 3 is bit 3 of the first response byte @@ -188,7 +240,27 @@ void number_to_payload(std::vector &data, int64_t value, SensorValueTy * @return 64-bit number of the payload */ int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask); + uint32_t bitmask, bool *error_return = nullptr); + +/** Create a modbus clinet pdu for reading/writing single/multiple coils/register/inputs. + * @param function_code the modbus function code to use. One of: + * READ_COILS + * READ_DISCRETE_INPUTS + * READ_HOLDING_REGISTERS + * READ_INPUT_REGISTERS + * WRITE_SINGLE_COIL + * WRITE_SINGLE_REGISTER + * WRITE_MULTIPLE_COILS + * WRITE_MULTIPLE_REGISTERS + * @param start_address coil/register/input starting address + * @param number_of_entities number of coils/registers/inputs to read/write + * @param values optional payload bytes to write (nullptr for read commands) + * @param values_len length of values array + * @return PDU (function code + data, no address, no CRC) + */ +StaticVector create_client_pdu(ModbusFunctionCode function_code, uint16_t start_address, + uint16_t number_of_entities, const uint8_t *values = nullptr, + size_t values_len = 0); inline std::vector float_to_payload(float value, SensorValueType value_type) { int64_t val; diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 6604276cc2..9246239ef9 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -201,7 +201,7 @@ void ModbusController::update() { // walk through the sensors and determine the register ranges to read size_t ModbusController::create_register_ranges_() { this->register_ranges_.clear(); - if (this->parent_->role == modbus::ModbusRole::CLIENT && this->sensorset_.empty()) { + if (this->sensorset_.empty()) { ESP_LOGW(TAG, "No sensors registered"); return 0; } diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index ba86c2cd16..4f674b2675 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -279,7 +279,7 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController : public PollingComponent, public modbus::ModbusDevice { +class ModbusController : public PollingComponent, public modbus::ModbusClientDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 5182bc05d1..2ba7f41b83 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -27,7 +27,7 @@ MULTI_CONF = True modbus_server_ns = cg.esphome_ns.namespace("modbus_server") ModbusServer = modbus_server_ns.class_( - "ModbusServer", cg.Component, modbus.ModbusDevice + "ModbusServer", cg.Component, modbus.ModbusServerDevice ) ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") @@ -44,7 +44,7 @@ SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( ModbusServerRegisterSchema = cv.Schema( { cv.GenerateID(): cv.declare_id(ServerRegister), - cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All( CONF_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), } - ).extend(modbus.modbus_device_schema(0x01)), + ).extend(modbus.modbus_device_schema(0x01, role="server")), ) @@ -119,6 +119,5 @@ async def to_code(config): ) ) cg.add(var.add_server_register(server_register_var)) - cg.add(var.set_address(config[CONF_ADDRESS])) await cg.register_component(var, config) - return await modbus.register_modbus_device(var, config) + return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e5ea2efa4d..c294d08888 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -5,6 +5,7 @@ namespace esphome::modbus_server { using modbus::ModbusFunctionCode; using modbus::ModbusExceptionCode; +using modbus::helpers::payload_to_number; static const char *const TAG = "modbus_server"; @@ -16,7 +17,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star this->address_, function_code, start_address, number_of_registers); if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); return; } @@ -30,9 +31,10 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star break; } int64_t value = server_register->read_lambda(); + char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", server_register->address, static_cast(server_register->value_type), - server_register->register_count, server_register->format_value(value).c_str()); + server_register->register_count, server_register->format_value(value, value_buf, sizeof(value_buf))); std::vector payload; payload.reserve(server_register->register_count * 2); @@ -49,7 +51,7 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star (current_address <= this->server_courtesy_response_.register_last_address)) { ESP_LOGV(TAG, "Could not match any register to address 0x%02X, but default allowed. " - "Returning default value: %d.", + "Returning default value: %" PRIu16 ".", current_address, this->server_courtesy_response_.register_value); sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); current_address += 1; // Just increment by 1, as the default response is a single register @@ -64,20 +66,22 @@ void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t star } std::vector response; + if (number_of_registers != sixteen_bit_response.size()) + ESP_LOGW(TAG, "Response size not matched to request register count."); + response.push_back(sixteen_bit_response.size() * 2); // actual byte count for (auto v : sixteen_bit_response) { auto decoded_value = decode_value(v); response.push_back(decoded_value[0]); response.push_back(decoded_value[1]); } - - this->send(function_code, start_address, number_of_registers, response.size(), response.data()); + this->send(function_code, response); } void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { uint16_t number_of_registers; uint16_t payload_offset; - if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { if (data.size() < 5) { ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); @@ -85,13 +89,15 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v } number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 ". Sending exception response.", number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); return; } uint16_t payload_size = data[4]; if (payload_size != number_of_registers * 2) { - ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", + ESP_LOGW(TAG, + "Payload size of %" PRIu16 " bytes is not 2 times the number of registers (%" PRIu16 + "). Sending exception response.", payload_size, number_of_registers); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); return; @@ -103,7 +109,7 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v return; } payload_offset = 5; - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + } else if (static_cast(function_code) == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { if (data.size() < 4) { ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); @@ -148,15 +154,22 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { return server_register->write_lambda != nullptr; })) { - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + ESP_LOGW(TAG, "Invalid register address. Sending exception response."); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); return; } // Actually write to the registers: if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); - return server_register->write_lambda(number); + bool error = false; + int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF, &error); + if (error) { + return false; + } else { + return server_register->write_lambda(number); + } })) { + ESP_LOGW(TAG, "Could not write all registers. Sending exception response."); this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); return; } diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index 0fc2e0bef5..fa1376542c 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -52,32 +52,34 @@ class ServerRegister { }; } - // Formats a raw value into a string representation based on the value type for debugging - std::string format_value(int64_t value) const { - // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) - // plus null terminator = 43, rounded to 44 for 4-byte alignment - char buf[44]; + // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) + // plus null terminator = 43, rounded to 44 for 4-byte alignment + static constexpr size_t FORMAT_VALUE_BUF_SIZE = 44; + + // Formats a raw value into a caller-provided buffer based on the value type for debugging. + // Returns buf for convenience. + const char *format_value(int64_t value, char *buf, size_t buf_size) const { switch (this->value_type) { case SensorValueType::U_WORD: case SensorValueType::U_DWORD: case SensorValueType::U_DWORD_R: case SensorValueType::U_QWORD: case SensorValueType::U_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(value)); + buf_append_printf(buf, buf_size, 0, "%" PRIu64, static_cast(value)); return buf; case SensorValueType::S_WORD: case SensorValueType::S_DWORD: case SensorValueType::S_DWORD_R: case SensorValueType::S_QWORD: case SensorValueType::S_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + buf_append_printf(buf, buf_size, 0, "%" PRId64, value); return buf; case SensorValueType::FP32_R: case SensorValueType::FP32: - buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast(static_cast(value))); + buf_append_printf(buf, buf_size, 0, "%.1f", bit_cast(static_cast(value))); return buf; default: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + buf_append_printf(buf, buf_size, 0, "%" PRId64, value); return buf; } } @@ -89,12 +91,10 @@ class ServerRegister { WriteLambda write_lambda; }; -class ModbusServer : public Component, public modbus::ModbusDevice { +class ModbusServer : public Component, public modbus::ModbusServerDevice { public: void dump_config() override; - /// Not used for ModbusServer. - void on_modbus_data(const std::vector &data) override{}; /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 56367d0b26..d87318b03d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -52,6 +52,11 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.core.config import BOARD_MAX_LENGTH import esphome.final_validate as fv +from esphome.framework_helpers import ( + get_project_compile_flags, + get_project_link_flags, + run_command_ok, +) from esphome.helpers import write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -63,7 +68,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ) -from .framework import check_and_install +from .framework import check_and_install, get_build_env, get_build_paths # force import gpio to register pin schema from .gpio import nrf52_pin_to_code # noqa: F401 @@ -99,9 +104,6 @@ FAKE_BOARD_MANIFEST = """ def set_core_data(config: ConfigType) -> ConfigType: - # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. - if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) zephyr_set_core_data(config) CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR @@ -112,6 +114,12 @@ def set_core_data(config: ConfigType) -> ConfigType: return config +def _resolve_toolchain(config: ConfigType) -> ConfigType: + if CORE.toolchain is None: + CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) + return config + + def set_framework(config: ConfigType) -> ConfigType: if CONF_VERSION not in config[CONF_FRAMEWORK]: default_version = "2.6.1-b" if CORE.using_toolchain_platformio else "2.9.2" @@ -147,6 +155,12 @@ BOOTLOADERS = [ ] +def _validate_toolchain(value) -> Toolchain: + return Toolchain( + cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value) + ) + + def _detect_bootloader(config: ConfigType) -> ConfigType: """Detect the bootloader for the given board.""" config = config.copy() @@ -233,9 +247,11 @@ CONFIG_SCHEMA = cv.All( ), } ), + cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm), } ), + _resolve_toolchain, set_framework, ) @@ -565,6 +581,47 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False +def _generate_cmake_lists() -> None: + compile_flags = get_project_compile_flags() + link_flags = get_project_link_flags() + + lines = [ + "cmake_minimum_required(VERSION 3.20.0)", + "", + 'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")', + "", + "find_package(Zephyr REQUIRED)", + "", + f"project({CORE.name})", + "", + 'file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/../src/*.cpp" "${CMAKE_CURRENT_LIST_DIR}/../src/*.c")', + "", + "target_sources(app PRIVATE ${APP_SOURCES})", + 'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")', + ] + + if compile_flags: + lines += [ + "", + "target_compile_options(app PRIVATE", + *[f' "{flag}"' for flag in compile_flags], + ")", + ] + + if link_flags: + lines += [ + "", + "zephyr_ld_options(", + *[f' "{flag}"' for flag in link_flags], + ")", + ] + + write_file_if_changed( + CORE.relative_build_path("zephyr", "CMakeLists.txt"), + "\n".join(lines) + "\n", + ) + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: return False @@ -574,4 +631,35 @@ def run_compile(args, config: ConfigType) -> bool: "Supported toolchains are 'platformio' and 'sdk-nrf'." ) check_and_install() - raise EsphomeError("Native build for nRF52 is not implemented yet") + + paths = get_build_paths() + env = get_build_env() + + _generate_cmake_lists() + + board = zephyr_data()[KEY_BOARD] + build_dir = CORE.relative_pioenvs_path(CORE.name) + source_dir = CORE.relative_build_path("zephyr") + + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "build", + "--pristine=auto", + "-b", + board, + "-d", + str(build_dir), + str(source_dir), + ] + + if not run_command_ok( + west_cmd, + env=env, + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 native build failed") + + return True diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 607ad0c7ed..a35ba3ef85 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -18,7 +18,7 @@ from esphome.framework_helpers import ( _LOGGER = logging.getLogger(__name__) -_WEST_VERSION = "1.5.0" +_REQUIREMENTS = Path(__file__).parent / "requirements.txt" _TOOLCHAIN_VERSION = "0.17.4" SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( @@ -28,6 +28,15 @@ SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( ) ) +# Minimal SDK provides cmake discovery files (Zephyr-sdkConfig.cmake) and +# host tools (dtc etc.) required by the Zephyr cmake build system. +SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str( + os.environ.get( + "ESPHOME_SDK_NG_MINIMAL_MIRRORS", + "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/zephyr-sdk-{VERSION}_{sysname}-{machine}_minimal.{extension}", + ) +) + def _get_tools_path() -> Path: return CORE.data_dir / "sdk-nrf" @@ -38,11 +47,11 @@ def _get_python_env_path(version: str) -> Path: def _get_framework_path(version: str) -> Path: - return _get_tools_path() / "frameworks" / f"{version}" + return _get_tools_path() / "frameworks" / version def _get_toolchain_path(version: str) -> Path: - return _get_tools_path() / "toolchains" / f"{version}" + return _get_tools_path() / "toolchains" / version # onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. @@ -95,29 +104,68 @@ def _get_toolchain_platform_info() -> tuple[str, str, str]: return sysname, machine, extension -def check_and_install() -> None: +def _get_version_str() -> str: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - version = f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + return f"v{framework_ver.major}.{framework_ver.minor}.{framework_ver.patch}" + + +def get_build_paths() -> dict: + version = _get_version_str() + return { + "python_executable": get_python_env_executable_path( + _get_python_env_path(version), "python" + ), + "framework_path": _get_framework_path(version), + } + + +def get_build_env() -> dict: + version = _get_version_str() + venv_bin_dir = get_python_env_executable_path( + _get_python_env_path(version), "python" + ).parent + env = os.environ.copy() + env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "") + env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr") + env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(_TOOLCHAIN_VERSION) / "cmake") + return env + + +def check_and_install() -> None: + version = _get_version_str() python_env_path = _get_python_env_path(version) env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" - install_venv = not sentinel.exists() + install_venv = ( + not sentinel.exists() + or _REQUIREMENTS.stat().st_mtime > sentinel.stat().st_mtime + ) if install_venv: rmdir(python_env_path, msg=f"Clean up {version} Python environment") - create_venv(python_env_path, msg=f"{version}") + create_venv(python_env_path, msg=version) _install_sitecustomize(python_env_path) - _LOGGER.info("Installing west %s ...", _WEST_VERSION) - cmd = [str(env_python_path), "-m", "pip", "install", f"west=={_WEST_VERSION}"] + _LOGGER.info("Installing requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(_REQUIREMENTS), + ] if not run_command_ok(cmd): - raise EsphomeError(f"Install west for {version} Python environment failure") + raise EsphomeError( + f"Install requirements for {version} Python environment failure" + ) sentinel.touch() framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" - if install_venv or not sentinel.exists(): + zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" + if not sentinel.exists() or not zephyr_reqs.exists(): rmdir(framework_path, msg=f"Clean up {version} framework environment") _LOGGER.info("Initializing nRF Connect SDK %s ...", version) cmd = [ @@ -128,7 +176,7 @@ def check_and_install() -> None: "-m", "https://github.com/nrfconnect/sdk-nrf", "--mr", - f"{version}", + version, str(framework_path), ] if not run_command_ok(cmd): @@ -146,17 +194,47 @@ def check_and_install() -> None: raise EsphomeError(f"Can't update nRF Connect SDK {version}") sentinel.touch() + zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" + if ( + install_venv + or not zephyr_sentinel.exists() + or zephyr_reqs.stat().st_mtime > zephyr_sentinel.stat().st_mtime + ): + _LOGGER.info("Installing Zephyr requirements ...") + cmd = [ + str(env_python_path), + "-m", + "pip", + "install", + "-r", + str(zephyr_reqs), + ] + if not run_command_ok(cmd): + raise EsphomeError(f"Install Zephyr requirements for {version} failure") + zephyr_sentinel.touch() + toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): rmdir( toolchains_dir, msg=f"Clean up {_TOOLCHAIN_VERSION} toolchain environment" ) + sysname, machine, extension = _get_toolchain_platform_info() + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading Zephyr SDK %s minimal ...", _TOOLCHAIN_VERSION) + download_from_mirrors( + SDK_NG_MINIMAL_MIRRORS, + { + "VERSION": _TOOLCHAIN_VERSION, + "sysname": sysname, + "machine": machine, + "extension": extension, + }, + tmp.file, + ) + archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") with tempfile.NamedTemporaryFile() as tmp: _LOGGER.info("Downloading %s toolchain ...", _TOOLCHAIN_VERSION) - - sysname, machine, extension = _get_toolchain_platform_info() - download_from_mirrors( SDK_NG_TOOLCHAIN_MIRRORS, { @@ -167,5 +245,9 @@ def check_and_install() -> None: }, tmp.file, ) - archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") + archive_extract_all( + tmp.file, + toolchains_dir / "arm-zephyr-eabi", + progress_header="Extracting", + ) sentinel.touch() diff --git a/esphome/components/nrf52/requirements.txt b/esphome/components/nrf52/requirements.txt new file mode 100644 index 0000000000..250d3a29cf --- /dev/null +++ b/esphome/components/nrf52/requirements.txt @@ -0,0 +1,3 @@ +west==1.5.0 +ninja==1.13.0 +cmake==4.3.2 diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index a5a3ea5104..22bce4cc41 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -1,4 +1,5 @@ #include "online_image.h" +#include "esphome/components/runtime_image/image_decoder.h" #include "esphome/core/log.h" #include @@ -181,7 +182,7 @@ void OnlineImage::loop() { auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread()); if (consumed < 0) { - ESP_LOGE(TAG, "Error decoding image: %d", consumed); + ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed)); this->end_connection_(); this->download_error_callback_.call(); return; diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index bc1e91d6da..215f921229 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -10,6 +10,8 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage +from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -20,6 +22,7 @@ from esphome.const import ( CONF_OUTPUT_POWER, CONF_USE_ADDRESS, PLATFORM_ESP32, + PlatformFramework, ) from esphome.core import ( CORE, @@ -52,7 +55,6 @@ AUTO_LOAD = ["network"] # Wi-fi / Bluetooth / Thread coexistence isn't implemented at this time # TODO: Doesn't conflict with wifi if you're using another ESP as an RCP (radio coprocessor), but this isn't implemented yet CONFLICTS_WITH = ["wifi"] -DEPENDENCIES = ["esp32"] IDF_TO_OT_LOG_LEVEL = { "NONE": "NONE", @@ -98,9 +100,7 @@ def set_sdkconfig_options(config): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - if tlv := config.get(CONF_TLV): - cg.add_define("USE_OPENTHREAD_TLVS", tlv) - else: + if not config.get(CONF_TLV): if pan_id := config.get(CONF_PAN_ID): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) @@ -128,9 +128,6 @@ def set_sdkconfig_options(config): "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() ) - if config.get(CONF_FORCE_DATASET): - cg.add_define("USE_OPENTHREAD_FORCE_DATASET") - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) @@ -159,6 +156,11 @@ _CONNECTION_SCHEMA = cv.Schema( def _validate(config: ConfigType) -> ConfigType: if CONF_USE_ADDRESS not in config: config[CONF_USE_ADDRESS] = f"{CORE.name}.local" + if CORE.using_zephyr and CONF_TLV not in config: + raise cv.Invalid( + "On nRF52, OpenThread credentials must be provided via 'tlv'. " + "Individual parameters (network_key, pan_id, channel, etc.) are not yet supported on this platform." + ) device_type = config.get(CONF_DEVICE_TYPE) poll_period = config.get(CONF_POLL_PERIOD) if ( @@ -175,11 +177,33 @@ def _validate(config: ConfigType) -> ConfigType: def _require_vfs_select(config): """Register VFS select requirement during config validation.""" - # OpenThread uses esp_vfs_eventfd which requires VFS select support - require_vfs_select() + # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) + if CORE.is_esp32: + require_vfs_select() return config +def _validate_platform(config): + if CORE.using_zephyr: + return config + return only_on_variant( + supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2] + )(config) + + +def _validate_tlv_hex(value): + s = cv.string_strict(value) + if len(s) % 2 != 0: + raise cv.Invalid("TLV must have an even number of hex characters") + try: + raw = bytes.fromhex(s) + except ValueError as e: + raise cv.Invalid(f"TLV must be valid hex: {e}") from e + if len(raw) > 254: # sizeof(otOperationalDatasetTlvs::mTlvs) + raise cv.Invalid(f"TLV too long ({len(raw)} bytes, max 254)") + return s + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -190,7 +214,7 @@ CONFIG_SCHEMA = cv.All( *CONF_DEVICE_TYPES, upper=True ), cv.Optional(CONF_FORCE_DATASET): cv.boolean, - cv.Optional(CONF_TLV): cv.string_strict, + cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, cv.Optional(CONF_OUTPUT_POWER): cv.All( @@ -200,7 +224,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), - only_on_variant(supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2]), + _validate_platform, _validate, _require_vfs_select, ) @@ -227,13 +251,27 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "openthread_esp.cpp": { + PlatformFramework.ESP32_IDF, + }, + "openthread_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + } +) + @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): # Re-enable openthread IDF component (excluded by default) - include_builtin_idf_component("openthread") + if CORE.is_esp32: + include_builtin_idf_component("openthread") cg.add_define("USE_OPENTHREAD") + if config.get(CONF_FORCE_DATASET): + cg.add_define("USE_OPENTHREAD_FORCE_DATASET") + if tlv := config.get(CONF_TLV): + cg.add_define("USE_OPENTHREAD_TLVS", tlv) # OpenThread SRP needs access to mDNS services after setup enable_mdns_storage() @@ -252,4 +290,12 @@ async def to_code(config): if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: cg.add(ot.set_output_power(output_power)) - set_sdkconfig_options(config) + if CORE.is_esp32: + set_sdkconfig_options(config) + elif CORE.using_zephyr: + zephyr_add_prj_conf("NET_L2_OPENTHREAD", True) + zephyr_add_prj_conf( + f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True + ) + zephyr_add_prj_conf(f"OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) + zephyr_add_prj_conf("MAIN_STACK_SIZE", 4096) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index bf14514636..102424c62e 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -132,7 +132,7 @@ void OpenThreadSrpComponent::setup() { char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size); const auto &host_name = App.get_name(); uint16_t host_name_len = host_name.size(); - if (host_name_len > size) { + if (host_name_len >= size) { ESP_LOGW(TAG, "Hostname is too long, choose a shorter project name"); return; } @@ -151,7 +151,7 @@ void OpenThreadSrpComponent::setup() { return; } - // Get mdns services and copy their data (strings are copied with strdup below) + // Get mdns services and copy their data (strdup on ESP32, pool_alloc_ on Zephyr) const auto &mdns_services = this->mdns_->get_services(); ESP_LOGD(TAG, "Setting up SRP services. count = %d\n", mdns_services.size()); for (const auto &service : mdns_services) { @@ -164,7 +164,7 @@ void OpenThreadSrpComponent::setup() { // Set service name char *string = otSrpClientBuffersGetServiceEntryServiceNameString(entry, &size); std::string full_service = std::string(MDNS_STR_ARG(service.service_type)) + "." + MDNS_STR_ARG(service.proto); - if (full_service.size() > size) { + if (full_service.size() >= size) { ESP_LOGW(TAG, "Service name too long: %s", full_service.c_str()); continue; } @@ -172,7 +172,7 @@ void OpenThreadSrpComponent::setup() { // Set instance name (using host_name) string = otSrpClientBuffersGetServiceEntryInstanceNameString(entry, &size); - if (host_name_len > size) { + if (host_name_len >= size) { ESP_LOGW(TAG, "Instance name too long: %s", host_name.c_str()); continue; } @@ -189,11 +189,21 @@ void OpenThreadSrpComponent::setup() { for (size_t i = 0; i < service.txt_records.size(); i++) { const auto &txt = service.txt_records[i]; // Value is either a compile-time string literal in flash or a pointer to dynamic_txt_values_ - // OpenThread SRP client expects the data to persist, so we strdup it + // OpenThread SRP client expects the data to persist, so we copy it const char *value_str = MDNS_STR_ARG(txt.value); txt_entries[i].mKey = MDNS_STR_ARG(txt.key); +#ifndef USE_ZEPHYR txt_entries[i].mValue = reinterpret_cast(strdup(value_str)); txt_entries[i].mValueLength = strlen(value_str); +#else + // strdup is not available on zephyr + // https:// github.com/zephyrproject-rtos/zephyr/issues/22464 + size_t value_len = strlen(value_str); + char *value_copy = reinterpret_cast(this->pool_alloc_(value_len + 1)); + memcpy(value_copy, value_str, value_len + 1); + txt_entries[i].mValue = reinterpret_cast(value_copy); + txt_entries[i].mValueLength = value_len; +#endif } entry->mService.mTxtEntries = txt_entries; entry->mService.mNumTxtEntries = service.txt_records.size(); @@ -227,13 +237,13 @@ bool OpenThreadComponent::teardown() { ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory"); return true; } - otInstance *instance = lock->get_instance(); + otInstance *instance = lock.get_instance(); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); global_openthread_component = nullptr; ESP_LOGD(TAG, "Exit main loop "); int error = this->openthread_stop_(); - if (error != ESP_OK) { + if (error != 0) { ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error); this->teardown_complete_ = true; } diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 5898492a50..f1c79fb9cb 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -43,10 +43,11 @@ class OpenThreadComponent : public Component { void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } #endif void set_output_power(int8_t output_power) { this->output_power_ = output_power; } + void set_connected(bool connected) { this->connected_ = connected; } + static void on_state_changed(otChangedFlags flags, void *context); protected: std::optional get_omr_address_(InstanceLock &lock); - static void on_state_changed(otChangedFlags flags, void *context); otInstance *get_openthread_instance_(); int openthread_stop_(); std::function factory_reset_external_callback_; @@ -86,19 +87,32 @@ class OpenThreadSrpComponent : public Component { void *pool_alloc_(size_t size); }; +// RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the +// guard may or may not own the lock (try_acquire can fail), so check it with +// operator bool before use. Non-copyable and non-movable: the factories return +// by value via guaranteed copy elision, so a guard is never duplicated and the +// lock is released exactly once, when the owning guard goes out of scope. class InstanceLock { public: - static std::optional try_acquire(int delay); + // May fail to acquire within delay ms; check the returned guard with operator bool. + static InstanceLock try_acquire(int delay); + // Blocks until the lock is held. static InstanceLock acquire(); + InstanceLock(const InstanceLock &) = delete; + InstanceLock(InstanceLock &&) = delete; + InstanceLock &operator=(const InstanceLock &) = delete; + InstanceLock &operator=(InstanceLock &&) = delete; ~InstanceLock(); - // Returns the global openthread instance guarded by this lock + explicit operator bool() const { return this->owns_; } + + // Returns the global openthread instance. Only valid on an owning guard + // (operator bool is true); the instance must not be used without the lock held. otInstance *get_instance(); private: - // Use a private constructor in order to force the handling - // of acquisition failure - InstanceLock() {} + explicit InstanceLock(bool owns) : owns_(owns) {} + bool owns_; }; } // namespace esphome::openthread diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index cf1288d90c..6edaa98524 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -216,14 +216,11 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } -std::optional InstanceLock::try_acquire(int delay) { - if (!global_openthread_component->is_lock_initialized()) { - return {}; +InstanceLock InstanceLock::try_acquire(int delay) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { + return InstanceLock(false); } - if (esp_openthread_lock_acquire(delay)) { - return InstanceLock(); - } - return {}; + return InstanceLock(esp_openthread_lock_acquire(delay)); } InstanceLock InstanceLock::acquire() { @@ -242,12 +239,16 @@ InstanceLock InstanceLock::acquire() { while (!esp_openthread_lock_acquire(100)) { esp_task_wdt_reset(); } - return InstanceLock(); + return InstanceLock(true); } otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); } -InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } +InstanceLock::~InstanceLock() { + if (this->owns_) { + esp_openthread_lock_release(); + } +} } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread/openthread_zephyr.cpp b/esphome/components/openthread/openthread_zephyr.cpp new file mode 100644 index 0000000000..7b9f14ab8c --- /dev/null +++ b/esphome/components/openthread/openthread_zephyr.cpp @@ -0,0 +1,141 @@ +#include "esphome/core/defines.h" +#if defined(USE_OPENTHREAD) && defined(USE_NRF52) +#include +#include +#include +#include "openthread.h" +#include "esphome/core/helpers.h" +#include + +static const char *const TAG = "openthread"; + +namespace esphome::openthread { + +static void on_thread_state_changed(otChangedFlags flags, struct openthread_context *ot_context, void *user_data) { + // Delegate connection status tracking to common callback + if (global_openthread_component != nullptr) { + OpenThreadComponent::on_state_changed(flags, global_openthread_component); + } + if (flags & OT_CHANGED_THREAD_ROLE) { + otDeviceRole role = otThreadGetDeviceRole(ot_context->instance); + ESP_LOGI(TAG, "Thread role changed to %s", otThreadDeviceRoleToString(role)); + } + if (flags & OT_CHANGED_THREAD_NETDATA) { + ESP_LOGI(TAG, "Thread network data updated"); + } + if (flags & (OT_CHANGED_THREAD_ROLE | OT_CHANGED_THREAD_NETDATA)) { + char buf[NET_IPV6_ADDR_LEN]; + for (const otNetifAddress *addr = otIp6GetUnicastAddresses(ot_context->instance); addr != nullptr; + addr = addr->mNext) { + ESP_LOGI(TAG, " Address: %s", net_addr_ntop(AF_INET6, &addr->mAddress, buf, sizeof(buf))); + } + } +} + +static struct openthread_state_changed_cb ot_state_changed_cb = {.state_changed_cb = on_thread_state_changed}; + +void OpenThreadComponent::setup() { + struct openthread_context *context = openthread_get_default_context(); + this->lock_initialized_ = true; + otOperationalDatasetTlvs dataset = {}; + +#ifndef USE_OPENTHREAD_FORCE_DATASET + otError error = otDatasetGetActiveTlvs(context->instance, &dataset); + if (error != OT_ERROR_NONE) { + dataset.mLength = 0; + } else { + ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); + } +#endif + +#ifdef USE_OPENTHREAD_TLVS + if (dataset.mLength == 0) { + const size_t tlv_chars = sizeof(USE_OPENTHREAD_TLVS) - 1; + if ((tlv_chars % 2) != 0) { + ESP_LOGE(TAG, "Invalid OpenThread TLV hex string length (must be even, got %zu)", tlv_chars); + this->mark_failed(); + return; + } + + size_t len = tlv_chars / 2; + if (len > sizeof(dataset.mTlvs)) { + ESP_LOGE(TAG, "OpenThread TLV too long (max %zu bytes, got %zu bytes)", sizeof(dataset.mTlvs), len); + this->mark_failed(); + return; + } + + size_t parsed = parse_hex(USE_OPENTHREAD_TLVS, tlv_chars, dataset.mTlvs, len); + if (parsed != tlv_chars) { + ESP_LOGE(TAG, "Invalid OpenThread TLV hex string (expected %zu hex chars, got %zu)", tlv_chars, parsed); + this->mark_failed(); + return; + } + dataset.mLength = len; + } +#endif + if (dataset.mLength > 0) { + otError error = otDatasetSetActiveTlvs(context->instance, &dataset); + if (error != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set active dataset: %s", otThreadErrorToString(error)); + this->mark_failed(); + return; + } + } + openthread_state_changed_cb_register(context, &ot_state_changed_cb); + openthread_start(context); +} + +void OpenThreadComponent::ot_main() {} + +otInstance *OpenThreadComponent::get_openthread_instance_() { return openthread_get_default_instance(); } + +int OpenThreadComponent::openthread_stop_() { + // OT stack is intentionally left running — no Zephyr stop API. The state callback stays + // registered but is safe (null-checks global_openthread_component). nRF52840 never + // re-enters setup() after teardown so this is functionally correct. + this->teardown_complete_ = true; + return 0; +} + +network::IPAddresses OpenThreadComponent::get_ip_addresses() { + network::IPAddresses addresses; + auto lock = InstanceLock::acquire(); + size_t addr_count = 0; + for (const otNetifAddress *addr = otIp6GetUnicastAddresses(openthread_get_default_instance()); + addr != nullptr && addr_count + 1 < addresses.size(); addr = addr->mNext) { + struct in6_addr ip6; + memcpy(&ip6, addr->mAddress.mFields.m8, sizeof(ip6)); + addresses[addr_count + 1] = network::IPAddress(&ip6); + addr_count++; + } + return addresses; +} + +InstanceLock InstanceLock::try_acquire(int delay) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { + return InstanceLock(false); + } + struct openthread_context *ot_context = openthread_get_default_context(); + if (k_mutex_lock(&ot_context->api_lock, K_MSEC(delay)) == 0) { + return InstanceLock(true); + } + return InstanceLock(false); +} + +InstanceLock InstanceLock::acquire() { + struct openthread_context *ot_context = openthread_get_default_context(); + k_mutex_lock(&ot_context->api_lock, K_FOREVER); + return InstanceLock(true); +} + +otInstance *InstanceLock::get_instance() { return openthread_get_default_instance(); } + +InstanceLock::~InstanceLock() { + if (this->owns_) { + struct openthread_context *ot_context = openthread_get_default_context(); + k_mutex_unlock(&ot_context->api_lock); + } +} + +} // namespace esphome::openthread +#endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index 10e83281f0..ef7c5cc8e9 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -17,7 +17,7 @@ class OpenThreadInstancePollingComponent : public PollingComponent { return; } - this->update_instance(lock->get_instance()); + this->update_instance(lock.get_instance()); } float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ade726da1f..ac765d8018 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION; } - watchdog::WatchdogManager watchdog(15000); + // esp_ota_begin() erases the destination region, which blocks loopTask and + // scales with the erase size -- a fixed watchdog overruns on large OTA slots. + // An unknown size (0, e.g. web_server uploads) erases the whole partition, so + // budget against the bytes actually erased. ~10ms/KiB (conservative + // ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still + // resets rather than hanging forever. + size_t erase_size = image_size; + if (erase_size == 0 || erase_size > this->partition_->size) { + erase_size = this->partition_->size; + } + const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10; + watchdog::WatchdogManager watchdog(erase_budget_ms); esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_); if (err != ESP_OK) { diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 0b166bb65c..4293dffb15 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -69,7 +69,7 @@ ENCRYPTION_SCHEMA = { cv.Optional(CONF_ENCRYPTION): cv.maybe_simple_value( cv.Schema( { - cv.Required(CONF_KEY): cv.string, + cv.Required(CONF_KEY): cv.sensitive(cv.string), } ), key=CONF_KEY, diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index d36d900997..296ea6c08c 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -16,6 +16,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, get_esp32_variant, idf_version, + variant_filtered_enum, ) import esphome.config_validation as cv from esphome.const import ( @@ -29,6 +30,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "psram" @@ -70,6 +72,11 @@ SPIRAM_SPEEDS = { VARIANT_ESP32P4: (20, 100, 200), } +SPIRAM_SPEEDS_MHZ = { + variant: tuple(f"{speed}MHZ" for speed in speeds) + for variant, speeds in SPIRAM_SPEEDS.items() +} + def supported() -> bool: if not CORE.is_esp32: @@ -145,15 +152,23 @@ def validate_psram_mode(config): return config -def get_config_schema(config): +def _set_variant_defaults(config: ConfigType) -> ConfigType: + """Resolve variant-dependent defaults before the static schema validates. + + The set of valid ``mode``/``speed`` values is variant-specific (enforced by + ``variant_filtered_enum`` in the schema below); this only supplies the default + when the user omits the option. ``mode`` has no single default on chips that + support more than one mode, so selection is required there. + """ variant = get_esp32_variant() - speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] - if not speeds: + modes = SPIRAM_MODES.get(variant) + speeds = SPIRAM_SPEEDS.get(variant) + if not modes or not speeds: raise cv.Invalid("PSRAM is not supported on this chip") - modes = SPIRAM_MODES[variant] - if CONF_MODE not in config and len(modes) != 1: - raise ( - cv.Invalid( + config = config.copy() + if CONF_MODE not in config: + if len(modes) != 1: + raise cv.Invalid( textwrap.dedent( f""" {variant} requires PSRAM mode selection; one of {", ".join(modes)} @@ -161,20 +176,27 @@ def get_config_schema(config): """ ) ) - ) - return cv.Schema( + config[CONF_MODE] = modes[0] + if CONF_SPEED not in config: + config[CONF_SPEED] = f"{speeds[0]}MHZ" + return config + + +CONFIG_SCHEMA = cv.All( + _set_variant_defaults, + cv.Schema( { cv.GenerateID(): cv.declare_id(PsramComponent), - cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True), + cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True), cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean, - cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True), + cv.Optional(CONF_SPEED): variant_filtered_enum( + SPIRAM_SPEEDS_MHZ, upper=True + ), cv.Optional(CONF_DISABLED, default=False): cv.boolean, cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean, } - )(config) - - -CONFIG_SCHEMA = get_config_schema + ), +) def _store_psram_guaranteed(config): diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 8a13110631..ea080adc6b 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -24,6 +24,8 @@ ResamplerSpeaker = resampler_ns.class_( CONF_TAPS = "taps" +PASSTHROUGH = "passthrough" + def _set_stream_limits(config): audio.set_stream_limits( @@ -35,14 +37,21 @@ def _set_stream_limits(config): def _validate_audio_compatibility(config): - inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) + # In passthrough mode the output bits per sample is determined at runtime from the input stream, so there is + # nothing to inherit or validate against the output speaker. + passthrough = config.get(CONF_BITS_PER_SAMPLE) == PASSTHROUGH + if not passthrough: + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) + audio.final_validate_audio_schema( "source_speaker", audio_device=CONF_OUTPUT_SPEAKER, - bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + bits_per_sample=cv.UNDEFINED + if passthrough + else config.get(CONF_BITS_PER_SAMPLE), channels=config.get(CONF_NUM_CHANNELS), sample_rate=config.get(CONF_SAMPLE_RATE), )(config) @@ -60,6 +69,9 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(ResamplerSpeaker), cv.Required(CONF_OUTPUT_SPEAKER): cv.use_id(speaker.Speaker), + cv.Optional(CONF_BITS_PER_SAMPLE, default=PASSTHROUGH): cv.Any( + cv.one_of(PASSTHROUGH, lower=True), cv.int_range(8, 32) + ), cv.Optional( CONF_BUFFER_DURATION, default="100ms" ): cv.positive_time_period_milliseconds, @@ -90,7 +102,10 @@ async def to_code(config): cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() - cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) + if config[CONF_BITS_PER_SAMPLE] == PASSTHROUGH: + cg.add(var.set_passthrough_bits_per_sample(True)) + else: + cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_target_sample_rate(config[CONF_SAMPLE_RATE])) cg.add(var.set_filters(config[CONF_FILTERS])) diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index ecbd445a80..f1ebd180cc 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -40,11 +40,19 @@ enum ResamplingEventGroupBits : uint32_t { }; void ResamplerSpeaker::dump_config() { - ESP_LOGCONFIG(TAG, - "Resampler Speaker:\n" - " Target Bits Per Sample: %u\n" - " Target Sample Rate: %" PRIu32 " Hz", - this->target_bits_per_sample_, this->target_sample_rate_); + if (this->passthrough_bits_per_sample_) { + ESP_LOGCONFIG(TAG, + "Resampler Speaker:\n" + " Target Bits Per Sample: passthrough\n" + " Target Sample Rate: %" PRIu32 " Hz", + this->target_sample_rate_); + } else { + ESP_LOGCONFIG(TAG, + "Resampler Speaker:\n" + " Target Bits Per Sample: %" PRIu8 "\n" + " Target Sample Rate: %" PRIu32 " Hz", + this->target_bits_per_sample_, this->target_sample_rate_); + } } void ResamplerSpeaker::setup() { @@ -253,8 +261,12 @@ void ResamplerSpeaker::send_command_(uint32_t command_bit, bool wake_loop) { void ResamplerSpeaker::start() { this->send_command_(ResamplingEventGroupBits::COMMAND_START, true); } esp_err_t ResamplerSpeaker::start_() { - this->target_stream_info_ = audio::AudioStreamInfo( - this->target_bits_per_sample_, this->audio_stream_info_.get_channels(), this->target_sample_rate_); + // In passthrough mode, the output keeps the input's bits per sample so only the sample rate is resampled. + const uint8_t target_bits_per_sample = this->passthrough_bits_per_sample_ + ? this->audio_stream_info_.get_bits_per_sample() + : this->target_bits_per_sample_; + this->target_stream_info_ = audio::AudioStreamInfo(target_bits_per_sample, this->audio_stream_info_.get_channels(), + this->target_sample_rate_); this->output_speaker_->set_audio_stream_info(this->target_stream_info_); this->output_speaker_->start(); @@ -305,7 +317,11 @@ void ResamplerSpeaker::set_volume(float volume) { } bool ResamplerSpeaker::requires_resampling_() const { - return (this->audio_stream_info_.get_sample_rate() != this->target_sample_rate_) || + if (this->audio_stream_info_.get_sample_rate() != this->target_sample_rate_) { + return true; + } + // In passthrough mode the bits per sample always matches the input, so it never forces resampling. + return !this->passthrough_bits_per_sample_ && (this->audio_stream_info_.get_bits_per_sample() != this->target_bits_per_sample_); } diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index 4a091e298a..f482ce4b88 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -49,6 +49,12 @@ class ResamplerSpeaker : public Component, public speaker::Speaker { } void set_target_sample_rate(uint32_t target_sample_rate) { this->target_sample_rate_ = target_sample_rate; } + /// @brief When enabled, the input bits per sample are passed through to the output speaker unchanged instead of being + /// converted to a fixed target. Only the sample rate is resampled if it differs from the target. + void set_passthrough_bits_per_sample(bool passthrough_bits_per_sample) { + this->passthrough_bits_per_sample_ = passthrough_bits_per_sample; + } + void set_filters(uint16_t filters) { this->filters_ = filters; } void set_taps(uint16_t taps) { this->taps_ = taps; } @@ -80,23 +86,24 @@ class ResamplerSpeaker : public Component, public speaker::Speaker { speaker::Speaker *output_speaker_{nullptr}; - bool task_stack_in_psram_{false}; - bool waiting_for_output_{false}; - StaticTask task_; audio::AudioStreamInfo target_stream_info_; - uint16_t taps_; - uint16_t filters_; - - uint8_t target_bits_per_sample_; - uint32_t target_sample_rate_; + uint64_t callback_remainder_{0}; uint32_t buffer_duration_ms_; uint32_t state_start_ms_{0}; + uint32_t target_sample_rate_; - uint64_t callback_remainder_{0}; + uint16_t taps_; + uint16_t filters_; + + uint8_t target_bits_per_sample_{0}; + + bool passthrough_bits_per_sample_{false}; + bool task_stack_in_psram_{false}; + bool waiting_for_output_{false}; }; } // namespace esphome::resampler diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 926108a8a0..c68ea5720b 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -7,8 +7,24 @@ enum DecodeError : int { DECODE_ERROR_INVALID_TYPE = -1, DECODE_ERROR_UNSUPPORTED_FORMAT = -2, DECODE_ERROR_OUT_OF_MEMORY = -3, + DECODE_ERROR_INTERNAL_DECODER_ERROR = -4, }; +constexpr const char *decode_error_to_string(int error) { + switch (error) { + case DECODE_ERROR_INVALID_TYPE: + return "Invalid type"; + case DECODE_ERROR_UNSUPPORTED_FORMAT: + return "Unsupported format"; + case DECODE_ERROR_OUT_OF_MEMORY: + return "Out of memory"; + case DECODE_ERROR_INTERNAL_DECODER_ERROR: + return "Internal decoder error"; + default: + return "Unknown error"; + } +} + class RuntimeImage; /** diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index dcaa07cd58..c46e86fd0d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -89,9 +89,21 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { return DECODE_ERROR_OUT_OF_MEMORY; } if (!this->jpeg_.decode(0, 0, 0)) { - ESP_LOGE(TAG, "Error while decoding."); + auto error = this->jpeg_.getLastError(); + ESP_LOGE(TAG, "Error while decoding: %d", error); this->jpeg_.close(); - return DECODE_ERROR_UNSUPPORTED_FORMAT; + switch (error) { + case JPEG_ERROR_MEMORY: + return DECODE_ERROR_OUT_OF_MEMORY; + case JPEG_UNSUPPORTED_FEATURE: + return DECODE_ERROR_UNSUPPORTED_FORMAT; + case JPEG_INVALID_FILE: + case JPEG_INVALID_PARAMETER: + return DECODE_ERROR_INVALID_TYPE; + case JPEG_DECODE_ERROR: + default: + return DECODE_ERROR_INTERNAL_DECODER_ERROR; + } } this->decoded_bytes_ = size; this->jpeg_.close(); diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 591504328d..12bce0d284 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -95,6 +95,7 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) { auto fed = pngle_feed(this->pngle_, buffer, size); if (fed < 0) { ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_)); + return DECODE_ERROR_INTERNAL_DECODER_ERROR; } else { this->decoded_bytes_ += fed; } diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 888d48e672..1e4910453a 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -47,7 +47,7 @@ class RuntimeStatsCollector { // overhead between Phase A and stats belongs to "residual"). // Residual overhead at log time = active − Σ(component) − before − tail, // which captures per-iteration inter-component bookkeeping (set_current_component, - // WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls, + // LoopBlockingGuard construction/destruction, feed_wdt_with_time calls, // the for-loop itself). void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) { this->period_active_count_++; diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 847fab02bd..6cd33e566c 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -3,6 +3,7 @@ #include #include #include +#include "esphome/core/application.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -57,6 +58,14 @@ template class Script : public ScriptLogger, public Triggerexecute(std::get(tuple)...); } + // Run the action chain with this script's name published as the current source (RAII save/restore, + // so nesting composes), so deferred work inside the script is attributed to it in blocking + // warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame). + inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE { + ScopedSourceGuard source_guard{this->name_}; + this->trigger(x...); + } + const LogString *name_{nullptr}; }; @@ -74,7 +83,7 @@ template class SingleScript : public Script { return; } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -91,7 +100,7 @@ template class RestartScript : public Script { this->stop_action(); } - this->trigger(x...); + this->run_actions_(x...); } }; @@ -136,7 +145,7 @@ template class QueueingScript : public Script, public Com return; } - this->trigger(x...); + this->run_actions_(x...); // Check if the trigger was immediate and we can continue right away. this->loop(); } @@ -175,7 +184,7 @@ template class QueueingScript : public Script, public Com } template void trigger_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->trigger(std::get(tuple)...); + this->run_actions_(std::get(tuple)...); } int num_queued_ = 0; // Number of queued instances (not including currently running) @@ -197,7 +206,7 @@ template class ParallelScript : public Script { LOG_STR_ARG(this->name_)); return; } - this->trigger(x...); + this->run_actions_(x...); } void set_max_runs(int max_runs) { max_runs_ = max_runs; } diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 7c3dab15ad..17c6c811dd 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -27,10 +27,6 @@ void Select::publish_state(size_t index) { const char *option = this->option_at(index); this->set_has_state(true); this->active_index_ = index; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->state = option; // Update deprecated member for backward compatibility -#pragma GCC diagnostic pop ESP_LOGV(TAG, "'%s' >> %s (%zu)", this->get_name().c_str(), option, index); this->state_callback_.call(index); #if defined(USE_SELECT) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 465283d92a..34d9248523 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -30,15 +30,8 @@ class Select : public EntityBase { public: SelectTraits traits; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use current_option() instead. This member will be removed in ESPHome 2026.7.0. - ESPDEPRECATED("Use current_option() instead of .state. Will be removed in 2026.7.0", "2026.1.0") - std::string state{}; - Select() = default; ~Select() = default; -#pragma GCC diagnostic pop void publish_state(const std::string &state); void publish_state(const char *state); diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 19c0cb500e..5eb34add65 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -32,7 +32,7 @@ from esphome.const import ( UNIT_PERCENT, ) -CODEOWNERS = ["@martgras", "@mebner86", "@mikelawrence", "@tuct"] +CODEOWNERS = ["@martgras", "@mebner86", "@tuct"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 107b6a3f1a..0731078eec 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate { write_only_(write_only) { if (!this->release_device_) add_device_(); + + if (this->write_only_) { + ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", + Utility::get_pin_no(this->cs_pin_)); + } } bool is_ready() override { return this->handle_ != nullptr; } @@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate { config.post_cb = nullptr; if (this->bit_order_ == BIT_ORDER_LSB_FIRST) config.flags |= SPI_DEVICE_BIT_LSBFIRST; - if (this->write_only_) { + if (this->write_only_) config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY; - ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", - Utility::get_pin_no(this->cs_pin_)); - } esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Add device failed - err %X", err); diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py new file mode 100644 index 0000000000..51cf3cfd91 --- /dev/null +++ b/esphome/components/ufm01/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@ljungqvist"] + +MULTI_CONF = True + +DEPENDENCIES = ["uart"] + +ufm01_ns = cg.esphome_ns.namespace("ufm01") +UFM01Component = ufm01_ns.class_("UFM01Component", uart.UARTDevice, cg.Component) + +CONF_UFM01_ID = "ufm01_id" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(UFM01Component), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "ufm01", + require_tx=True, + require_rx=True, + baud_rate=2400, + parity="EVEN", + stop_bits=1, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/ufm01/binary_sensor.py b/esphome/components/ufm01/binary_sensor.py new file mode 100644 index 0000000000..92ae585d96 --- /dev/null +++ b/esphome/components/ufm01/binary_sensor.py @@ -0,0 +1,52 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_UFC_CHIP_ERROR = "ufc_chip_error" +CONF_FLOW_DIRECTION_WRONG = "flow_direction_wrong" +CONF_EMPTY_TUBE = "empty_tube" +CONF_FLOW_RATE_OUT_OF_RANGE = "flow_rate_out_of_range" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_UFC_CHIP_ERROR): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, device_class=DEVICE_CLASS_PROBLEM + ), + cv.Optional(CONF_FLOW_DIRECTION_WRONG): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), + cv.Optional(CONF_EMPTY_TUBE): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), + cv.Optional(CONF_FLOW_RATE_OUT_OF_RANGE): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=DEVICE_CLASS_PROBLEM, + ), +} + + +async def to_code(config): + ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + + if ufc_chip_error_config := config.get(CONF_UFC_CHIP_ERROR): + sens = await binary_sensor.new_binary_sensor(ufc_chip_error_config) + cg.add(ufm01_component.set_ufc_chip_error_binary_sensor(sens)) + + if flow_direction_wrong_config := config.get(CONF_FLOW_DIRECTION_WRONG): + sens = await binary_sensor.new_binary_sensor(flow_direction_wrong_config) + cg.add(ufm01_component.set_flow_direction_wrong_binary_sensor(sens)) + + if empty_tube_config := config.get(CONF_EMPTY_TUBE): + sens = await binary_sensor.new_binary_sensor(empty_tube_config) + cg.add(ufm01_component.set_empty_tube_binary_sensor(sens)) + + if flow_rate_out_of_range_config := config.get(CONF_FLOW_RATE_OUT_OF_RANGE): + sens = await binary_sensor.new_binary_sensor(flow_rate_out_of_range_config) + cg.add(ufm01_component.set_flow_rate_out_of_range_binary_sensor(sens)) diff --git a/esphome/components/ufm01/sensor.py b/esphome/components/ufm01/sensor.py new file mode 100644 index 0000000000..4dcd7ceebe --- /dev/null +++ b/esphome/components/ufm01/sensor.py @@ -0,0 +1,63 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_FLOW, + CONF_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLUME_FLOW_RATE, + DEVICE_CLASS_WATER, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_CUBIC_METER_PER_HOUR, + UNIT_LITRE, +) + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_ACCUMULATED_FLOW = "accumulated_flow" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_ACCUMULATED_FLOW): sensor.sensor_schema( + unit_of_measurement=UNIT_LITRE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_WATER, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_FLOW): sensor.sensor_schema( + unit_of_measurement=UNIT_CUBIC_METER_PER_HOUR, + accuracy_decimals=5, + device_class=DEVICE_CLASS_VOLUME_FLOW_RATE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:waves-arrow-right", + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:thermometer-water", + ), + } +) + + +async def to_code(config): + ufm01_component = await cg.get_variable(config[CONF_UFM01_ID]) + + if CONF_ACCUMULATED_FLOW in config: + sens = await sensor.new_sensor(config[CONF_ACCUMULATED_FLOW]) + cg.add(ufm01_component.set_accumulated_flow_sensor(sens)) + + if CONF_FLOW in config: + sens = await sensor.new_sensor(config[CONF_FLOW]) + cg.add(ufm01_component.set_flow_sensor(sens)) + + if CONF_TEMPERATURE in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) + cg.add(ufm01_component.set_temperature_sensor(sens)) diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp new file mode 100644 index 0000000000..1380c34284 --- /dev/null +++ b/esphome/components/ufm01/ufm01.cpp @@ -0,0 +1,234 @@ +#include "ufm01.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ufm01 { + +static const char *const TAG = "ufm01"; + +static constexpr uint8_t COMMAND_ACK = 0xE5; +static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 200; + +static constexpr float L_PER_M3 = 1000.0f; +static constexpr float M3_PER_L = 1.0f / L_PER_M3; + +static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; +static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; + +// Active-mode frame layout (datasheet Table 7) +static constexpr size_t FRAME_CHECKSUM_INDEX = 30; +static constexpr size_t FRAME_STOP_INDEX = 31; +static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; +static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; +static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; +static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; +static constexpr uint8_t FRAME_INDEX_TEMP_FLAG = 24; +static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; +static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; +static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; + +// Measurement decoding +static constexpr uint8_t FRAME_ACC_FLOW_FLAG_INDEX = 8; +static constexpr uint8_t ACC_FLOW_M3_FLAG = 0x1A; +static constexpr uint8_t FRAME_FLOW_SIGN_INDEX = 20; +static constexpr uint8_t FLOW_NEGATIVE_SIGN = 0x80; + +// Status bytes (datasheet ST1 / ST2) +static constexpr uint8_t FRAME_ST1_INDEX = 28; +static constexpr uint8_t FRAME_ST2_INDEX = 29; +static constexpr uint8_t ST1_EMPTY_TUBE_MASK = 0x20; +static constexpr uint8_t ST2_UFC_ERROR_MASK = 0x20; +static constexpr uint8_t ST2_FLOW_DIRECTION_WRONG_MASK = 0x08; +static constexpr uint8_t ST2_FLOW_RATE_OUT_OF_RANGE_MASK = 0x04; + +static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } + +static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { + if (data[index] == expected) + return true; + ESP_LOGW(TAG, "%s (byte %zu) - expected 0x%02X, but was 0x%02X", name, index, expected, data[index]); + return false; +} + +static bool validate_data(uint8_t data[FRAME_SIZE]) { + uint8_t sum = 0; + for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) + sum += data[i]; + return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") && + check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") && + check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") && + check_byte(data, FRAME_INDEX_RESERVED_SECTION, FRAME_FLAG_RESERVED_SECTION, "reserved section flag") && + check_byte(data, FRAME_INDEX_TEMP_FLAG, FRAME_FLAG_TEMP, "temperature flag") && + check_byte(data, FRAME_CHECKSUM_INDEX, sum, "checksum") && + check_byte(data, FRAME_STOP_INDEX, FRAME_STOP_BYTE, "stop byte"); +} + +static float read_accumulated_flow(uint8_t data[FRAME_SIZE]) { + return (data[FRAME_ACC_FLOW_FLAG_INDEX] == ACC_FLOW_M3_FLAG ? L_PER_M3 : 1.0f) * + (to_float(data[14]) * 10000000.0f + to_float(data[13]) * 100000.0f + to_float(data[12]) * 1000.0f + + to_float(data[11]) * 10.0f + to_float(data[10]) * 0.1f + to_float(data[9]) * 0.001f); +} + +static float read_flow(uint8_t data[FRAME_SIZE]) { + return (data[FRAME_FLOW_SIGN_INDEX] == FLOW_NEGATIVE_SIGN ? -1.0f : 1.0f) * + (to_float(data[19]) * 10000.0f + to_float(data[18]) * 100.0f + to_float(data[17]) + + to_float(data[16]) * 0.01f) * + M3_PER_L; +} + +static void log_hex(const uint8_t *data, size_t len) { + char hex_buf[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); +} + +static float read_temperature(uint8_t data[FRAME_SIZE]) { + // happens sometimes before getting a real reading + if (data[27] == 0x00 && (data[26] == 0x00 || data[26] == 0x70) && data[25] == 0x00) { + return NAN; + } + return to_float(data[27]) * 100.0f + to_float(data[26]) + to_float(data[25]) * 0.01f; +} + +static bool read_ufc_chip_error(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST2_INDEX] & ST2_UFC_ERROR_MASK; } + +static bool read_flow_direction_wrong(const uint8_t data[FRAME_SIZE]) { + return data[FRAME_ST2_INDEX] & ST2_FLOW_DIRECTION_WRONG_MASK; +} + +static bool read_empty_tube(const uint8_t data[FRAME_SIZE]) { return data[FRAME_ST1_INDEX] & ST1_EMPTY_TUBE_MASK; } + +static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { + return data[FRAME_ST2_INDEX] & ST2_FLOW_RATE_OUT_OF_RANGE_MASK; +} + +bool UFM01Component::send_command_(const std::array &command) { + this->write_array(command); + this->flush(); + const uint32_t start = millis(); + while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { + if (this->available()) { + uint8_t byte; + if (this->read_byte(&byte)) { + if (byte == COMMAND_ACK) + return true; + ESP_LOGV(TAG, "Unexpected byte while waiting for command ACK: 0x%02X", byte); + } + } + delay(1); + } + return false; +} + +bool UFM01Component::reset_device_() { return this->send_command_(RESET_DEVICE); } + +bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEAR_ACCUMULATED_FLOW); } + +bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } + +float UFM01Component::get_setup_priority() const { return setup_priority::IO; } + +void UFM01Component::setup() { + ESP_LOGI(TAG, "Setting up UFM-01..."); + if (!this->set_active_mode_()) { + ESP_LOGW(TAG, "Failed to set active mode (no ACK from device)"); + this->mark_failed(); + } +} + +void UFM01Component::dump_config() { + ESP_LOGCONFIG(TAG, "UFM-01:"); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Accumulated Flow", this->accumulated_flow_sensor_); + LOG_SENSOR(" ", "Flow", this->flow_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "UFC Chip Error", this->ufc_chip_error_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Flow Direction Wrong", this->flow_direction_wrong_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); +#endif + this->check_uart_settings(2400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); + if (this->is_failed()) { + ESP_LOGW(TAG, "Setup failed: active mode not acknowledged by device"); + } +} + +void UFM01Component::on_data_(uint8_t data[FRAME_SIZE]) { + bool empty_tube = read_empty_tube(data); +#ifdef USE_BINARY_SENSOR + if (this->ufc_chip_error_binary_sensor_ != nullptr) + this->ufc_chip_error_binary_sensor_->publish_state(read_ufc_chip_error(data)); + if (this->flow_direction_wrong_binary_sensor_ != nullptr) + this->flow_direction_wrong_binary_sensor_->publish_state(read_flow_direction_wrong(data)); + if (this->empty_tube_binary_sensor_ != nullptr) + this->empty_tube_binary_sensor_->publish_state(empty_tube); + if (this->flow_rate_out_of_range_binary_sensor_ != nullptr) + this->flow_rate_out_of_range_binary_sensor_->publish_state(read_flow_rate_out_of_range(data)); +#endif + +#ifdef USE_SENSOR + // Total volume remains valid when the tube is dry; flow and temperature are not. + if (this->accumulated_flow_sensor_ != nullptr) + this->accumulated_flow_sensor_->publish_state(read_accumulated_flow(data)); + + if (empty_tube) { + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); + } else { + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(read_flow(data)); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(read_temperature(data)); + } +#endif +} + +void UFM01Component::loop() { + // Drain the UART buffer each loop, reading one byte at a time into the frame + while (this->available()) { + if (!this->read_byte(&this->data_[this->read_index_])) { + ESP_LOGW(TAG, "unable to read byte"); + this->read_index_ = 0; + continue; + } + if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) || + (this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) { + ESP_LOGW(TAG, "not start of data at %d (is 0x%02X)", this->read_index_, this->data_[this->read_index_]); + this->read_index_ = 0; + continue; + } + if (++this->read_index_ < static_cast(FRAME_SIZE)) + continue; + + // Full frame received + if (validate_data(this->data_)) { + this->on_data_(this->data_); + this->read_index_ = 0; + continue; + } + + // Invalid frame: try to resync on the next start marker within the buffer + log_hex(this->data_, sizeof(this->data_)); + ESP_LOGE(TAG, "unable to read data"); + for (int32_t i = 2; + i < static_cast(FRAME_STOP_INDEX) && this->read_index_ == static_cast(FRAME_SIZE); ++i) { + if ((this->data_[i] == FRAME_START_BYTE_1) && (this->data_[i + 1] == FRAME_START_BYTE_2)) { + for (int32_t j = i; j < static_cast(FRAME_SIZE); ++j) + this->data_[j - i] = this->data_[j]; + this->read_index_ = static_cast(FRAME_SIZE) - i; + } + } + if (this->read_index_ == static_cast(FRAME_SIZE)) + this->read_index_ = 0; + } +} + +} // namespace esphome::ufm01 diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h new file mode 100644 index 0000000000..e759de9169 --- /dev/null +++ b/esphome/components/ufm01/ufm01.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#include "esphome/components/uart/uart.h" + +#include + +// component API definition at https://www.sciosense.com/wp-content/uploads/2025/06/UFM-01-Datasheet-1.pdf + +namespace esphome::ufm01 { + +static constexpr size_t FRAME_SIZE = 32; + +class UFM01Component : public uart::UARTDevice, public Component { +#ifdef USE_SENSOR + SUB_SENSOR(accumulated_flow) + SUB_SENSOR(flow) + SUB_SENSOR(temperature) +#endif + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(ufc_chip_error) + SUB_BINARY_SENSOR(flow_direction_wrong) + SUB_BINARY_SENSOR(empty_tube) + SUB_BINARY_SENSOR(flow_rate_out_of_range) +#endif + + public: + void setup() override; + + void dump_config() override; + + void loop() override; + + float get_setup_priority() const override; + + protected: + bool clear_accumulated_flow_(); + bool set_active_mode_(); + bool reset_device_(); + + private: + bool send_command_(const std::array &command); + + int32_t read_index_ = 0; + uint8_t data_[FRAME_SIZE]; + void on_data_(uint8_t data[FRAME_SIZE]); +}; + +} // namespace esphome::ufm01 diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index 6ce0795cdb..e2a7aee1a2 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_TIME_ID, DEVICE_CLASS_DURATION, - DEVICE_CLASS_UPTIME, + DEVICE_CLASS_TIMESTAMP, ENTITY_CATEGORY_DIAGNOSTIC, ICON_TIMER, STATE_CLASS_TOTAL_INCREASING, @@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema( ).extend(cv.polling_component_schema("60s")), "timestamp": sensor.sensor_schema( UptimeTimestampSensor, + icon=ICON_TIMER, accuracy_decimals=0, - device_class=DEVICE_CLASS_UPTIME, + device_class=DEVICE_CLASS_TIMESTAMP, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ) .extend( diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 909a27c81c..cdb8544fbb 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -164,36 +164,9 @@ EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const { } #endif - // Try matching by entity name (new format) + // Match by entity name if (this->id == entity->get_name()) { result.matched = true; - return result; - } - - // Fall back to object_id (deprecated format) - char object_id_buf[OBJECT_ID_MAX_LEN]; - StringRef object_id = entity->get_object_id_to(object_id_buf); - if (this->id == object_id) { - result.matched = true; - // Log deprecation warning -#ifdef USE_DEVICES - Device *device = entity->get_device(); - if (device != nullptr) { - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s/%.*s - use entity name '/%.*s/%s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - (int) this->domain.size(), this->domain.c_str(), (int) this->device_name.size(), - this->device_name.c_str(), (int) this->id.size(), this->id.c_str(), (int) this->domain.size(), - this->domain.c_str(), device->get_name(), entity->get_name().c_str()); - } else -#endif - { - ESP_LOGW(TAG, - "Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. " - "Object ID URLs will be removed in 2026.7.0.", - (int) this->domain.size(), this->domain.c_str(), (int) this->id.size(), this->id.c_str(), - (int) this->domain.size(), this->domain.c_str(), entity->get_name().c_str()); - } } return result; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 25f8f8212d..e4defdbd9a 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -76,7 +76,7 @@ struct UrlMatch { bool method_equals(const __FlashStringHelper *str) const { return this->method == str; } #endif - /// Match entity by name first, then fall back to object_id with deprecation warning + /// Match entity by name /// Returns EntityMatchResult with match status and whether action segment is empty EntityMatchResult match_entity(EntityBase *entity) const; }; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b7719c80d1..1cfd2b9821 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -10,6 +10,7 @@ from esphome.components.esp32 import ( const, get_esp32_variant, only_on_variant, + request_wifi, ) from esphome.components.network import ( has_high_performance_networking, @@ -594,9 +595,11 @@ async def to_code(config): ) cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) cg.add_define("USE_WIFI_AP") - elif CORE.is_esp32 and not CORE.using_arduino: - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) - add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) + + # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which + # drops SoftAP support / the LWIP DHCP server when AP mode is unused. + if CORE.is_esp32: + request_wifi(ap=CONF_AP in config) # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: @@ -761,6 +764,7 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +RUNTIME_ROAMING_SUPPRESSION_KEY = "wifi_runtime_roaming_suppression" # Keys for listener counts IP_STATE_LISTENERS_KEY = "wifi_ip_state_listeners" SCAN_RESULTS_LISTENERS_KEY = "wifi_scan_results_listeners" @@ -791,6 +795,19 @@ def enable_runtime_power_save_control(): CORE.data[RUNTIME_POWER_SAVE_KEY] = True +def enable_runtime_roaming_suppression() -> None: + """Enable runtime suppression of post-connect roaming scans. + + Components that are disrupted by the radio briefly going off-channel during a + roaming scan (e.g., audio playback) should call this function during their code + generation. This enables the request_roaming_suppression() and + release_roaming_suppression() APIs, which pause periodic roaming scans while active. + + Only supported on ESP32. + """ + CORE.data[RUNTIME_ROAMING_SUPPRESSION_KEY] = True + + def request_wifi_ip_state_listener() -> None: """Request an IP state listener slot.""" CORE.data[IP_STATE_LISTENERS_KEY] = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) + 1 @@ -824,6 +841,8 @@ async def final_step(): ) if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False): cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") + if CORE.data.get(RUNTIME_ROAMING_SUPPRESSION_KEY, False): + cg.add_define("USE_WIFI_RUNTIME_ROAMING_SUPPRESSION") # Generate listener defines - each listener type has its own #ifdef ip_state_count = CORE.data.get(IP_STATE_LISTENERS_KEY, 0) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 07cb2ac243..ffc6ea8e14 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -822,7 +822,7 @@ void WiFiComponent::loop() { } // else: scan in progress, wait } else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS && - now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL) { + now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) { this->check_roaming_(now); } } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index d0521e548a..c774e3a68e 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -16,6 +16,8 @@ #endif #include "esphome/core/string_ref.h" +#include +#include #include #include #include @@ -604,6 +606,49 @@ class WiFiComponent final : public Component { bool release_high_performance(); #endif // USE_WIFI_RUNTIME_POWER_SAVE +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + /** Request that post-connect roaming scans be suppressed. + * + * Components that are disrupted by the radio briefly going off-channel during a + * scan (e.g., audio playback) can call this to pause periodic roaming scans while + * active. Multiple components can request suppression simultaneously; roaming + * resumes once every requester has called release_roaming_suppression(). + * + * A roaming scan already in progress is allowed to finish; this only prevents new + * roaming scans from starting. The roaming interval timer is not reset, so roaming + * resumes on the next loop once suppression is released (and the interval elapsed). + * + * Note: Only supported on ESP32. + * + * Thread-safe: may be called from any task. + */ + void request_roaming_suppression() { + uint8_t current = this->roaming_suppression_count_.load(std::memory_order_relaxed); + // CAS loop: saturate at max instead of wrapping, so an excess of requests can't roll the + // counter back to zero and unintentionally re-enable roaming. + while (current < std::numeric_limits::max() && + !this->roaming_suppression_count_.compare_exchange_weak(current, current + 1, std::memory_order_relaxed)) { + } + } + + /** Release a roaming suppression request. + * + * Must be paired with a prior request_roaming_suppression() call. When all requests + * are released (count reaches zero), post-connect roaming resumes. A release with no + * outstanding request is ignored rather than underflowing the counter. + * + * Thread-safe: may be called from any task. + */ + void release_roaming_suppression() { + uint8_t current = this->roaming_suppression_count_.load(std::memory_order_relaxed); + // CAS loop: decrement only if non-zero, so an unmatched release can't wrap the counter + // and permanently suppress roaming. + while (current > 0 && + !this->roaming_suppression_count_.compare_exchange_weak(current, current - 1, std::memory_order_relaxed)) { + } + } +#endif // USE_ESP32 && USE_WIFI_RUNTIME_ROAMING_SUPPRESSION + protected: #ifdef USE_WIFI_AP void setup_ap_config_(); @@ -732,6 +777,15 @@ class WiFiComponent final : public Component { void process_roaming_scan_(); void clear_roaming_state_(); + /// Returns true if a component has requested that roaming scans be suppressed (e.g. during audio playback). + bool roaming_suppressed_() const { +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + return this->roaming_suppression_count_.load(std::memory_order_relaxed) != 0; +#else + return false; +#endif + } + /// Free scan results memory unless a component needs them void release_scan_results_(); @@ -845,6 +899,13 @@ class WiFiComponent final : public Component { // int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS) int8_t selected_sta_index_{-1}; uint8_t roaming_attempts_{0}; +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + // Count of active roaming-suppression requests. Incremented/decremented from any task + // (e.g. audio playback), read in loop(). Roaming scans are paused while non-zero. + // Relaxed ordering is sufficient: the count value is the only data shared across threads, + // so no happens-before relationship with other memory needs to be established. + std::atomic roaming_suppression_count_{0}; +#endif #if USE_NETWORK_IPV6 uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index d08a54529d..83a7332005 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -6,6 +6,13 @@ namespace esphome::xpt2046 { +static constexpr uint8_t XPT_READ_Z1 = 0xB0; +static constexpr uint8_t XPT_READ_Z2 = 0xC0; +static constexpr uint8_t XPT_READ_X = 0xD0; +static constexpr uint8_t XPT_READ_Y = 0x90; +static constexpr uint8_t XPT_ADC_ON = 0x01; +static constexpr uint8_t XPT_VREF_ON = 0x02; + static const char *const TAG = "xpt2046"; void XPT2046Component::setup() { @@ -20,7 +27,7 @@ void XPT2046Component::setup() { this->attach_interrupt_(this->irq_pin_, gpio::INTERRUPT_FALLING_EDGE); } this->spi_setup(); - this->read_adc_(0xD0); // ADC powerdown, enable PENIRQ pin + this->read_adc_(XPT_READ_X); // ADC powerdown, enable PENIRQ pin } void XPT2046Component::update_touches() { @@ -29,21 +36,22 @@ void XPT2046Component::update_touches() { enable(); - int16_t touch_pressure_1 = this->read_adc_(0xB1 /* touch_pressure_1 */); - int16_t touch_pressure_2 = this->read_adc_(0xC1 /* touch_pressure_2 */); + int16_t touch_pressure_1 = this->read_adc_(XPT_READ_Z1 | XPT_ADC_ON); + int16_t touch_pressure_2 = this->read_adc_(XPT_READ_Z2 | XPT_ADC_ON); z_raw = touch_pressure_1 + 0xfff - touch_pressure_2; ESP_LOGVV(TAG, "Touchscreen Update z = %d", z_raw); touch = (z_raw >= this->threshold_); if (touch) { - read_adc_(0xD1 /* X */); // dummy Y measure, 1st is always noisy - data[0] = this->read_adc_(0x91 /* Y */); - data[1] = this->read_adc_(0xD1 /* X */); // make 3 x-y measurements - data[2] = this->read_adc_(0x91 /* Y */); - data[3] = this->read_adc_(0xD1 /* X */); - data[4] = this->read_adc_(0x91 /* Y */); + read_adc_(XPT_READ_X | XPT_ADC_ON); // dummy X measure, 1st is always noisy + // make 3 x-y measurements + data[0] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); + data[1] = this->read_adc_(XPT_READ_X | XPT_ADC_ON); + data[2] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); + data[3] = this->read_adc_(XPT_READ_X | XPT_ADC_ON); + data[4] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON); } - data[5] = this->read_adc_(0xD0 /* X */); // Last X touch power down + data[5] = this->read_adc_(XPT_READ_X); // Last X touch power down disable(); @@ -95,15 +103,16 @@ int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t v return reta; } -int16_t XPT2046Component::read_adc_(uint8_t ctrl) { // NOLINT - uint8_t data[2]; +int16_t XPT2046Component::read_adc_(uint8_t ctrl) { + uint8_t data[3]; - this->write_byte(ctrl); - delay(1); - data[0] = this->read_byte(); - data[1] = this->read_byte(); + data[0] = ctrl; + data[1] = 0; + data[2] = 0; - return ((data[0] << 8) | data[1]) >> 3; + this->transfer_array(data, sizeof(data)); + + return ((data[1] << 8) | data[2]) >> 3; } } // namespace esphome::xpt2046 diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 57f5778d54..bd5f01aa3a 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -76,7 +76,10 @@ def zephyr_data() -> ZephyrData: def zephyr_add_prj_conf( - name: str, value: PrjConfValueType, required: bool = True, image: str = "" + name: str, + value: PrjConfValueType, + required: bool = True, + image: str = "", ) -> None: """Set an zephyr prj conf value.""" if not name.startswith("CONFIG_"): @@ -133,7 +136,7 @@ def zephyr_to_code(config: ConfigType) -> None: # os: ***** USAGE FAULT ***** # os: Illegal load of EXC_RETURN into PC - zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048) + zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048, required=False) CORE.add_job(_cdc_acm_to_code, config) diff --git a/esphome/config.py b/esphome/config.py index 91e6df8bad..33e687137f 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -137,6 +137,96 @@ def _path_begins_with(path: ConfigPath, other: ConfigPath) -> bool: return path[: len(other)] == other +# CORE.data key for the per-alias "already warned this run" dedupe set. +# Cleared between runs because CORE.data is reset; one warning per alias +# per `esphome config|compile|run` invocation is the desired UX. +_ALIAS_WARNED_KEY = "_component_aliases_warned" + + +def _resolve_component_aliases(config: dict[str, Any]) -> None: + """Rewrite legacy top-level keys to their canonical names, in place. + + Looks up each top-level key against the component-alias map built by + :mod:`esphome.loader` (see ``ComponentManifest.aliases``); when a + matching alias is found, the key is moved to its canonical name and a + one-shot deprecation warning is logged (per alias, per run — deduped + via ``CORE.data``). + + Ambiguous configurations raise ``cv.Invalid`` rather than silently + keeping one entry — that would hide a real misconfiguration. Two cases + are rejected: the canonical key together with one of its deprecated + aliases, and two or more different aliases of the same canonical + component. + + The rest of the validator chain (dependency resolution, schema + validation, codegen) sees only canonical names, so component + `DEPENDENCIES = [""]` works regardless of which spelling + the user typed. + """ + alias_meta_map = loader.get_alias_metadata() + if not alias_meta_map: + return + + # Group every legacy alias key present in the config by the canonical + # component it resolves to, preserving config order within each group. + legacy_by_canonical: dict[str, list[str]] = {} + for key in config: + meta = alias_meta_map.get(key) + if meta is not None: + legacy_by_canonical.setdefault(meta.canonical, []).append(key) + + if not legacy_by_canonical: + return + + # Reject ambiguous configurations up front — checking before rewriting + # means a conflict is caught regardless of key order. + for canonical, legacies in legacy_by_canonical.items(): + if canonical in config: + # The canonical key and (at least) one deprecated alias are both + # present. + raise vol.Invalid( + f"Both '{legacies[0]}:' (deprecated alias of '{canonical}:') " + f"and '{canonical}:' are present in the configuration. Remove " + f"the deprecated '{legacies[0]}:' key.", + path=[legacies[0]], + ) + if len(legacies) > 1: + # Several different deprecated aliases of the same component. + listed = ", ".join(f"'{alias}:'" for alias in legacies) + raise vol.Invalid( + f"Multiple deprecated aliases of '{canonical}:' are present " + f"({listed}). Use only '{canonical}:'.", + path=[legacies[0]], + ) + + warned: set[str] = CORE.data.setdefault(_ALIAS_WARNED_KEY, set()) + + # Rebuild in place so each canonical key keeps the legacy key's original + # position — top-level key order matters for some downstream passes + # (e.g. auto-load ordering). A plain `config[canonical] = config.pop(...)` + # would instead move the renamed key to the end. + rewritten: dict[str, Any] = {} + for key, value in config.items(): + meta = alias_meta_map.get(key) + if meta is None: + rewritten[key] = value + continue + rewritten[meta.canonical] = value + if key not in warned: + warned.add(key) + removal = ( + f" Removed in {meta.removal_version}." if meta.removal_version else "" + ) + _LOGGER.warning( + "The '%s:' top-level key is deprecated; rename it to '%s:'.%s", + key, + meta.canonical, + removal, + ) + config.clear() + config.update(rewritten) + + @functools.total_ordering class _ValidationStepTask: def __init__(self, priority: float, id_number: int, step: ConfigValidationStep): @@ -1048,6 +1138,18 @@ def validate_config( substitutions = config.pop(CONF_SUBSTITUTIONS, None) CORE.raw_config = config + # 1.15. Resolve component aliases so legacy top-level keys + # (`rp2040:`, …) route to their canonical component before any + # downstream pass touches the config. Logs a deprecation warning + # per alias; mutates `config` in place. Errors here surface as + # plain config errors and abort further validation. + try: + _resolve_component_aliases(config) + except vol.Invalid as err: + result.update(config) + result.add_error(err) + return result + # 1.2. Resolve !extend and !remove and check for REPLACEME # After this step, there will not be any Extend or Remove values in the config anymore try: diff --git a/esphome/const.py b/esphome/const.py index 22351244bd..3ca7b2e618 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.6.0-dev" +__version__ = "2026.7.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 90c162fedd..21ff7ef07c 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -593,6 +593,8 @@ class EsphomeCore: self.build_flags: set[str] = set() # A set of build unflags to set in the platformio project self.build_unflags: set[str] = set() + # The C++ language standard for the build (e.g. "gnu++20"), set via cg.set_cpp_standard() + self.cpp_standard: str | None = None # A set of defines to set for the compile process in esphome/core/defines.h self.defines: set[Define] = set() # A map of all platformio options to apply @@ -649,6 +651,7 @@ class EsphomeCore: self.platformio_libraries = {} self.build_flags = set() self.build_unflags = set() + self.cpp_standard = None self.defines = set() self.platformio_options = {} self.loaded_integrations = set() @@ -955,6 +958,13 @@ class EsphomeCore: return build_flag def add_build_unflag(self, build_unflag: str) -> None: + if self.using_toolchain_esp_idf: + # The native ESP-IDF build generator does not consume build_unflags + _LOGGER.warning( + "Build unflag %s is ignored when building with the native " + "ESP-IDF toolchain", + build_unflag, + ) self.build_unflags.add(build_unflag) _LOGGER.debug("Adding build unflag: %s", build_unflag) diff --git a/esphome/core/application.h b/esphome/core/application.h index 369c970d46..7c12a66b2c 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -104,9 +104,13 @@ class Application { void register_area(Area *area) { this->areas_.push_back(area); } #endif - void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } + // Owning script of the action chain currently executing (nullptr when none); used to attribute + // blocking warnings for deferred work to the script that scheduled it. + void set_current_source(const LogString *source) { this->current_source_ = source; } + const LogString *get_current_source() { return this->current_source_; } + // Entity register methods (generated from entity_types.h). // Each entity type gets two overloads: // - register_(obj) — bare push_back @@ -393,6 +397,7 @@ class Application { protected: friend Component; friend class Scheduler; + friend class LoopBlockingGuard; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -402,6 +407,14 @@ class Application { /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + // Publish the running unit's identity (component + source) and dispatch time together, so a + // dispatch site can't set one without the others. Friend-only (Scheduler). + void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) { + this->current_component_ = component; + this->current_source_ = source; + this->set_loop_component_start_time_(now); + } + /// 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 @@ -482,6 +495,7 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; + const LogString *current_source_{nullptr}; // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components @@ -554,6 +568,76 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the +/// previous value on exit, attributing deferred work scheduled inside to that source. +class ScopedSourceGuard { + public: + explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) { + App.set_current_source(source); + } + ~ScopedSourceGuard() { App.set_current_source(this->prev_); } + ScopedSourceGuard(const ScopedSourceGuard &) = delete; + ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete; + + private: + const LogString *prev_; +}; + +// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the +// main loop too long. The constructor publishes the unit's identity + dispatch time to App; +// finish()/the cold warning path read them back, so the guard stores no copy. +// +// Guards must not nest: the constructor publishes to App but never restores on destruction, so a +// nested guard would clobber the outer's context. Safe because the two dispatch sites (component +// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback. +class LoopBlockingGuard { + public: + // Publish the unit's identity + dispatch time, then start timing. The millis start lives in App, + // so only the runtime-stats micros stamp is kept here. + LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) { + App.set_current_execution_context_(component, source, now); +#ifdef USE_RUNTIME_STATS + this->started_us_ = micros(); +#endif + } + + // Finish the timing operation and return the current time (millis) + // Inlined: the fast path is just millis() + subtract + compare + inline uint32_t HOT finish() { +#ifdef USE_RUNTIME_STATS + uint32_t elapsed_us = micros() - this->started_us_; + // Delays have no component; accumulate into the global counter so loop() can subtract them. + Component *component = App.get_current_component(); + if (component != nullptr) { + component->runtime_stats_.record_time(elapsed_us); + } else { + ComponentRuntimeStats::global_recorded_us += elapsed_us; + } +#endif + uint32_t curr_time = MillisInternal::get(); +#ifndef USE_BENCHMARK + // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) + static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; + uint32_t blocking_time = curr_time - App.get_loop_component_start_time(); + if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { + warn_blocking(blocking_time); + } +#endif + return curr_time; + } + + ~LoopBlockingGuard() = default; + +#ifdef USE_RUNTIME_STATS + protected: + uint32_t started_us_; +#endif + + private: + // Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit. + static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // before/tail splits recorded below. uint32_t loop_active_start_us = micros(); // Snapshot the cumulative component-recorded time so we can subtract the - // slice that the scheduler spends inside its own WarnIfComponentBlockingGuard + // slice that the scheduler spends inside its own LoopBlockingGuard // (scheduler.cpp) — that time is already counted in per-component stats, // so charging it again to "before" would double-count. uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us; @@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { this->current_loop_index_++) { Component *component = this->looping_components_[this->current_loop_index_]; - // Update the cached time before each component runs - this->loop_component_start_time_ = last_op_end_time; - { - this->set_current_component(component); - WarnIfComponentBlockingGuard guard{component, last_op_end_time}; + // Guard publishes this component (no script source) + dispatch time, then times loop(). + LoopBlockingGuard guard{component, nullptr, last_op_end_time}; component->loop(); // Use the finish method to get the current time as the end time last_op_end_time = guard.finish(); diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index dcad7c9d2e..cf8b05a300 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -201,7 +201,10 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(), [this]() { this->play_next_(); }, - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // Record the owning script (if any) so the blocking warning can name it; propagates across + // chained delays via the scheduler. + /* source= */ App.get_current_source()); } else { // For delays with arguments, capture by value to preserve argument values // Arguments must be copied because original references may be invalid after delay @@ -212,7 +215,9 @@ template class DelayAction : public Action { /* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER, /* static_name= */ reinterpret_cast(this), /* hash_or_id= */ 0, this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1, + // See the no-argument branch above: record the owning script for log attribution. + /* source= */ App.get_current_source()); } } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2d80301897..7ef5ff50a5 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -258,9 +258,11 @@ void Component::call() { break; } } -bool Component::should_warn_of_blocking(uint32_t blocking_time) { +bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) { // Convert centisecond threshold to milliseconds for comparison uint32_t threshold_ms = static_cast(this->warn_if_blocking_over_) * 10U; + // Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate. + threshold_ms_out = threshold_ms; if (blocking_time > threshold_ms) { // Set new threshold: blocking_time + increment, converted back to centiseconds uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS; @@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #endif -void __attribute__((noinline, cold)) -WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) { - bool should_warn; +void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) { + // Identity is published on App by the caller before the guard is built; read it back here. + Component *component = App.get_current_component(); + // Component-less path always warns (the caller already checked the constant threshold). + uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS; + if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) { + return; // Component's (possibly ratcheted) threshold not exceeded yet + } + // Component name if any, else the published source (owning script), else a generic label. + const LogString *name; if (component != nullptr) { - should_warn = component->should_warn_of_blocking(blocking_time); + name = component->get_component_log_str(); } else { - should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller - } - if (should_warn) { - ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms", - component == nullptr ? LOG_STR_LITERAL("") : LOG_STR_ARG(component->get_component_log_str()), - blocking_time); + name = App.get_current_source(); + if (name == nullptr) + name = LOG_STR("a scheduled task"); } + ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name), + blocking_time, threshold_ms); } #ifdef USE_SETUP_PRIORITY_OVERRIDE diff --git a/esphome/core/component.h b/esphome/core/component.h index ff10f1a8f1..299a5f72ea 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -118,7 +118,7 @@ struct ComponentRuntimeStats { // Cumulative sum of every record_time() duration since boot, across all // components. Used by Application::loop() to snapshot time spent inside - // WarnIfComponentBlockingGuard (including guards constructed by the + // LoopBlockingGuard (including guards constructed by the // scheduler at scheduler.cpp) so main-loop overhead accounting can // subtract scheduled-callback time from the before_loop_tasks_ wall time. static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -326,7 +326,7 @@ class Component { return component_source_lookup(this->component_source_index_); } - bool should_warn_of_blocking(uint32_t blocking_time); + bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out); protected: friend class Application; @@ -571,7 +571,7 @@ class Component { volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; ComponentRuntimeStats runtime_stats_; #endif }; @@ -619,59 +619,7 @@ class PollingComponent : public Component { uint32_t update_interval_; }; -// millis() and micros() are available via hal.h - -class WarnIfComponentBlockingGuard { - public: - WarnIfComponentBlockingGuard(Component *component, uint32_t start_time) - : started_(start_time), - component_(component) -#ifdef USE_RUNTIME_STATS - , - started_us_(micros()) -#endif - { - } - - // Finish the timing operation and return the current time (millis) - // Inlined: the fast path is just millis() + subtract + compare - inline uint32_t HOT finish() { -#ifdef USE_RUNTIME_STATS - uint32_t elapsed_us = micros() - this->started_us_; - // component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...)) - if (this->component_ != nullptr) { - this->component_->runtime_stats_.record_time(elapsed_us); - } else { - // Still accumulate into the global counter so Application::loop() can subtract - // this time from before_loop_tasks_ wall time. - ComponentRuntimeStats::global_recorded_us += elapsed_us; - } -#endif - uint32_t curr_time = MillisInternal::get(); -#ifndef USE_BENCHMARK - // Fast path: compare against constant threshold in ms (computed at compile time from centiseconds) - static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast(WARN_IF_BLOCKING_OVER_CS) * 10U; - uint32_t blocking_time = curr_time - this->started_; - if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { - warn_blocking(this->component_, blocking_time); - } -#endif - return curr_time; - } - - ~WarnIfComponentBlockingGuard() = default; - - protected: - uint32_t started_; - Component *component_; -#ifdef USE_RUNTIME_STATS - uint32_t started_us_; -#endif - - private: - // Cold path for blocking warning - defined in component.cpp - static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time); -}; +// LoopBlockingGuard lives in application.h because it reads its state from App. // Function to clear setup priority overrides after all components are set up // Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined diff --git a/esphome/core/config.py b/esphome/core/config.py index 8214fcf80c..b925f0b7d9 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None: include_file(path, basename, is_c_header) +def _add_library_str(lib: str) -> None: + if "@" in lib: + name, vers = lib.split("@", 1) + cg.add_library(name, vers) + elif "://" in lib: + # Repository... + if "=" in lib: + name, repo = lib.split("=", 1) + cg.add_library(name, None, repo) + else: + cg.add_library(None, None, lib) + else: + cg.add_library(lib, None) + + @coroutine_with_priority(CoroPriority.FINAL) -async def _add_platformio_options(pio_options): +async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: + if CORE.using_toolchain_esp_idf: + # The native ESP-IDF build doesn't read platformio.ini; honor the + # options with a native equivalent and warn about the rest, which + # would otherwise be silently ignored. + for key, val in pio_options.items(): + vals = [val] if isinstance(val, str) else val + if key == CONF_BUILD_FLAGS: + # Deprecated: esphome->build_flags is the native equivalent. + # Remove before 2026.12.0 + _LOGGER.warning( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead. Support for it will be removed " + "in 2026.12.0." + ) + for flag in vals: + cg.add_build_flag(flag) + elif key == "lib_deps": + # Routed through the regular library mechanism so the libraries + # are converted to IDF components like any other PIO library + for lib in vals: + _add_library_str(lib) + elif key == "lib_ignore": + # Read by the PIO-library-to-IDF-component conversion + # (generate_idf_components); filters both top-level libraries + # and dependencies discovered during conversion + cg.add_platformio_option(key, vals) + elif key != "upload_speed": + # upload_speed needs no handling: it is read from the raw + # config at upload time (upload_using_esptool) + _LOGGER.warning( + "esphome->platformio_options->%s is ignored when building with " + "the native ESP-IDF toolchain", + key, + ) + return # Add includes at the very end, so that they override everything for key, val in pio_options.items(): if key in ["build_flags", "lib_ignore"] and not isinstance(val, list): @@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None: # Libraries for lib in config[CONF_LIBRARIES]: - if "@" in lib: - name, vers = lib.split("@", 1) - cg.add_library(name, vers) - elif "://" in lib: - # Repository... - if "=" in lib: - name, repo = lib.split("=", 1) - cg.add_library(name, None, repo) - else: - cg.add_library(None, None, lib) - - else: - cg.add_library(lib, None) + _add_library_str(lib) cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 410858f904..17b5e64862 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -312,6 +312,7 @@ #define ESPHOME_WIFI_CONNECT_STATE_LISTENERS 2 #define ESPHOME_WIFI_POWER_SAVE_LISTENERS 2 #define USE_WIFI_RUNTIME_POWER_SAVE +#define USE_WIFI_RUNTIME_ROAMING_SUPPRESSION #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 36000d4e77..2042c43804 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,8 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +extern void esphome_wake_loop_threadsafe(void); + #ifdef USE_OTA_PLATFORM_ESPHOME static struct netconn *s_ota_listener_conn = NULL; extern void esphome_wake_ota_component_any_context(void); @@ -189,10 +191,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt esphome_wake_ota_component_any_context(); } #endif - TaskHandle_t task = esphome_main_task_handle; - if (task != NULL) { - xTaskNotifyGive(task); - } + esphome_wake_loop_threadsafe(); } } diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index bc1d55a1c4..7297d22357 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -16,7 +16,7 @@ namespace esphome { // Friend-gated accessor for a fast millis() variant intended only for // known task-context callers on the main loop hot path (Application::loop() -// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context +// and LoopBlockingGuard::finish()). It skips the ISR-context // dispatch that the public esphome::millis() pays on ESP32 and libretiny. // // MUST NOT be called from ISR context: on ESP32 and libretiny it calls the @@ -50,7 +50,7 @@ class MillisInternal { #endif } friend class Application; - friend class WarnIfComponentBlockingGuard; + friend class LoopBlockingGuard; }; } // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a7c624486d..15bb9ea239 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function &&func, bool is_retry, bool skip_cancel) { + std::function &&func, bool is_retry, bool skip_cancel, + const LogString *source) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { @@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // Create and populate the scheduler item SchedulerItem *item = this->get_item_from_pool_locked_(); - item->component = component; + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } item->set_name(name_type, static_name, hash_or_id); item->type = type; // Use destroy + placement-new instead of move-assignment. @@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Not reached timeout yet, done for this call break; } - // Don't run on failed components - if (item->component != nullptr && item->component->is_failed()) { + // Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays). + if (this->is_item_failed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; @@ -790,10 +796,21 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { - App.set_current_component(item->component); - // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. - App.set_loop_component_start_time_(now); - WarnIfComponentBlockingGuard guard{item->component, now}; + // Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared + // union slot with a single name-type check. Self-keyed items have no owning component; their slot + // holds the source name (e.g. the owning script), published so deferred work chained inside the + // callback re-captures it and the blocking warning can name the script instead of "". + Component *component; + const LogString *source; + if (item->get_name_type() == NameType::SELF_POINTER) { + component = nullptr; + source = item->source_name; + } else { + component = item->component; + source = nullptr; + } + // Guard publishes the item's identity + dispatch time, then times the callback. + LoopBlockingGuard guard{component, source, now}; item->callback(); uint32_t end = guard.finish(); // Feed the watchdog after each scheduled item (both main heap and defer diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b640aa86fe..378c0fb94b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -183,11 +183,12 @@ class Scheduler { protected: struct SchedulerItem { - // Ordered by size to minimize padding. - // `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive). + // Ordered by size to minimize padding. Mutually exclusive by state; read the component via + // get_component() so SELF_POINTER items read as component-less. union { - Component *component; - SchedulerItem *next_free; + Component *component; // live, non-SELF_POINTER: owning component + const LogString *source_name; // live SELF_POINTER: owning script name (log attribution) + SchedulerItem *next_free; // while pooled }; // Optimized name storage using tagged union - zero heap allocation union { @@ -302,14 +303,23 @@ class Scheduler { next_execution_high_ = static_cast(value >> 32); } constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; } - const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); } + // The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead). + // All component access goes through this so SELF_POINTER items read as component-less. + Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; } + const LogString *get_source() const { + // Same no-source label as warn_blocking, for consistent log vocabulary. + if (name_type_ == NameType::SELF_POINTER) + return source_name != nullptr ? source_name : LOG_STR("a scheduled task"); + return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown"); + } }; // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id + // `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise. void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, - bool skip_cancel = false); + bool skip_cancel = false, const LogString *source = nullptr); // Common implementation for retry - Remove before 2026.8.0 // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id @@ -402,8 +412,10 @@ class Scheduler { // Fixes: https://github.com/esphome/esphome/issues/11940 if (item == nullptr) return false; - if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) || - (match_retry && !item->is_retry)) { + // get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they + // match by the `this` key alone. + if (item->get_component() != component || item->type != type || + (skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -423,11 +435,16 @@ class Scheduler { // Helper to execute a scheduler item uint32_t execute_item_(SchedulerItem *item, uint32_t now); - // Helper to check if item should be skipped - bool should_skip_item_(SchedulerItem *item) const { - return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); + // True if the item's component is failed (so it must not run). SELF_POINTER delays have no + // component (get_component() == nullptr) and always fire. + bool is_item_failed_(SchedulerItem *item) const { + Component *component = item->get_component(); + return component != nullptr && component->is_failed(); } + // Helper to check if item should be skipped + bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); } + // Helper to recycle a SchedulerItem back to the pool. // Takes a raw pointer — caller transfers ownership. The item is either added to the // pool or deleted if the pool is full. diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp index 0bf700daa8..458ef51f89 100644 --- a/esphome/core/wake/wake_freertos.cpp +++ b/esphome/core/wake/wake_freertos.cpp @@ -30,4 +30,9 @@ void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } } // namespace esphome +extern "C" void esphome_wake_loop_threadsafe() { + esphome::wake_request_set(); + esphome_main_task_notify(); +} + #endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 9d2a650ca2..8cb382a77e 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -123,6 +123,14 @@ void wakeable_delay(uint32_t ms) { if (ms == 0) [[unlikely]] { yield(); } + // A socket woke select() early — open the component-phase gate so the + // owning component's loop() drains the data on this tick rather than + // waiting up to loop_interval_ ms. Idempotent if wake_loop_threadsafe() + // already set the flag (wake socket fired); required when an application + // socket fired and nothing else set the flag. + if (ret > 0) { + wake_request_set(); + } return; } // ret < 0: error (EINTR is normal, anything else is unexpected). diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 151018baa4..582b8fc74d 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -705,15 +705,8 @@ def add_build_unflag(build_unflag: str) -> None: def set_cpp_standard(standard: str) -> None: - """Set C++ standard with compiler flag `-std={standard}`.""" - CORE.add_build_unflag("-std=gnu++11") - CORE.add_build_unflag("-std=gnu++14") - CORE.add_build_unflag("-std=gnu++17") - CORE.add_build_unflag("-std=gnu++23") - CORE.add_build_unflag("-std=gnu++2a") - CORE.add_build_unflag("-std=gnu++2b") - CORE.add_build_unflag("-std=gnu++2c") - CORE.add_build_flag(f"-std={standard}") + """Set the C++ language standard for the build (e.g. ``gnu++20``).""" + CORE.cpp_standard = standard def add_define(name: str, value: SafeExpType = None): diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 62d6f0d00d..d3f4d151c2 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: # Gates arduino-only components in esphome/idf_component.yml (IDF reads it at # reconfigure time). Set here -- before the manifest is written/reconfigured. - os.environ["ESPHOME_ARDUINO"] = ( + os.environ["ESPHOME_ARDUINO_COMPONENT"] = ( "1" if settings.target_framework == "arduino" else "0" ) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 7398a91c36..cfd42916b2 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" class Source: - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: raise NotImplementedError @@ -64,10 +64,12 @@ class URLSource(Source): def __init__(self, url: str): self.url = url - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: base_dir = Path(CORE.data_dir) / DOMAIN h = hashlib.new("sha256") h.update(self.url.encode()) + if salt: + h.update(salt.encode()) path = base_dir / h.hexdigest()[:8] / dir_suffix # Marker file written last to signal a complete extraction. Using a # marker (instead of just `path.is_dir()`) means an interrupted @@ -99,12 +101,12 @@ class GitSource(Source): self.url = url self.ref = ref - def download(self, dir_suffix: str, force: bool = False) -> Path: + def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: path, _ = git.clone_or_update( url=self.url, ref=self.ref, refresh=git.NEVER_REFRESH if not force else None, - domain=DOMAIN, + domain=f"{DOMAIN}/{salt}" if salt else DOMAIN, submodules=[], subpath=Path(dir_suffix), ) @@ -146,14 +148,16 @@ class IDFComponent: def get_require_name(self): return self.get_sanitized_name().replace("/", "__") - def download(self, force: bool = False): + def download(self, force: bool = False, salt: str = ""): """ The dependency name should match the directory name at the end of the override path. The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name. If you want to specify the full name of the component with the namespace, replace / in the component name with __. @see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html """ - self.path = self.source.download(self.get_sanitized_name(), force=force) + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt + ) def _apply_extra_script(component: IDFComponent) -> None: @@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: The returned list holds the top-level components (those directly requested); transitive dependencies are converted too and wired into each component's generated manifest. + + ``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by + short name (part after the ``/``), matched against both the top-level + libraries and every dependency discovered during the graph walk. """ nodes: dict[str, _LibNode] = {} + lib_ignore = { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + # The generated CMakeLists.txt/idf_component.yml inside the shared cache + # bake in the dependency wiring, which lib_ignore changes; salt the cache + # path so configs with different lib_ignore values don't fight over (and + # constantly rewrite) the same converted component files. + salt = ( + hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8] + if lib_ignore + else "" + ) + + def is_ignored(name: str | None) -> bool: + if not lib_ignore or name is None: + return False + return name.split("/")[-1].lower() in lib_ignore + def add_spec(name: str | None, version: str | None, repository: str | None) -> str: key, is_git, locator = _node_key(name, version, repository) node = nodes.get(key) or _LibNode(key=key, is_git=is_git) @@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: top_level = [ add_spec(library.name, library.version, library.repository) for library in libraries + if not is_ignored(library.name) ] # Collect + resolve to a fixpoint: a node is (re)resolved whenever its @@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: component = IDFComponent( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.download() + component.download(salt=salt) library_json_path = component.path / "library.json" library_properties_path = component.path / "library.properties" @@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: except InvalidIDFComponent as e: _LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e)) continue + dep_name = _owner_pkgname_to_name( + dependency.get("owner"), dependency.get("name") + ) + if is_ignored(dep_name): + _LOGGER.debug("Skip ignored dependency %s", dep_name) + continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] dep_url = None @@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: dep_url, dep_version = dep_version, None except (TypeError, ValueError): pass - dep_key = add_spec( - _owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")), - dep_version, - dep_url, - ) + dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) worklist.append(dep_key) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index c0e9a0051f..6f4aeef9f0 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -81,8 +81,13 @@ def _get_idf_tools_path() -> Path: Path object pointing to the ESP-IDF tools directory """ if "ESPHOME_ESP_IDF_PREFIX" in os.environ: - return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() - return CORE.data_dir / "idf" + path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + else: + path = CORE.data_dir / "idf" + # Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``) + # doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which + # otherwise warns that the venv interpreter path doesn't match the install. + return path.resolve() # Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 2fef3faf8d..000ce739db 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -14,6 +14,7 @@ from esphome.const import CONF_FRAMEWORK, CONF_SOURCE from esphome.core import CORE, EsphomeError from esphome.espidf.framework import check_esp_idf_install, get_framework_env from esphome.espidf.size_summary import print_summary +from esphome.helpers import add_git_ceiling_directory _LOGGER = logging.getLogger(__name__) @@ -82,6 +83,11 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] |= get_framework_env( *_get_esphome_esp_idf_paths(version) ) + + # Cap git's repo search at the config directory so ESP-IDF's + # `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(env_cache[version], CORE.config_dir) return env_cache[version] @@ -466,6 +472,7 @@ def get_idedata() -> dict | None: pass data = idedata_from_build(compile_commands) + data["prog_path"] = str(get_elf_path()) cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return data diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 276dfbbf1c..6bf389240b 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -20,6 +20,25 @@ PathType = str | os.PathLike _LOGGER = logging.getLogger(__name__) +def get_project_link_flags() -> list[str]: + """Return the sorted -Wl, linker flags from the current build.""" + from esphome.core import CORE # local import to avoid circular dependency + + return sorted(flag for flag in CORE.build_flags if flag.startswith("-Wl,")) + + +def get_project_compile_flags() -> list[str]: + """Return the sorted -D and -W (non-linker) flags from the current build.""" + from esphome.core import CORE # local import to avoid circular dependency + + return [ + flag + for flag in sorted(CORE.build_flags) + if flag.startswith("-D") + or (flag.startswith("-W") and not flag.startswith("-Wl,")) + ] + + def str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string diff --git a/esphome/helpers.py b/esphome/helpers.py index 733474c9c9..ef7e2d0b93 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import MutableMapping from contextlib import suppress import ipaddress import logging @@ -374,6 +375,26 @@ def is_ha_addon(): return get_bool_env("ESPHOME_IS_HA_ADDON") +def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) -> None: + """Add ``directory`` to ``env``'s ``GIT_CEILING_DIRECTORIES`` list. + + Git stops walking up the directory tree to find a repository once it reaches + a ceiling directory, so this caps the search at ``directory`` (the ESPHome + project root). Without it, an uninitialized or corrupt git repo in a parent + directory makes the ``git describe`` that build toolchains run for the app + version error out and fail the whole build. + + ``GIT_CEILING_DIRECTORIES`` is an ``os.pathsep``-joined list of absolute + paths; any existing entries are preserved and duplicates are skipped. + """ + ceiling = str(directory) + existing = env.get("GIT_CEILING_DIRECTORIES", "") + parts = existing.split(os.pathsep) if existing else [] + if ceiling not in parts: + parts.append(ceiling) + env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts) + + def rmtree(path: Path | str) -> None: """Remove a directory tree, handling read-only files on Windows. diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 7cbc2ac4ae..b3b670d77b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.1 + version: 0.3.0 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: @@ -38,7 +38,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.12.8 + version: 2.12.9 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: @@ -109,4 +109,4 @@ dependencies: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b rules: - - if: "$ESPHOME_ARDUINO == 1" + - if: "$ESPHOME_ARDUINO_COMPONENT == 1" diff --git a/esphome/loader.py b/esphome/loader.py index 8823d82fc1..a9287abf86 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -101,6 +101,27 @@ class ComponentManifest: def codeowners(self) -> list[str]: return getattr(self.module, "CODEOWNERS", []) + @property + def aliases(self) -> list[str]: + """Legacy names that should transparently route to this component. + + See the :func:`_build_alias_map` documentation for how aliases are + discovered (AST scan, no execution) and registered both for the YAML + loader (top-level key rename in :mod:`esphome.config`) and for + Python imports (``sys.meta_path`` finder, below). + """ + return getattr(self.module, "ALIASES", []) + + @property + def alias_removal_version(self) -> str | None: + """Optional ESPHome version when the alias warning becomes a hard error. + + Surfaced in the deprecation warning emitted by the YAML pre-pass so + users know how long they have to migrate. ``None`` means the warning + does not mention a specific version. + """ + return getattr(self.module, "ALIAS_REMOVAL_VERSION", None) + @property def instance_type(self) -> "MockObjClass | None": return getattr(self.module, "INSTANCE_TYPE", None) @@ -216,6 +237,17 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: _COMPONENT_CACHE[domain] = manif return manif + # If `domain` is the legacy name of a renamed component, redirect to the + # canonical module so the rest of the loader (and every caller of + # `get_component(legacy)`) transparently sees the new component. + alias_map = _get_alias_map() + if domain in alias_map: + canonical = alias_map[domain] + manif = _lookup_module(canonical, exception) + if manif is not None: + _COMPONENT_CACHE[domain] = manif + return manif + try: module = importlib.import_module(f"esphome.components.{domain}") except ImportError as e: @@ -261,3 +293,276 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non code should never call this. """ _COMPONENT_CACHE[domain] = manifest + + +# --------------------------------------------------------------------------- +# Component aliases (renamed-platform back-compat) +# --------------------------------------------------------------------------- +# +# A component can declare ``ALIASES = ["legacy_name"]`` (and optionally +# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two +# integrations are then wired up automatically: +# +# 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``) +# intercepts ``esphome.components.``/``....`` +# imports and resolves them against the canonical component so external +# custom components that still import from the old path keep working. +# +# 2. **YAML loader** — ``_lookup_module`` consults the alias map so +# ``get_component("legacy")`` returns the canonical manifest. The +# ``esphome.config`` pre-pass uses the same map to rewrite legacy +# top-level keys in the user's config (with a deprecation warning) so +# dependency checks, schema validation and codegen all see only the +# canonical name. +# +# Both lookups are populated by ``_build_alias_map``, which **AST-parses** +# every component's ``__init__.py`` rather than importing it. That keeps the +# cost low: scanning ~400 components on disk takes ~5 ms instead of the +# multi-second cost of executing every component's import side-effects. + + +_ALIAS_MAP_CACHE: dict[str, str] | None = None +_ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None + + +@dataclass(frozen=True) +class AliasMeta: + """Metadata for a single deprecated alias entry. + + Used by the YAML pre-pass in :mod:`esphome.config` to produce a + deprecation warning citing the canonical name and (optionally) the + removal version declared by the canonical component. + """ + + canonical: str + removal_version: str | None + + +def _ensure_alias_caches() -> None: + """Populate both alias caches from a single directory scan. + + ``_build_alias_map`` returns both maps together, so building them in one + shot avoids scanning every component's ``__init__.py`` twice when a run + needs both the canonical map (loader) and the metadata map (config + pre-pass). + """ + global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE + if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None: + _ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map() + + +def _get_alias_map() -> dict[str, str]: + """Return the legacy-name → canonical-name map, building it lazily.""" + _ensure_alias_caches() + return _ALIAS_MAP_CACHE + + +def get_alias_metadata() -> dict[str, AliasMeta]: + """Return the legacy-name → :class:`AliasMeta` map (cached). + + Used by the YAML pre-pass to format a per-alias deprecation warning. + """ + _ensure_alias_caches() + return _ALIAS_META_CACHE + + +def _build_alias_map() -> tuple[dict[str, str], dict[str, AliasMeta]]: + """Scan every core component dir for ``ALIASES`` declarations. + + Uses :mod:`ast` to read each component's ``__init__.py`` without + executing it — component import side-effects (logger setup, + namespace registration, etc.) shouldn't run just because we're + enumerating aliases. + + Raises if the same alias is claimed by two canonical components, since + silently picking one would cause non-deterministic routing depending on + directory-iteration order. Also raises if an alias shadows an existing + component package: that would hijack a live component domain and, in the + self-alias case (alias == canonical), send ``_lookup_module`` into + infinite recursion redirecting a domain to itself. + """ + import ast + + alias_to_canonical: dict[str, str] = {} + alias_to_meta: dict[str, AliasMeta] = {} + + if not CORE_COMPONENTS_PATH.is_dir(): + return alias_to_canonical, alias_to_meta + + for child in sorted(CORE_COMPONENTS_PATH.iterdir()): + if not child.is_dir(): + continue + init = child / "__init__.py" + if not init.is_file(): + continue + aliases, removal_version = _read_aliases(init, ast) + if not aliases: + continue + canonical = child.name + for alias in aliases: + if (CORE_COMPONENTS_PATH / alias / "__init__.py").is_file(): + from esphome.core import EsphomeError + + raise EsphomeError( + f"Component alias '{alias}' (declared by '{canonical}') " + "shadows an existing component package of the same name. " + "An alias may only name a component that no longer exists." + ) + if alias in alias_to_canonical: + from esphome.core import EsphomeError + + raise EsphomeError( + f"Component alias '{alias}' is declared by both " + f"'{alias_to_canonical[alias]}' and '{canonical}'. " + "Each alias must map to exactly one canonical component." + ) + alias_to_canonical[alias] = canonical + alias_to_meta[alias] = AliasMeta( + canonical=canonical, removal_version=removal_version + ) + return alias_to_canonical, alias_to_meta + + +def _read_aliases( + init_path: Path, ast_module: ModuleType +) -> tuple[list[str], str | None]: + """Extract ``ALIASES`` and ``ALIAS_REMOVAL_VERSION`` from a component + ``__init__.py`` via AST parsing. + + Only handles the simple ``NAME = [str_literal, ...]`` / ``NAME = "..."`` + forms — anything more dynamic (function call, conditional, etc.) is + silently ignored. Components should keep their alias declarations + static so this scanner can see them. + """ + try: + source = init_path.read_text(encoding="utf-8") + except OSError as err: + _LOGGER.warning( + "Could not read %s while scanning for component aliases: %s", + init_path, + err, + ) + return [], None + + # Cheap substring pre-filter: almost no component declares ALIASES, and + # parsing every component __init__.py with ast is comparatively expensive. + # Skip the parse entirely unless the token appears in the file at all. + if "ALIASES" not in source: + return [], None + + try: + tree = ast_module.parse(source) + except SyntaxError as err: + _LOGGER.warning( + "Could not parse %s while scanning for component aliases: %s", + init_path, + err, + ) + return [], None + + aliases: list[str] = [] + removal_version: str | None = None + + for node in tree.body: + if not isinstance(node, ast_module.Assign): + continue + for target in node.targets: + if not isinstance(target, ast_module.Name): + continue + if target.id == "ALIASES" and isinstance(node.value, ast_module.List): + aliases.extend( + elt.value + for elt in node.value.elts + if isinstance(elt, ast_module.Constant) + and isinstance(elt.value, str) + ) + elif ( + target.id == "ALIAS_REMOVAL_VERSION" + and isinstance(node.value, ast_module.Constant) + and isinstance(node.value.value, str) + ): + removal_version = node.value.value + return aliases, removal_version + + +class _AliasFinder(importlib.abc.MetaPathFinder): + """``sys.meta_path`` finder that resolves legacy-component imports. + + Routes ``esphome.components.[.]`` to the canonical + component's module/submodule of the same name, so external code that + still imports ``from esphome.components.rp2040 import boards`` keeps + working without the canonical component having to maintain a shim + package on disk. + + The finder caches the resolved module in ``sys.modules`` under the + legacy name on first lookup, so subsequent imports hit the cache and + skip this finder entirely. + """ + + _PREFIX = "esphome.components." + + def find_spec(self, fullname, path, target=None): # noqa: ARG002 + if not fullname.startswith(self._PREFIX): + return None + # Anything matching the ``esphome.components.`` prefix splits into at + # least three parts, so ``parts[2]`` (the domain) always exists. + parts = fullname.split(".") + domain = parts[2] + alias_map = _get_alias_map() + if domain not in alias_map: + return None + + parts[2] = alias_map[domain] + canonical_fullname = ".".join(parts) + try: + canonical_module = importlib.import_module(canonical_fullname) + except ModuleNotFoundError as err: + # Only treat a missing *canonical target* as "no alias to + # resolve" (let the normal import machinery report it). If some + # other module is missing, the canonical exists but failed to + # import one of its own dependencies — surface that real error + # rather than masking it as an unresolved alias. + if err.name == canonical_fullname: + return None + raise + # Do NOT pre-populate ``sys.modules[fullname]`` here. Python's + # ``_find_spec`` (in importlib._bootstrap) has an optimization that + # detects ``name in sys.modules`` after a finder returns and prefers + # ``sys.modules[name].__spec__`` over the finder's spec — for an + # alias, that's the canonical module's own SourceFileLoader spec, + # which Python then *re-loads*, defeating the aliasing. Letting + # ``_load_unlocked`` populate sys.modules itself (via our + # ``_AliasLoader.create_module``) sidesteps that branch. + return importlib.util.spec_from_loader(fullname, _AliasLoader(canonical_module)) + + +class _AliasLoader(importlib.abc.Loader): + """No-op loader that returns the already-resolved canonical module. + + :class:`_AliasFinder` populates ``sys.modules`` itself; this loader + just satisfies the :mod:`importlib` protocol so Python doesn't try to + re-execute the module. + """ + + def __init__(self, module: ModuleType) -> None: + self._module = module + + def create_module(self, spec): # noqa: ARG002 + return self._module + + def exec_module(self, module): # noqa: ARG002 + # Nothing to execute — the canonical module is already initialized. + return None + + +# Register once at module load. Idempotent: re-installing the finder on +# repeated imports (e.g. by tests that reload `esphome.loader`) is a no-op +# because we check for an existing instance first. +def _install_alias_finder() -> None: + for entry in sys.meta_path: + if isinstance(entry, _AliasFinder): + return + sys.meta_path.append(_AliasFinder()) + + +_install_alias_finder() diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index c81420e6ca..c97df812e3 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -7,6 +7,7 @@ import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError +from esphome.helpers import add_git_ceiling_directory from esphome.util import FlashImage, run_external_process _LOGGER = logging.getLogger(__name__) @@ -53,6 +54,10 @@ 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") + # Cap git's repo search at the config directory so the framework's build + # scripts running `git describe` for the app version can't error out on an + # uninitialized or corrupt git repo in a parent directory. + add_git_ceiling_directory(os.environ, CORE.config_dir) # Strip the Windows extended-length path prefix from sys.executable so it # doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted # command lines run through cmd.exe. diff --git a/platformio.ini b/platformio.ini index 718dfb672f..862b7a7dbe 100644 --- a/platformio.ini +++ b/platformio.ini @@ -141,7 +141,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.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.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 = @@ -168,12 +171,16 @@ build_flags = -DAUDIO_NO_SD_FS ; i2s_audio build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + 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.git +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip +platform_packages = + 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 = @@ -187,7 +194,9 @@ build_flags = -DUSE_ESP32_FRAMEWORK_ESP_IDF build_unflags = ${common.build_unflags} -extra_scripts = post:esphome/components/esp32/post_build.py.script +extra_scripts = + pre:esphome/components/esp32/pre_build.py.script + post:esphome/components/esp32/post_build.py.script ; These are common settings for the RP2040 using Arduino. [common:rp2040-arduino] @@ -271,7 +280,6 @@ build_unflags = [env:esp32-arduino] extends = common:esp32-arduino board = esp32dev -board_build.partitions = huge_app.csv build_flags = ${common:esp32-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/requirements.txt b/requirements.txt index 62ed506e36..06a383b00a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ -cryptography==48.0.0 +cryptography==49.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 tornado==6.5.7 -tzlocal==5.3.1 # from time +tzlocal==5.4.3 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 @@ -19,13 +19,13 @@ 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.3.2 +resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 requests==2.34.2 -py7zr==1.1.0 +py7zr==1.1.3 # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 diff --git a/requirements_dev.txt b/requirements_dev.txt index 31463e07c3..7e66c7244d 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ # Useful stuff when working in a development environment clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating -clang-tidy==22.1.0.1 +clang-tidy==22.1.7 yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating diff --git a/requirements_test.txt b/requirements_test.txt index 9da27acc19..4e498abc21 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,11 +1,11 @@ -pylint==4.0.5 +pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.16 # also change in .pre-commit-config.yaml when updating +ruff==0.15.18 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.0.3 +pytest==9.1.1 pytest-cov==7.1.0 pytest-mock==3.15.1 pytest-asyncio==1.4.0 diff --git a/script/build_helpers.py b/script/build_helpers.py index eaf3a1f1a7..50830c221e 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -70,12 +70,15 @@ def populate_dependency_config( * ``domain.platform`` form (e.g. ``sensor.gpio``) appends ``{platform: }`` to ``config[domain]``, creating the list if needed. - * Bare components are looked up via ``get_component_fn``. Platform - components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are - initialised as ``[]`` so the sibling ``domain.platform`` branch can - ``append`` into them. Everything else is populated by running the - component's schema with ``{}`` so defaults exist; if the schema requires - explicit input, an empty ``{}`` is used as a fallback. + * Bare components are looked up via ``get_component_fn``. Target-platform + components (``is_target_platform``, e.g. ``esp32``) are skipped entirely: + a host build targets ``host``, so a foreign target platform's sources are + guarded out and its schema must not run here (it would mutate global CORE + state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``) + and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling + ``domain.platform`` branch can ``append`` into them. Everything else is + populated by running the component's schema with ``{}`` so defaults exist; + if the schema requires explicit input, an empty ``{}`` is used as a fallback. Platform components must always be a list here even when no ``domain.platform`` entry follows, because the ``domain.platform`` branch @@ -96,6 +99,12 @@ def populate_dependency_config( component = get_component_fn(component_name) if component is None: continue + # Skip target platforms (e.g. esp32): a host build targets `host`, so a + # foreign target's sources are guarded out, and running its schema with + # {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF), + # crashing the host compile. See #17035. + if component.is_target_platform: + continue if component.multi_conf or component.is_platform_component: config.setdefault(component_name, []) elif component_name not in config: diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 4b0b0ee548..974957245a 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -428,6 +428,33 @@ def fix_menu(): menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") +def fix_lvgl_widgets(): + # lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The + # dumper has no cycle detection, so — like fix_menu — hoist the inlined + # widget-type enumeration into a named schema and reference it for both the + # top-level list and each widget's own children, instead of expanding it. + if "lvgl" not in output: + return + schemas = output["lvgl"][S_SCHEMAS] + config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS] + widgets = config_vars.get("widgets") + if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]: + return + # 1. Hoist the (one-level) widget enumeration into a named schema. + schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]} + # 2. Reference it from the top-level widgets: list instead of inlining. + widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]} + # 3. Let every widget contain child widgets, via the same named ref. + for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values(): + if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget: + widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = { + S_TYPE: S_SCHEMA, + "is_list": True, + "key": "Optional", + S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}, + } + + def get_logger_tags(): pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE) # tags not in components dir @@ -740,6 +767,7 @@ def build_schema(): add_logger_tags() shrink() fix_menu() + fix_lvgl_widgets() # aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc. data = {} @@ -923,6 +951,15 @@ def convert(schema, config_var, path): elif schema_type == "enum": config_var[S_TYPE] = "enum" config_var["values"] = dict.fromkeys(list(data.keys())) + elif schema_type == "variant_enum": + # Per-variant enum (e.g. psram mode/speed): each value carries the + # list of variants that accept it so clients can filter to the + # user's selected variant. Additive to the plain enum format — + # consumers that ignore the metadata still see every option. + config_var[S_TYPE] = "enum" + config_var["values"] = { + value: {"variants": variants} for value, variants in data.items() + } elif schema_type == "maybe": # maybe_simple_value: either a scalar shorthand (mapped to the key in # data[1]) or the full wrapped schema. The wrapped schema is usually a diff --git a/script/ci-custom.py b/script/ci-custom.py index 78ff6cf781..cbc54ce55d 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -276,7 +276,7 @@ def lint_newline(fname, line, col, content): return "File contains Windows newline. Please set your editor to Unix newline mode." -@lint_content_check(exclude=["*.svg", ".clang-tidy.hash"]) +@lint_content_check(exclude=["*.svg"]) def lint_end_newline(fname, content): if content and not content.endswith("\n"): return "File does not end with a newline, please add an empty line at the end of the file." diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py old mode 100755 new mode 100644 index 62f76246b4..00bcaf45b0 --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -1,66 +1,32 @@ -#!/usr/bin/env python3 -"""Calculate and manage hash for clang-tidy configuration.""" +"""Files that affect clang-tidy results, and a content hash over them. + +``CLANG_TIDY_GLOBAL_FILES`` (plus ``SDKCONFIG_DEFAULTS_PREFIX``) is the single +source of truth for which files influence clang-tidy output. A change to any of +them can surface warnings in source files a PR didn't touch, so: + +* ``script/determine-jobs.py`` runs a full clang-tidy scan when one changes, and +* ``calculate_clang_tidy_hash()`` folds them into the idedata cache key used by + ``script/helpers.py`` (a content hash, unlike an mtime check, stays correct + across git checkouts). +""" from __future__ import annotations -import argparse import hashlib from pathlib import Path -import re -import sys -# Add the script directory to path to import helpers -script_dir = Path(__file__).parent -sys.path.insert(0, str(script_dir)) +# Root-relative paths whose contents affect clang-tidy results. +CLANG_TIDY_GLOBAL_FILES = ( + ".clang-tidy", + "platformio.ini", + "requirements_dev.txt", + "esphome/idf_component.yml", +) - -def read_file_lines(path: Path) -> list[str]: - """Read lines from a file.""" - with path.open() as f: - return f.readlines() - - -def parse_requirement_line(line: str) -> tuple[str, str] | None: - """Parse a requirement line and return (package, original_line) or None. - - Handles formats like: - - package==1.2.3 - - package==1.2.3 # comment - - package>=1.2.3,<2.0.0 - """ - original_line = line.strip() - - # Extract the part before any comment for parsing - parse_line = line - if "#" in parse_line: - parse_line = parse_line[: parse_line.index("#")] - - parse_line = parse_line.strip() - if not parse_line: - return None - - # Use regex to extract package name - # This matches package names followed by version operators - match = re.match(r"^([a-zA-Z0-9_-]+)(==|>=|<=|>|<|!=|~=)(.+)$", parse_line) - if match: - return (match.group(1), original_line) # Return package name and original line - - return None - - -def get_clang_tidy_version_from_requirements(repo_root: Path | None = None) -> str: - """Get clang-tidy version from requirements_dev.txt""" - repo_root = _ensure_repo_root(repo_root) - requirements_path = repo_root / "requirements_dev.txt" - lines = read_file_lines(requirements_path) - - for line in lines: - parsed = parse_requirement_line(line) - if parsed and parsed[0] == "clang-tidy": - # Return the original line (preserves comments) - return parsed[1] - - return "clang-tidy version not found" +# sdkconfig.defaults and per-target sdkconfig.defaults. files flip the +# CONFIG flags that decide which variant code paths clang-tidy sees. Matched by +# this prefix at the repo root. +SDKCONFIG_DEFAULTS_PREFIX = "sdkconfig.defaults" def read_file_bytes(path: Path) -> bytes: @@ -80,130 +46,20 @@ def _ensure_repo_root(repo_root: Path | None) -> Path: def calculate_clang_tidy_hash(repo_root: Path | None = None) -> str: - """Calculate hash of clang-tidy configuration and version""" + """Calculate a hash of the files that affect clang-tidy results.""" repo_root = _ensure_repo_root(repo_root) hasher = hashlib.sha256() - # Hash .clang-tidy file - clang_tidy_path = repo_root / ".clang-tidy" - content = read_file_bytes(clang_tidy_path) - hasher.update(content) + for name in CLANG_TIDY_GLOBAL_FILES: + path = repo_root / name + if path.exists(): + hasher.update(read_file_bytes(path)) - # Hash clang-tidy version from requirements_dev.txt - version = get_clang_tidy_version_from_requirements(repo_root) - hasher.update(version.encode()) - - # Hash the entire platformio.ini file - platformio_path = repo_root / "platformio.ini" - platformio_content = read_file_bytes(platformio_path) - hasher.update(platformio_content) - - # Hash sdkconfig.defaults and any per-target sdkconfig.defaults.: - # the per-target files flip CONFIG flags that change which variant code - # paths clang-tidy sees. Include the filename so a rename is detected. - for sdkconfig_path in sorted(repo_root.glob("sdkconfig.defaults*")): - hasher.update(sdkconfig_path.name.encode()) - hasher.update(read_file_bytes(sdkconfig_path)) - - # Hash esphome/idf_component.yml: its managed deps drive the ESP-IDF - # build's include set, which clang-tidy analyzes. - idf_component_path = repo_root / "esphome" / "idf_component.yml" - if idf_component_path.exists(): - hasher.update(read_file_bytes(idf_component_path)) + # Hash each sdkconfig.defaults* file. Include the filename so adding or + # renaming a per-target variant is detected, not just content edits. + for path in sorted(repo_root.glob(f"{SDKCONFIG_DEFAULTS_PREFIX}*")): + hasher.update(path.name.encode()) + hasher.update(read_file_bytes(path)) return hasher.hexdigest() - - -def read_stored_hash(repo_root: Path | None = None) -> str | None: - """Read the stored hash from file""" - repo_root = _ensure_repo_root(repo_root) - hash_file = repo_root / ".clang-tidy.hash" - if hash_file.exists(): - lines = read_file_lines(hash_file) - return lines[0].strip() if lines else None - return None - - -def write_file_content(path: Path, content: str) -> None: - """Write content to a file.""" - with path.open("w") as f: - f.write(content) - - -def write_hash(hash_value: str, repo_root: Path | None = None) -> None: - """Write hash to file""" - repo_root = _ensure_repo_root(repo_root) - hash_file = repo_root / ".clang-tidy.hash" - # Strip any trailing newlines to ensure consistent formatting - write_file_content(hash_file, hash_value.strip() + "\n") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Manage clang-tidy configuration hash") - parser.add_argument( - "--check", - action="store_true", - help="Check if full scan needed (exit 0 if needed)", - ) - parser.add_argument("--update", action="store_true", help="Update the hash file") - parser.add_argument( - "--update-if-changed", - action="store_true", - help="Update hash only if configuration changed (for pre-commit)", - ) - parser.add_argument( - "--verify", action="store_true", help="Verify hash matches (for CI)" - ) - - args = parser.parse_args() - - current_hash = calculate_clang_tidy_hash() - stored_hash = read_stored_hash() - - if args.check: - # Check if hash changed OR if .clang-tidy.hash was updated in this PR - # This is used in CI to determine if a full clang-tidy scan is needed - hash_changed = current_hash != stored_hash - - # Lazy import to avoid requiring dependencies that aren't needed for other modes - from helpers import changed_files # noqa: E402 - - hash_file_updated = ".clang-tidy.hash" in changed_files() - - # Exit 0 if full scan needed - sys.exit(0 if (hash_changed or hash_file_updated) else 1) - - elif args.verify: - # Verify that hash file is up to date with current configuration - # This is used in pre-commit and CI checks to ensure hash was updated - if current_hash != stored_hash: - print("ERROR: Clang-tidy configuration has changed but hash not updated!") - print(f"Expected: {current_hash}") - print(f"Found: {stored_hash}") - print("\nPlease run: script/clang_tidy_hash.py --update") - sys.exit(1) - print("Hash verification passed") - - elif args.update: - write_hash(current_hash) - print(f"Hash updated: {current_hash}") - - elif args.update_if_changed: - if current_hash != stored_hash: - write_hash(current_hash) - print(f"Clang-tidy hash updated: {current_hash}") - # Exit 0 so pre-commit can stage the file - sys.exit(0) - else: - print("Clang-tidy hash unchanged") - sys.exit(0) - - else: - print(f"Current hash: {current_hash}") - print(f"Stored hash: {stored_hash}") - print(f"Match: {current_hash == stored_hash}") - - -if __name__ == "__main__": - main() diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 94a78e8423..af3e83f96b 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -55,10 +55,10 @@ from functools import cache import json import os from pathlib import Path -import subprocess import sys from typing import Any +from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES, SDKCONFIG_DEFAULTS_PREFIX from helpers import ( CPP_FILE_EXTENSIONS, ESPHOME_TESTS_COMPONENTS_PATH, @@ -280,23 +280,22 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s @cache -def _is_clang_tidy_full_scan() -> bool: - """Check if clang-tidy configuration changed (requires full scan). +def _is_clang_tidy_full_scan(branch: str | None = None) -> bool: + """Check if a clang-tidy-relevant config file changed (requires full scan). + + A change to a file that affects clang-tidy globally can surface warnings in + source files the PR didn't touch, so the entire codebase must be re-scanned. Returns: - True if full scan is needed (hash changed), False otherwise. + True if full scan is needed, False otherwise. """ - try: - result = subprocess.run( - [str(Path(root_path) / "script" / "clang_tidy_hash.py"), "--check"], - capture_output=True, - check=False, - ) - # Exit 0 means hash changed (full scan needed) - return result.returncode == 0 - except Exception: # noqa: BLE001 - # If hash check fails, run full scan to be safe - return True + for file in changed_files(branch): + if file in CLANG_TIDY_GLOBAL_FILES: + return True + # Root-level sdkconfig.defaults and per-target sdkconfig.defaults. + if "/" not in file and file.startswith(SDKCONFIG_DEFAULTS_PREFIX): + return True + return False def should_run_clang_tidy(branch: str | None = None) -> bool: @@ -307,13 +306,12 @@ def should_run_clang_tidy(branch: str | None = None) -> bool: Clang-tidy will run when ANY of the following conditions are met: - 1. Clang-tidy configuration changed - - The hash of .clang-tidy configuration file has changed - - The hash includes the .clang-tidy file, clang-tidy version from requirements_dev.txt, - and relevant platformio.ini sections - - When configuration changes, a full scan is needed to ensure all code complies - with the new rules - - Detected by script/clang_tidy_hash.py --check returning exit code 0 + 1. A clang-tidy-relevant config file changed (full scan needed) + - Any file in CLANG_TIDY_GLOBAL_FILES (.clang-tidy, platformio.ini, + requirements_dev.txt, esphome/idf_component.yml) or a root-level + sdkconfig.defaults* file + - These affect clang-tidy results globally, so all code must be re-checked + to ensure it still complies 2. Any C++ source files changed - Any file with C++ extensions: .cpp, .h, .hpp, .cc, .cxx, .c, .tcc @@ -321,27 +319,14 @@ def should_run_clang_tidy(branch: str | None = None) -> bool: - This ensures all C++ code is checked, including tests, examples, etc. - Examples: esphome/core/component.cpp, tests/custom/my_component.h - 3. The .clang-tidy.hash file itself changed - - This indicates the configuration has been updated and clang-tidy should run - - Ensures that PRs updating the clang-tidy configuration are properly validated - - If the hash check fails for any reason, clang-tidy runs as a safety measure to ensure - code quality is maintained. - Args: branch: Branch to compare against. If None, uses default. Returns: True if clang-tidy should run, False otherwise. """ - # First check if clang-tidy configuration changed (full scan needed) - if _is_clang_tidy_full_scan(): - return True - - # Check if .clang-tidy.hash file itself was changed - # This handles the case where the hash was properly updated in the PR - files = changed_files(branch) - if ".clang-tidy.hash" in files: + # First check if a clang-tidy-relevant config file changed (full scan needed) + if _is_clang_tidy_full_scan(branch): return True return _any_changed_file_endswith(branch, CPP_FILE_EXTENSIONS) @@ -481,11 +466,11 @@ def should_run_device_builder(branch: str | None = None) -> bool: return False -# Components tested by the native ESP-IDF compile-test job. This is the +# Components tested by the PlatformIO compile-test job. This is the # single source of truth: the workflow reads the comma-joined list from the -# `native-idf-components` output of `determine-jobs` and uses it as the -# `TEST_COMPONENTS` env on the `test-native-idf` job. -NATIVE_IDF_TEST_COMPONENTS = frozenset( +# `esp32-platformio-components` output of `determine-jobs` and uses it as the +# `TEST_COMPONENTS` env on the `test-esp32-platformio` job. +ESP32_PLATFORMIO_TEST_COMPONENTS = frozenset( { "esp32", "api", @@ -505,53 +490,75 @@ NATIVE_IDF_TEST_COMPONENTS = frozenset( } ) -# Path prefixes whose changes always trigger the native ESP-IDF compile -# test: anything under esphome/espidf/ (the native IDF runner / API / -# framework / component generator). -NATIVE_IDF_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) +# Path prefixes whose changes always trigger the PlatformIO compile test: +# anything under esphome/platformio/ (the PlatformIO runner / toolchain that +# drives every PlatformIO build). The esp32 platform component is already in +# ESP32_PLATFORMIO_TEST_COMPONENTS, so its changes are covered by the normal +# component-narrowing path. +ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",) -# Standalone files that, when changed, also trigger the native ESP-IDF -# compile test: -# - esphome/build_gen/espidf.py -- the native IDF build generator -# (other files under build_gen/ target PlatformIO and don't affect -# the native IDF path) +# Standalone files that, when changed, trigger the PlatformIO compile test: +# - esphome/build_gen/platformio.py -- the PlatformIO build generator # - script/test_build_components.py -- the harness the job invokes # - .github/workflows/ci.yml -- the job's own definition -NATIVE_IDF_TRIGGER_FILES = frozenset( +ESP32_PLATFORMIO_TRIGGER_FILES = frozenset( { - "esphome/build_gen/espidf.py", + "esphome/build_gen/platformio.py", "script/test_build_components.py", ".github/workflows/ci.yml", } ) -def _native_idf_path_or_file_trigger(files: list[str]) -> bool: - """Whether any changed file is a native IDF infrastructure / harness trigger.""" +def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool: + """Whether any changed file is a PlatformIO infrastructure / harness trigger.""" for file in files: - if file in NATIVE_IDF_TRIGGER_FILES: + if file in ESP32_PLATFORMIO_TRIGGER_FILES: return True - if any(file.startswith(prefix) for prefix in NATIVE_IDF_TRIGGER_PATH_PREFIXES): + if any( + file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES + ): return True return False -def native_idf_components_to_test(branch: str | None = None) -> list[str]: - """Subset of ``NATIVE_IDF_TEST_COMPONENTS`` the job needs to compile. +# ESP-IDF infra: changes under esphome/espidf/ or to the IDF build generator +# affect every esp32 IDF build (now the default toolchain) but aren't +# components, so the component matrix wouldn't otherwise force any esp32 +# compile. When they change we fold the `esp32` component into the matrix so +# the default native-IDF build path is still compiled on an infra-only PR. +ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",) +ESP_IDF_INFRA_TRIGGER_FILES = frozenset({"esphome/build_gen/espidf.py"}) - The job builds components with the native ESP-IDF toolchain (no - PlatformIO). When only a specific component (or something it depends - on) changed, there's no value in re-building every other unrelated - component in the test list -- the regular ``component-test`` matrix - already covers them via PlatformIO. So we narrow to the intersection - of ``NATIVE_IDF_TEST_COMPONENTS`` and the changed-component dependency + +def _esp_idf_infra_changed(files: list[str]) -> bool: + """Whether any changed file is ESP-IDF build/runner infrastructure.""" + for file in files: + if file in ESP_IDF_INFRA_TRIGGER_FILES: + return True + if any( + file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES + ): + return True + return False + + +def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]: + """Subset of ``ESP32_PLATFORMIO_TEST_COMPONENTS`` the job needs to compile. + + The job builds components with the PlatformIO toolchain. When only a + specific component (or something it depends on) changed, there's no + value in re-building every other unrelated component in the test list -- + the regular ``component-test`` matrix already covers them via the + default toolchain. So we narrow to the intersection of + ``ESP32_PLATFORMIO_TEST_COMPONENTS`` and the changed-component dependency closure. Returns the full list (sorted) when we can't safely narrow: 1. Core C++/Python files changed (``esphome/core/*``). - 2. Native IDF infrastructure changed (``esphome/espidf/*`` or - ``esphome/build_gen/espidf.py``). + 2. PlatformIO infrastructure changed (``esphome/platformio/*`` or + ``esphome/build_gen/platformio.py``). 3. The test harness or workflow itself changed (``script/test_build_components.py``, ``.github/workflows/ci.yml``). @@ -573,31 +580,31 @@ def native_idf_components_to_test(branch: str | None = None) -> list[str]: """ files = changed_files(branch) - if core_changed(files) or _native_idf_path_or_file_trigger(files): - return sorted(NATIVE_IDF_TEST_COMPONENTS) + if core_changed(files) or _esp32_platformio_path_or_file_trigger(files): + return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) component_files = [f for f in files if filter_component_and_test_files(f)] changed = get_components_with_dependencies(component_files, True) - return sorted(NATIVE_IDF_TEST_COMPONENTS & set(changed)) + return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed)) -def should_run_native_idf(branch: str | None = None) -> bool: - """Determine if the `test-native-idf` compile-test job should run. +def should_run_esp32_platformio(branch: str | None = None) -> bool: + """Determine if the `test-esp32-platformio` compile-test job should run. - Runs whenever ``native_idf_components_to_test()`` returns a non-empty + Runs whenever ``esp32_platformio_components_to_test()`` returns a non-empty list. Skipping the job on unrelated Python-only PRs avoids ~5 min of CI per PR (worse on cold caches). The regular ``component-test`` - matrix still exercises the same components through PlatformIO when - those components change. + matrix still exercises the same components through the default + toolchain when those components change. Args: branch: Branch to compare against. If None, uses default. Returns: - True if the native ESP-IDF compile test should run, False otherwise. + True if the PlatformIO compile test should run, False otherwise. """ - return bool(native_idf_components_to_test(branch)) + return bool(esp32_platformio_components_to_test(branch)) def determine_cpp_unit_tests( @@ -1177,8 +1184,8 @@ def main() -> None: run_python_linters = True run_import_time = True run_device_builder = True - native_idf_components = sorted(NATIVE_IDF_TEST_COMPONENTS) - run_native_idf = True + esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS) + run_esp32_platformio = True else: integration_run_all, integration_test_files = determine_integration_tests( args.branch @@ -1188,8 +1195,8 @@ def main() -> None: run_python_linters = should_run_python_linters(args.branch) run_import_time = should_run_import_time(args.branch) run_device_builder = should_run_device_builder(args.branch) - native_idf_components = native_idf_components_to_test(args.branch) - run_native_idf = bool(native_idf_components) + esp32_platformio_components = esp32_platformio_components_to_test(args.branch) + run_esp32_platformio = bool(esp32_platformio_components) run_integration, integration_test_buckets = _compute_integration_test_buckets( integration_run_all, integration_test_files ) @@ -1243,6 +1250,18 @@ def main() -> None: if _component_has_tests(component) ] + # ESP-IDF build-gen/runner changed but no component pulled esp32 in: fold the + # `esp32` component into the matrix so the default native-IDF build path is + # still compiled on an infra-only PR. force_all/core already test everything, + # so skip there. Runs grouped (not added to directly-changed). + if ( + not is_core_change + and _esp_idf_infra_changed(changed) + and "esp32" not in changed_components_with_tests + and _component_has_tests("esp32") + ): + changed_components_with_tests.append("esp32") + # Get directly changed components with tests (for isolated testing) # These will be tested WITHOUT --testing-mode in CI to enable full validation # (pin conflicts, etc.) since they contain the actual changes being reviewed @@ -1276,9 +1295,9 @@ def main() -> None: # Determine clang-tidy mode based on actual files that will be checked is_full_scan = False if run_clang_tidy: - # Full scan needed if: hash changed OR core files changed - # (is_core_change is forced True under --force-all) - is_full_scan = _is_clang_tidy_full_scan() or is_core_change + # Full scan needed if: a clang-tidy-relevant config file changed OR + # core files changed (is_core_change is forced True under --force-all) + is_full_scan = _is_clang_tidy_full_scan(args.branch) or is_core_change if is_full_scan: # Full scan checks all files - always use split mode for efficiency @@ -1360,8 +1379,8 @@ def main() -> None: "python_linters": run_python_linters, "import_time": run_import_time, "device_builder": run_device_builder, - "native_idf": run_native_idf, - "native_idf_components": ",".join(native_idf_components), + "esp32_platformio": run_esp32_platformio, + "esp32_platformio_components": ",".join(esp32_platformio_components), "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/tests/component_tests/conftest.py b/tests/component_tests/conftest.py index 763628f57c..3730978ec3 100644 --- a/tests/component_tests/conftest.py +++ b/tests/component_tests/conftest.py @@ -104,6 +104,44 @@ def set_component_config() -> Callable[[str, Any], None]: return setter +@pytest.fixture +def choose_variant_with_pins() -> Generator[Callable[[list], None]]: + """Set the ESP32 variant to the first one on which all the given pins are valid. + + For ESP32 only, since the other platforms do not have variants. The core + configuration must already have been set up for an ESP32 target. + Using local imports to avoid importing when ESP32 is not the target. + """ + from esphome import config_validation as cv + from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS + from esphome.components.esp32.gpio import validate_gpio_pin + from esphome.const import CONF_INPUT, CONF_OUTPUT + from esphome.pins import gpio_pin_schema + + def chooser(pins: list) -> None: + for variant in VARIANTS: + try: + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + for pin in pins: + if pin is not None: + pin = gpio_pin_schema( + { + CONF_INPUT: True, + CONF_OUTPUT: True, + }, + internal=True, + )(pin) + validate_gpio_pin(pin) + return + except cv.Invalid: + continue + raise cv.Invalid( + f"No compatible variant found for pins: {', '.join(map(str, pins))}" + ) + + yield chooser + + @pytest.fixture def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]: """Return a function to get absolute paths relative to the component's fixtures directory.""" diff --git a/tests/component_tests/epaper_spi/config/enable_pin_test.yaml b/tests/component_tests/epaper_spi/config/enable_pin_test.yaml new file mode 100644 index 0000000000..d238cd1d9e --- /dev/null +++ b/tests/component_tests/epaper_spi/config/enable_pin_test.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + enable_pin: + - GPIO25 + - GPIO26 + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_display_metadata.py b/tests/component_tests/epaper_spi/test_display_metadata.py new file mode 100644 index 0000000000..95afefcf35 --- /dev/null +++ b/tests/component_tests/epaper_spi/test_display_metadata.py @@ -0,0 +1,156 @@ +"""Tests for display metadata created by the epaper_spi component.""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from esphome import config_validation as cv +from esphome.components.display import get_all_display_metadata, get_display_metadata +from esphome.components.epaper_spi.display import CONFIG_SCHEMA +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _base_config(**overrides: Any) -> ConfigType: + """Build a minimal valid ssd1677 config, allowing field overrides.""" + config: ConfigType = { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": {"width": 200, "height": 300}, + } + config.update(overrides) + return config + + +def test_metadata_dimensions_and_defaults( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Metadata picks up explicit dimensions and epaper_spi defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config()) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.width == 200 + assert meta.height == 300 + # epaper_spi always reports full hardware rotation + assert meta.has_hardware_rotation is True + # epaper_spi does not declare a byte order + assert meta.byte_order is cv.UNDEFINED + assert meta.draw_rounding == 0 + # no drawing methods configured -> no writer + assert meta.has_writer is False + + +def test_metadata_default_dimensions_from_model( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A model with built-in dimensions reports those without explicit dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + # waveshare-4.26in is an ssd1677 derivative with default 800x480 dimensions + config = CONFIG_SCHEMA( + { + "id": "wave_display", + "model": "waveshare-4.26in", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + } + ) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.width == 800 + assert meta.height == 480 + + +def test_metadata_has_writer_with_auto_clear( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A display with auto_clear_enabled reports has_writer=True.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config(auto_clear_enabled=True)) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.has_writer is True + + +def test_metadata_rotation_propagated( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The configured rotation is stored in the metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = CONFIG_SCHEMA(_base_config(rotation=90)) + meta = get_display_metadata(config["id"]) + + assert meta is not None + assert meta.rotation == 90 + + +def test_metadata_multiple_displays_independent( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Each display gets its own independent metadata entry.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + CONFIG_SCHEMA(_base_config(id="disp_a", dimensions={"width": 200, "height": 300})) + CONFIG_SCHEMA(_base_config(id="disp_b", dimensions={"width": 400, "height": 480})) + + all_meta = get_all_display_metadata() + assert all_meta["disp_a"].width == 200 + assert all_meta["disp_a"].height == 300 + assert all_meta["disp_b"].width == 400 + assert all_meta["disp_b"].height == 480 + + +def test_metadata_via_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Full code generation registers metadata for the configured display.""" + generate_main(component_config_path("enable_pin_test.yaml")) + + all_meta = get_all_display_metadata() + assert len(all_meta) == 1 + meta = next(iter(all_meta.values())) + # enable_pin_test.yaml: ssd1677 at 200x200 + assert meta.width == 200 + assert meta.height == 200 + assert meta.has_hardware_rotation is True diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index a9f5735fca..c7f34d7dd2 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -1,6 +1,8 @@ """Tests for epaper_spi configuration validation.""" from collections.abc import Callable +from pathlib import Path +import re from typing import Any import pytest @@ -11,17 +13,13 @@ from esphome.components.epaper_spi.display import ( FINAL_VALIDATE_SCHEMA, MODELS, ) -from esphome.components.esp32 import ( - KEY_BOARD, - KEY_VARIANT, - VARIANT_ESP32, - VARIANT_ESP32S3, -) +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, CONF_DC_PIN, CONF_DIMENSIONS, + CONF_ENABLE_PIN, CONF_HEIGHT, CONF_INIT_SEQUENCE, CONF_RESET_PIN, @@ -31,6 +29,30 @@ from esphome.const import ( from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable +# Pin options whose values must be valid on the chosen ESP32 variant. +_PIN_CONF_KEYS = ( + CONF_CS_PIN, + CONF_DC_PIN, + CONF_RESET_PIN, + CONF_BUSY_PIN, + CONF_ENABLE_PIN, +) + + +def _pins_for(model: Any, config: ConfigType) -> list: + """Collect every GPIO the config will actually use (model defaults or injected).""" + pins: list = [] + for key in _PIN_CONF_KEYS: + # An injected value in the config takes precedence over the model default. + value = config[key] if key in config else model.get_default(key) + if not value: # get_default returns False for pins the model omits + continue + if isinstance(value, list): + pins.extend(value) + else: + pins.append(value) + return pins + def run_schema_validation( config: ConfigType, with_final_validate: bool = False @@ -90,29 +112,20 @@ def test_basic_configuration_errors( def test_all_predefined_models( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test all predefined epaper models validate successfully with appropriate defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + # Test all models, providing default values where necessary for name, model in MODELS.items(): - # SEEED models are designed for ESP32-S3 hardware - if name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={ - KEY_BOARD: "esp32-s3-devkitc-1", - KEY_VARIANT: VARIANT_ESP32S3, - }, - ) - else: - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) - - # Configure SPI component which is required by epaper_spi - set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) - config = {"model": name} # Add ID field @@ -141,6 +154,10 @@ def test_all_predefined_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Select an ESP32 variant on which all of this model's pins are valid + # (some models default to high-numbered pins only present on the S3). + choose_variant_with_pins(_pins_for(model, config)) + run_schema_validation(config) @@ -152,27 +169,19 @@ def test_individual_models( model_name: str, set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], + choose_variant_with_pins: Callable[[list], None], ) -> None: """Test each epaper model individually to ensure it validates correctly.""" - # SEEED models are designed for ESP32-S3 hardware - if model_name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={ - KEY_BOARD: "esp32-s3-devkitc-1", - KEY_VARIANT: VARIANT_ESP32S3, - }, - ) - else: - set_core_config( - PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, - ) + model = MODELS[model_name] + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) # Configure SPI component which is required by epaper_spi set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) - model = MODELS[model_name] config: dict[str, Any] = {"model": model_name, "id": "test_display"} # Add required fields based on model defaults @@ -195,6 +204,10 @@ def test_individual_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Select an ESP32 variant on which all of this model's pins are valid + # (some models default to high-numbered pins only present on the S3). + choose_variant_with_pins(_pins_for(model, config)) + # This should not raise any exceptions run_schema_validation(config) @@ -342,3 +355,102 @@ def test_busy_pin_input_mode_ssd1677( reset_pin_config = result[CONF_RESET_PIN] assert "mode" in reset_pin_config assert reset_pin_config["mode"]["output"] is True + + +def test_enable_pin_single( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test that a single enable_pin is accepted and normalised to a list of output pins.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + result = run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "enable_pin": 25, + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + # A single pin is normalised to a list by cv.ensure_list + assert CONF_ENABLE_PIN in result + enable_pins = result[CONF_ENABLE_PIN] + assert isinstance(enable_pins, list) + assert len(enable_pins) == 1 + # enable pins are configured as outputs + assert enable_pins[0]["mode"]["output"] is True + + +def test_enable_pin_multiple( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test that a list of enable_pins is accepted.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + result = run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "enable_pin": [25, 26], + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + assert CONF_ENABLE_PIN in result + enable_pins = result[CONF_ENABLE_PIN] + assert isinstance(enable_pins, list) + assert len(enable_pins) == 2 + assert all(pin["mode"]["output"] is True for pin in enable_pins) + + +def test_enable_pin_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that enable_pins are wired up in the generated C++ code.""" + main_cpp = generate_main(component_config_path("enable_pin_test.yaml")) + + # Derive the auto-generated pin variable names from the set_pin() lines + # rather than hard-coding them, so the test does not break when unrelated + # codegen details shift the generated IDs. + def pin_var_for(gpio_num: int) -> str: + match = re.search(rf"(\w+)->set_pin\(::GPIO_NUM_{gpio_num}\);", main_cpp) + assert match is not None, ( + f"GPIO_NUM_{gpio_num} pin not set up in generated code" + ) + return match.group(1) + + pin_25 = pin_var_for(25) + pin_26 = pin_var_for(26) + + # Both pin objects must be passed to the display via set_enable_pins() as a + # std::vector initializer list, in the configured order. + assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp diff --git a/tests/component_tests/esp32/config/flash_mode_default.yaml b/tests/component_tests/esp32/config/flash_mode_default.yaml new file mode 100644 index 0000000000..0d05142099 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_default.yaml @@ -0,0 +1,7 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/flash_mode_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml new file mode 100644 index 0000000000..d12d4a734b --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + board: esp32dev + flash_mode: qio + flash_frequency: 80MHz + toolchain: platformio + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/network_ethernet_only.yaml b/tests/component_tests/esp32/config/network_ethernet_only.yaml new file mode 100644 index 0000000000..73d11e0a13 --- /dev/null +++ b/tests/component_tests/esp32/config/network_ethernet_only.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz diff --git a/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml b/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml new file mode 100644 index 0000000000..9aff46b7c4 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_ble_coexistence.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +esp32_ble_tracker: + software_coexistence: true diff --git a/tests/component_tests/esp32/config/network_wifi_only.yaml b/tests/component_tests/esp32/config/network_wifi_only.yaml new file mode 100644 index 0000000000..61dfde3e03 --- /dev/null +++ b/tests/component_tests/esp32/config/network_wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index e9fa9446d4..bdba981c44 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2,14 +2,25 @@ Test ESP32 configuration """ +import asyncio from collections.abc import Callable from pathlib import Path from typing import Any import pytest -from esphome.components.esp32 import VARIANT_ESP32, VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANTS, + NetworkSdkconfigData, + _reconcile_network_sdkconfig, +) +from esphome.components.esp32.const import ( + KEY_ESP32, + KEY_NETWORK_SDKCONFIG, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, +) from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv from esphome.const import ( @@ -64,6 +75,38 @@ def test_esp32_config( assert VARIANT_FRIENDLY[variant].lower() in config["board"] +@pytest.mark.parametrize( + ("config_toolchain", "expected"), + [ + # No `toolchain:` set -> the new default for esp32. + (None, Toolchain.ESP_IDF), + # An explicit `toolchain:` still wins over the default. + (Toolchain.PLATFORMIO.value, Toolchain.PLATFORMIO), + (Toolchain.ESP_IDF.value, Toolchain.ESP_IDF), + ], +) +def test_esp32_default_toolchain_is_esp_idf( + set_core_config: SetCoreConfigCallable, + config_toolchain: str | None, + expected: Toolchain, +) -> None: + """With no `toolchain:` set (and nothing pinned via the CLI), esp32 resolves + to the ESP-IDF toolchain; an explicit `toolchain:` still wins.""" + set_core_config(PlatformFramework.ESP32_IDF) + + from esphome.components.esp32 import CONFIG_SCHEMA + + # Fresh run: no --toolchain CLI and no prior config pinned CORE.toolchain. + CORE.toolchain = None + config: dict[str, Any] = {"variant": VARIANT_ESP32} + if config_toolchain is not None: + config["toolchain"] = config_toolchain + + CONFIG_SCHEMA(config) + + assert CORE.toolchain == expected + + @pytest.mark.parametrize( ("config", "error_match"), [ @@ -285,3 +328,209 @@ def test_native_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_flash_mode_sets_sdkconfig_and_pio_option( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """flash_mode/flash_frequency select the esptool flash parameters on both backends.""" + generate_main(component_config_path("flash_mode_idf.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert CORE.platformio_options.get("board_build.flash_mode") == "qio" + assert CORE.platformio_options.get("board_build.f_flash") == "80000000L" + + +def test_flash_mode_unset_leaves_defaults( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without flash_mode the board/sdkconfig defaults stay untouched.""" + generate_main(component_config_path("flash_mode_default.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig) + assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig) + assert "board_build.flash_mode" not in CORE.platformio_options + assert "board_build.f_flash" not in CORE.platformio_options + + +@pytest.mark.parametrize( + ("framework", "net", "preset", "expected"), + [ + # --- IDF: single-interface cases (must match pre-refactor behavior) --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True), + {}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_no_ap", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True, wifi_ap=True), + {}, + {}, + id="idf_wifi_ap_leaves_softap_dhcps", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="idf_ethernet_only", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData( + wifi=True, bluetooth=True, ble_42=True, software_coexistence=True + ), + {}, + { + "CONFIG_BT_ENABLED": True, + "CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True, + "CONFIG_SW_COEXIST_ENABLE": True, + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_ble_tracker_coexistence", + ), + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(bluetooth=True), + {}, + {"CONFIG_BT_ENABLED": True}, + id="idf_ble_server_only_no_ble42", + ), + # --- IDF: user sdkconfig_options always win --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True), + {"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_user_override_wins", + ), + # --- IDF: user advanced enable_lwip_dhcp_server: false, even with AP --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData( + wifi=True, wifi_ap=True, enable_lwip_dhcp_server=False + ), + {}, + {"CONFIG_LWIP_DHCPS": False}, + id="idf_user_disables_dhcps_with_ap", + ), + # --- IDF: WiFi + Ethernet coexist (the multi-interface unlock) --- + pytest.param( + PlatformFramework.ESP32_IDF, + NetworkSdkconfigData(wifi=True, ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False, + "CONFIG_LWIP_DHCPS": False, + }, + id="idf_wifi_and_ethernet_keeps_wifi_enabled", + ), + # --- Arduino: SoftAP/DHCPS disable is IDF-only --- + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(wifi=True), + {}, + {}, + id="arduino_wifi_no_ap_untouched", + ), + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(ethernet=True), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="arduino_ethernet_only_disables_wifi", + ), + # --- Arduino + Ethernet: DHCPS stays available even if user disabled it --- + pytest.param( + PlatformFramework.ESP32_ARDUINO, + NetworkSdkconfigData(ethernet=True, enable_lwip_dhcp_server=False), + {}, + { + "CONFIG_ESP_WIFI_ENABLED": False, + "CONFIG_SW_COEXIST_ENABLE": False, + }, + id="arduino_ethernet_dhcps_exclusion", + ), + ], +) +def test_reconcile_network_sdkconfig( + set_core_config: SetCoreConfigCallable, + framework: PlatformFramework, + net: NetworkSdkconfigData, + preset: dict[str, Any], + expected: dict[str, Any], +) -> None: + """The FINAL-priority reconciler resolves WiFi/Ethernet/Bluetooth/coexistence + sdkconfig flags from the requests recorded in NetworkSdkconfigData.""" + set_core_config(framework) + CORE.data[KEY_ESP32] = { + KEY_SDKCONFIG_OPTIONS: dict(preset), + KEY_NETWORK_SDKCONFIG: net, + } + + asyncio.run(_reconcile_network_sdkconfig()) + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected + + +def test_network_wifi_only_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: codegen for an ESP-IDF WiFi (no AP) config runs the reconciler + after wifi's request_wifi(), disabling SoftAP support and the DHCP server.""" + generate_main(component_config_path("network_wifi_only.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + # WiFi stack stays enabled (no ethernet) and no Bluetooth requested. + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig + assert "CONFIG_BT_ENABLED" not in sdkconfig + + +def test_network_ethernet_only_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: ethernet's request_ethernet() makes the reconciler disable the + WiFi stack and coexistence when WiFi is absent.""" + generate_main(component_config_path("network_ethernet_only.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False + + +def test_network_wifi_ble_coexistence_reconciles_end_to_end( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """End-to-end: WiFi + esp32_ble_tracker software_coexistence resolves to + BT enabled and coexistence on, with SoftAP/DHCP server dropped (no AP).""" + generate_main(component_config_path("network_wifi_ble_coexistence.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_BT_ENABLED") is True + assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True + assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False + assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False + # WiFi present alongside BT -> WiFi stack must stay enabled. + assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig diff --git a/tests/component_tests/mipi_spi/conftest.py b/tests/component_tests/mipi_spi/conftest.py index 082a9e55f2..ed48056f63 100644 --- a/tests/component_tests/mipi_spi/conftest.py +++ b/tests/component_tests/mipi_spi/conftest.py @@ -1,16 +1,10 @@ """Tests for mpip_spi configuration validation.""" -from collections.abc import Callable, Generator from unittest import mock import pytest -from esphome import config_validation as cv -from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS -from esphome.components.esp32.gpio import validate_gpio_pin -from esphome.const import CONF_INPUT, CONF_OUTPUT -from esphome.core import CORE -from esphome.pins import gpio_pin_schema +# choose_variant_with_pins is provided by the shared parent conftest. @pytest.fixture(autouse=True) @@ -21,34 +15,3 @@ def mock_spi_final_validate(): return_value=lambda config: None, ): yield - - -@pytest.fixture -def choose_variant_with_pins() -> Generator[Callable[[list], None]]: - """ - Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms - do not have variants. - """ - - def chooser(pins: list) -> None: - for variant in VARIANTS: - try: - CORE.data[KEY_ESP32][KEY_VARIANT] = variant - for pin in pins: - if pin is not None: - pin = gpio_pin_schema( - { - CONF_INPUT: True, - CONF_OUTPUT: True, - }, - internal=True, - )(pin) - validate_gpio_pin(pin) - return - except cv.Invalid: - continue - raise cv.Invalid( - f"No compatible variant found for pins: {', '.join(map(str, pins))}" - ) - - yield chooser diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 4873892a8d..d681908027 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -314,7 +314,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp @@ -330,7 +330,7 @@ def test_lvgl_generation( main_cpp = generate_main(component_fixture_path("lvgl.yaml")) assert ( - "mipi_spi::MipiSpi();" + "mipi_spi::MipiSpi();" in main_cpp ) assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py new file mode 100644 index 0000000000..82adf88b7e --- /dev/null +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -0,0 +1,434 @@ +"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.components.mipi_spi.display import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + MODELS, + get_instance, +) +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config: ConfigType) -> ConfigType: + """Run schema + final validation and return the validated config.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +class TestSPIModeCalculation: + """Test default SPI mode calculation logic.""" + + @pytest.mark.parametrize( + ("bus_mode", "cs_pin", "expected_mode"), + [ + pytest.param( + TYPE_OCTAL, + None, + "MODE3", + id="octal_bus_no_cs", + ), + pytest.param( + TYPE_OCTAL, + 14, + "MODE3", + id="octal_bus_with_cs", + ), + pytest.param( + TYPE_SINGLE, + None, + "MODE3", + id="single_bus_no_cs", + ), + pytest.param( + TYPE_SINGLE, + 14, + "MODE0", + id="single_bus_with_cs", + ), + pytest.param( + TYPE_QUAD, + None, + "MODE0", + id="quad_bus_no_cs", + ), + pytest.param( + TYPE_QUAD, + 14, + "MODE0", + id="quad_bus_with_cs", + ), + ], + ) + def test_default_spi_mode_calculation( + self, + bus_mode: str, + cs_pin: int | None, + expected_mode: str, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that SPI mode is correctly calculated based on bus mode and CS pin.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config: ConfigType = { + "model": "custom", + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": bus_mode, + } + + # Add dc_pin for modes that require it (single and octal) + # quad mode does not allow dc_pin + if bus_mode != TYPE_QUAD: + config[CONF_DC_PIN] = 11 + + # Add CS pin if specified + if cs_pin is not None: + config[CONF_CS_PIN] = cs_pin + + validated = validated_config(config) + # The validated config should have the correct SPI mode set by model_schema + assert validated.get(CONF_SPI_MODE) == expected_mode + + def test_explicit_spi_mode_overrides_default( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that an explicitly configured SPI mode is not overridden.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # For octal bus, default is MODE3, but we specify MODE0 + config = validated_config( + { + "model": "custom", + "dc_pin": 11, # Required for octal mode + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "bus_mode": TYPE_OCTAL, + "spi_mode": "MODE0", # Explicitly set + } + ) + + assert config[CONF_SPI_MODE] == "MODE0" + + +class TestModelWithPaddingDimensions: + """Test that padding dimensions are correctly returned by models.""" + + def test_model_get_dimensions_returns_six_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that get_dimensions() returns 6 values including padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # Test with a real model + model = MODELS["ST7735"] + config = {"model": "ST7735", "dc_pin": 18} + + # Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height) + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6 + assert all(isinstance(v, int) for v in dimensions) + + def test_custom_model_padding_values( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding values for a custom model with explicit offset.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 20, + "offset_height": 10, + }, + "init_sequence": [[0xA0, 0x01]], + } + ) + + # For custom models, the model is created dynamically from the config + # We can verify the config has the right dimensions + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 320 + assert config["dimensions"]["offset_width"] == 20 + assert config["dimensions"]["offset_height"] == 10 + # Padding is not stored in config for custom models (defaults to 0) + assert config["dimensions"].get("offset_width_pad", 0) == 0 + assert config["dimensions"].get("offset_height_pad", 0) == 0 + + +class TestNewModelVariants: + """Test new model variants added in this change.""" + + def test_m5core2_with_native_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test M5CORE2 variant with reset native_width and native_height.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + # M5CORE2 should validate successfully + config = validated_config({"model": "M5CORE2"}) + assert config is not None + + # Verify the model has correct dimensions + model = MODELS["M5CORE2"] + dimensions = model.get_dimensions(config) + width, height, _, _, _, _ = dimensions + assert width == 320 + assert height == 240 + + def test_geekmagic_smalltv_variant( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test GEEKMAGIC-SMALLTV variant of ST7789V.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # GEEKMAGIC-SMALLTV should validate successfully + config = validated_config({"model": "GEEKMAGIC-SMALLTV"}) + assert config is not None + + # Verify it's a variant of ST7789V with expected dimensions + model = MODELS["GEEKMAGIC-SMALLTV"] + dimensions = model.get_dimensions(config) + width, height, offset_x, offset_y, _, _ = dimensions + assert width == 240 + assert height == 240 + assert offset_x == 0 + assert offset_y == 0 + + def test_all_predefined_models_with_new_get_dimensions_signature( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Verify all predefined models work with new 6-value get_dimensions().""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + for name, model in MODELS.items(): + # Skip custom model + if name == "custom": + continue + + config = {"model": name} + + # Try to get dimensions - should return 6 values for all models + dimensions = model.get_dimensions(config) + assert len(dimensions) == 6, ( + f"Model {name} should return 6 dimensions, got {len(dimensions)}" + ) + + +class TestTemplateParameterPassing: + """Test that padding parameters are correctly passed to C++ templates.""" + + def test_instance_creation_with_padding( + self, + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], + ) -> None: + """Test that get_instance() correctly passes padding parameters to template.""" + main_cpp = generate_main(component_fixture_path("native.yaml")) + + # native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer + # (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION, + # FRACTION, ROUNDING) + # The instantiation should include padding values (0, 0 for default) + assert ( + "mipi_spi::MipiSpiBuffer()" + in main_cpp + ), ( + "Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation" + ) + + def test_single_mode_with_offset_padding( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that single-mode display with custom offset works with padding.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Should not raise any errors + instance = get_instance(config) + assert instance is not None + + +class TestUserConfiguredPadding: + """Test that pad_width and pad_height can be configured in user dimensions.""" + + def test_explicit_pad_width_and_height_in_dimensions( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that pad_width and pad_height can be explicitly set in dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 320, + "offset_width": 40, + "offset_height": 20, + "pad_width": 80, + "pad_height": 40, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Config should validate successfully with padding dimensions + assert config is not None + assert config["dimensions"]["pad_width"] == 80 + assert config["dimensions"]["pad_height"] == 40 + + def test_padding_for_native_dimension_calculation( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test that explicit padding allows native dimensions to be calculated.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A controller that has 320x320 total pixels with: + # - 240x320 active display area + # - offset_width=40, offset_height=20 + # - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, # Active display width + "height": 320, # Active display height + "offset_width": 40, + "offset_height": 0, + "pad_width": 40, # Pixels after width+offset + "pad_height": 0, # Pixels after height+offset + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + # Get instance should work and correctly calculate native dimensions + instance = get_instance(config) + assert instance is not None + + def test_padding_without_offset( + self, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Test padding can be used without offset for controllers with top-left-aligned displays.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # A display with no offset but padding on right and bottom + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": { + "width": 240, + "height": 240, + "offset_width": 0, + "offset_height": 0, + "pad_width": 0, + "pad_height": 16, + }, + "init_sequence": [[0xA0, 0x01]], + "buffer_size": 0.25, + } + ) + + assert config is not None + assert config["dimensions"]["width"] == 240 + assert config["dimensions"]["height"] == 240 + assert config["dimensions"]["pad_height"] == 16 diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 0924e66adc..ea4adc69a9 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants( FINAL_VALIDATE_SCHEMA(config) +def test_psram_applies_single_mode_default( + set_core_config: SetCoreConfigCallable, +) -> None: + """On a single-mode variant the omitted mode/speed fall back to defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + config = CONFIG_SCHEMA({}) + assert config["mode"] == "quad" + assert config["speed"] == "40MHZ" + assert config["disabled"] is False + assert config["ignore_not_found"] is True + + +def test_psram_requires_mode_on_multi_mode_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A variant with multiple modes requires an explicit mode selection.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"): + CONFIG_SCHEMA({}) + + +def test_psram_rejects_mode_invalid_for_variant( + set_core_config: SetCoreConfigCallable, +) -> None: + """A mode not supported by the active variant is rejected by the schema.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32}, + full_config={CONF_ESPHOME: {}}, + ) + from esphome.components.psram import CONFIG_SCHEMA + + with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"): + CONFIG_SCHEMA({"mode": "octal"}) + + def _setup_psram_final_validation_test( esp32_config: dict, set_core_config: SetCoreConfigCallable, diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index e1b4fb2aa6..cd260f410a 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -4,6 +4,181 @@ namespace esphome::modbus::helpers { +using FC = ModbusFunctionCode; + +// --- server_frame_length --------------------------------------------------- +// Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from +// tests/integration/fixtures/uart_mock_modbus.yaml. + +TEST(ModbusServerFrameLength, TooShortReturnsMinimum) { + const uint8_t frame[] = {0x01}; + EXPECT_EQ(server_frame_length(frame, 1), MIN_FRAME_SIZE); +} + +TEST(ModbusServerFrameLength, ReadHoldingUsesByteCount) { + // inject_rx for basic_register: 2 data bytes -> 5 + 2 = 7 + const uint8_t frame[] = {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 7); +} + +TEST(ModbusServerFrameLength, ReadByteCountCappedAtMax) { + const uint8_t frame[] = {0x01, 0x03, 0xFF}; // claim 255 bytes + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5 + MAX_NUM_OF_REGISTERS_TO_READ * 2); +} + +TEST(ModbusServerFrameLength, ReadMissingByteCountReturnsHeaderOnly) { + const uint8_t frame[] = {0x01, 0x03}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5); +} + +TEST(ModbusServerFrameLength, ExceptionResponse) { + // exception_response fixture: function code 0x83 has the exception bit set + const uint8_t frame[] = {0x01, 0x83, 0x02, 0xC0, 0xF1}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5); +} + +TEST(ModbusServerFrameLength, WriteResponsesAreFixed) { + for (FC fc : + {FC::WRITE_SINGLE_COIL, FC::WRITE_SINGLE_REGISTER, FC::WRITE_MULTIPLE_COILS, FC::WRITE_MULTIPLE_REGISTERS}) { + const uint8_t frame[] = {0x01, static_cast(fc)}; + EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast(fc); + } +} + +TEST(ModbusServerFrameLength, MiscFixedAndUnknown) { + const uint8_t mask[] = {0x01, static_cast(FC::MASK_WRITE_REGISTER)}; + const uint8_t fifo[] = {0x01, static_cast(FC::READ_FIFO_QUEUE)}; + const uint8_t unknown[] = {0x01, 0x42}; + EXPECT_EQ(server_frame_length(mask, sizeof(mask)), 10); + EXPECT_EQ(server_frame_length(fifo, sizeof(fifo)), 6); + EXPECT_EQ(server_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE); +} + +// --- client_frame_length --------------------------------------------------- + +TEST(ModbusClientFrameLength, TooShortReturnsMinimum) { + const uint8_t frame[] = {0x01}; + EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE); +} + +TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) { + // basic_register request fixture is a read-holding request -> 8 bytes + const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A}; + EXPECT_EQ(client_frame_length(read, sizeof(read)), 8); + for (FC fc : {FC::READ_COILS, FC::READ_DISCRETE_INPUTS, FC::READ_INPUT_REGISTERS, FC::WRITE_SINGLE_COIL, + FC::WRITE_SINGLE_REGISTER}) { + const uint8_t frame[] = {0x01, static_cast(fc)}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast(fc); + } +} + +TEST(ModbusClientFrameLength, WriteMultipleUsesByteCount) { + // write 2 registers (4 data bytes): addr(2)+qty(2)+count(1) then data; count is frame[6] + const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + 4); +} + +TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) { + const uint8_t frame[] = {0x01, 0x0F, 0x00, 0x00, 0x00, 0x02, 0xFF}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2); +} + +TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) { + const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02}; + EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9); +} + +TEST(ModbusClientFrameLength, MiscFixedAndUnknown) { + const uint8_t mask[] = {0x01, static_cast(FC::MASK_WRITE_REGISTER)}; + const uint8_t fifo[] = {0x01, static_cast(FC::READ_FIFO_QUEUE)}; + const uint8_t unknown[] = {0x01, 0x42}; + EXPECT_EQ(client_frame_length(mask, sizeof(mask)), 10); + EXPECT_EQ(client_frame_length(fifo, sizeof(fifo)), 6); + EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE); +} + +// --- create_client_pdu ----------------------------------------------------- +// PDU = function code + data (no address, no CRC). + +TEST(ModbusCreateClientPdu, ReadHolding) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0003, 1); + const std::vector expected{0x03, 0x00, 0x03, 0x00, 0x01}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteSingleOmitsQuantity) { + const uint8_t values[] = {0x00, 0x0B}; + auto pdu = create_client_pdu(FC::WRITE_SINGLE_REGISTER, 0x0003, 1, values, sizeof(values)); + const std::vector expected{0x06, 0x00, 0x03, 0x00, 0x0B}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteSingleTooFewValuesReturnsEmpty) { + const uint8_t values[] = {0x00}; + auto pdu = create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, values, sizeof(values)); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteMultipleIncludesByteCount) { + const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16}; + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, sizeof(values)); + const std::vector expected{0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, WriteMultipleOverCapacityReturnsEmpty) { + std::vector values(MAX_PDU_SIZE - 6 + 1, 0xAA); + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, values.data(), values.size()); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, UnsupportedFunctionCodeReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_FIFO_QUEUE, 0x0000, 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ZeroEntitiesReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, 0); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteWithoutValuesReturnsEmpty) { + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, nullptr, 0); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ReadHoldingOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +// Regression: coils allow up to 2000 entities, well above the 125 register limit. +// A switch fall-through previously subjected coil/discrete reads to the register limit. +TEST(ModbusCreateClientPdu, ReadCoilsAboveRegisterLimitIsValid) { + const uint16_t quantity = MAX_NUM_OF_REGISTERS_TO_READ + 1; // 126: valid for coils, too many for registers + auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, quantity); + const std::vector expected{0x01, 0x00, 0x00, static_cast(quantity >> 8), + static_cast(quantity & 0xFF)}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); +} + +TEST(ModbusCreateClientPdu, ReadCoilsOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, MAX_NUM_OF_COILS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, ReadDiscreteInputsOverMaxReturnsEmpty) { + auto pdu = create_client_pdu(FC::READ_DISCRETE_INPUTS, 0x0000, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ + 1); + EXPECT_TRUE(pdu.empty()); +} + +TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) { + const uint8_t values[] = {0x00, 0x0B}; + auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_WRITE + 1, values, + sizeof(values)); + EXPECT_TRUE(pdu.empty()); +} + TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) { const std::vector data{0x12, 0x34}; EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0); diff --git a/tests/components/modbus/modbus_test.cpp b/tests/components/modbus/modbus_test.cpp deleted file mode 100644 index afe5ced082..0000000000 --- a/tests/components/modbus/modbus_test.cpp +++ /dev/null @@ -1,59 +0,0 @@ -#include -#include "esphome/components/modbus/modbus.h" -#include "esphome/core/helpers.h" - -namespace esphome::modbus { - -// Exposes protected methods for testing. -class TestModbus : public Modbus { - public: - bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); } - void test_clear_rx_buffer() { this->rx_buffer_.clear(); } - void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; } -}; - -class MockDevice : public ModbusDevice { - public: - void on_modbus_data(const std::vector &data) override { this->data_received = true; } - bool data_received{false}; -}; - -TEST(ModbusTest, TwoByteRegressionTest) { - TestModbus modbus; - modbus.set_role(ModbusRole::CLIENT); - // First byte (at=0) - EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01)); - // Second byte (at=1) - // This used to reach raw[2] because it skipped the if(at==2) check, causing a - // buffer overflow. - EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03)); -} - -TEST(ModbusTest, TestValidFrame) { - TestModbus modbus; - modbus.set_role(ModbusRole::CLIENT); - - MockDevice device; - device.set_parent(&modbus); - device.set_address(0x01); - modbus.register_device(&device); - modbus.set_waiting(0x01); - - // Address 1, Function 3, Length 2, Data 0x1234 - uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34}; - uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data)); - - std::vector frame; - for (uint8_t b : frame_data) - frame.push_back(b); - frame.push_back(crc & 0xFF); - frame.push_back((crc >> 8) & 0xFF); - - for (size_t i = 0; i < frame.size(); i++) { - bool result = modbus.test_parse_modbus_byte(frame[i]); - EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")"; - } - EXPECT_TRUE(device.data_received); -} - -} // namespace esphome::modbus diff --git a/tests/components/openthread/test.nrf52-adafruit.yaml b/tests/components/openthread/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..ac2fe63739 --- /dev/null +++ b/tests/components/openthread/test.nrf52-adafruit.yaml @@ -0,0 +1,5 @@ +network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/psram/validate-quad.esp32-s3-idf.yaml b/tests/components/psram/validate-quad.esp32-s3-idf.yaml new file mode 100644 index 0000000000..3fa6360d14 --- /dev/null +++ b/tests/components/psram/validate-quad.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses +# octal; this exercises the other branch of the per-variant mode enum (quad) and +# lets speed fall back to its 40MHz default. +psram: + mode: quad diff --git a/tests/components/psram/validate.esp32-idf.yaml b/tests/components/psram/validate.esp32-idf.yaml new file mode 100644 index 0000000000..9c04284163 --- /dev/null +++ b/tests/components/psram/validate.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: with no options the single-mode ESP32 resolves mode -> quad and +# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here, +# so this only runs through `esphome config`. +psram: diff --git a/tests/components/psram/validate.esp32-p4-idf.yaml b/tests/components/psram/validate.esp32-p4-idf.yaml new file mode 100644 index 0000000000..3e5899061f --- /dev/null +++ b/tests/components/psram/validate.esp32-p4-idf.yaml @@ -0,0 +1,4 @@ +# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz). +# With no options it resolves mode -> hex and speed -> 20MHz, exercising the +# P4-specific default branch of the per-variant enums. +psram: diff --git a/tests/components/resampler/common.yaml b/tests/components/resampler/common.yaml index 782dc831c4..65dd5590ee 100644 --- a/tests/components/resampler/common.yaml +++ b/tests/components/resampler/common.yaml @@ -7,3 +7,8 @@ speaker: - platform: resampler id: resampler_speaker_id output_speaker: resampler_i2s_speaker_id + bits_per_sample: 16 + - platform: resampler + id: resampler_speaker_2_id + output_speaker: resampler_speaker_id + bits_per_sample: passthrough diff --git a/tests/components/ufm01/common.yaml b/tests/components/ufm01/common.yaml new file mode 100644 index 0000000000..c818dc2965 --- /dev/null +++ b/tests/components/ufm01/common.yaml @@ -0,0 +1,30 @@ +ufm01: + id: ufm01_component + uart_id: uart_bus + +sensor: + - platform: ufm01 + accumulated_flow: + id: accumulated_flow + name: "Accumulated flow" + flow: + id: flow + name: "Flow" + temperature: + id: temperature + name: "Temperature" + +binary_sensor: + - platform: ufm01 + ufc_chip_error: + id: ufc_chip_error + name: "UFC chip error" + flow_direction_wrong: + id: flow_direction_wrong + name: "Flow direction wrong" + empty_tube: + id: empty_tube + name: "Empty tube" + flow_rate_out_of_range: + id: flow_rate_out_of_range + name: "Flow rate out of range" diff --git a/tests/components/ufm01/test.esp32-idf.yaml b/tests/components/ufm01/test.esp32-idf.yaml new file mode 100644 index 0000000000..34041cc223 --- /dev/null +++ b/tests/components/ufm01/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/ufm01/test.esp8266-ard.yaml b/tests/components/ufm01/test.esp8266-ard.yaml new file mode 100644 index 0000000000..195f4b41b5 --- /dev/null +++ b/tests/components/ufm01/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/ufm01/test.rp2040-ard.yaml b/tests/components/ufm01/test.rp2040-ard.yaml new file mode 100644 index 0000000000..13b3284fe3 --- /dev/null +++ b/tests/components/ufm01/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_2400_even: !include ../../test_build_components/common/uart_2400_even/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index b2b2233ef3..d000c61170 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -1,15 +1,19 @@ psram: -# Tests the high performance request and release; requires the USE_WIFI_RUNTIME_POWER_SAVE define +# Tests the high performance and roaming suppression request/release APIs; +# requires the USE_WIFI_RUNTIME_POWER_SAVE and USE_WIFI_RUNTIME_ROAMING_SUPPRESSION defines esphome: platformio_options: build_flags: - "-DUSE_WIFI_RUNTIME_POWER_SAVE" + - "-DUSE_WIFI_RUNTIME_ROAMING_SUPPRESSION" on_boot: - then: - lambda: |- esphome::wifi::global_wifi_component->request_high_performance(); esphome::wifi::global_wifi_component->release_high_performance(); + esphome::wifi::global_wifi_component->request_roaming_suppression(); + esphome::wifi::global_wifi_component->release_roaming_suppression(); wifi: use_psram: true diff --git a/tests/integration/fixtures/logger_buffered_recursion_guard.yaml b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml new file mode 100644 index 0000000000..058adbff99 --- /dev/null +++ b/tests/integration/fixtures/logger_buffered_recursion_guard.yaml @@ -0,0 +1,61 @@ +esphome: + name: logger-recursion-test +host: +api: +logger: + level: DEBUG + on_message: + # Fires on the main loop for every message delivered to listeners, including + # messages drained from the task log buffer (i.e. logged from a non-main thread). + # The lambda logs again on the main task. Without a recursion guard on the buffered + # drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the + # buffered message that is still being delivered, corrupting its console output. + - level: VERY_VERBOSE + then: + - lambda: |- + ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER"); + +button: + - platform: template + name: "Start Race Test" + id: start_test_button + on_press: + - lambda: |- + // Keep the count well under the host task-log-buffer slot count so every + // message goes through the ring buffer (buffered drain path) instead of the + // emergency console fallback. The main loop is blocked in pthread_join while + // the thread logs, so all messages are drained together once it returns. + static const int NUM_MESSAGES = 30; + + struct ThreadTest { + static void *thread_func(void *arg) { + char thread_name[16]; + snprintf(thread_name, sizeof(thread_name), "LogThread"); + #ifdef __APPLE__ + pthread_setname_np(thread_name); + #else + pthread_setname_np(pthread_self(), thread_name); + #endif + + for (int i = 0; i < NUM_MESSAGES; i++) { + // Verifiable payload: data is a deterministic function of the message + // index, so a clobbered buffer shows up as a missing or mismatched line. + ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345); + } + return nullptr; + } + }; + + // RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the + // synchronous path, which already holds the recursion guard) so the test can + // always detect completion even when the buffered path is corrupted. + ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES); + + pthread_t thread; + if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) { + ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread"); + return; + } + pthread_join(thread, nullptr); + + ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES); diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml index 634d7157b2..01e4394559 100644 --- a/tests/integration/fixtures/multi_device_preferences.yaml +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -109,7 +109,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Device A Mode set to %s", x.c_str()); - id(mode_device_a).state = x; + id(mode_device_a).publish_state(x); - platform: template name: Mode @@ -124,7 +124,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Device B Mode set to %s", x.c_str()); - id(mode_device_b).state = x; + id(mode_device_b).publish_state(x); - platform: template name: Mode @@ -138,7 +138,7 @@ select: set_action: - lambda: |- ESP_LOGI("test", "Main Mode set to %s", x.c_str()); - id(mode_main).state = x; + id(mode_main).publish_state(x); # Button to trigger preference logging test button: @@ -153,9 +153,9 @@ button: ESP_LOGI("test", "Device A Setpoint: %.1f", id(setpoint_device_a).state); ESP_LOGI("test", "Device B Setpoint: %.1f", id(setpoint_device_b).state); ESP_LOGI("test", "Main Setpoint: %.1f", id(setpoint_main).state); - ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).state.c_str()); - ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).state.c_str()); - ESP_LOGI("test", "Main Mode: %s", id(mode_main).state.c_str()); + ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str()); + ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str()); + ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str()); // Log preference hashes for entities that actually store preferences ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); diff --git a/tests/integration/fixtures/scheduler_blocking_warning.yaml b/tests/integration/fixtures/scheduler_blocking_warning.yaml new file mode 100644 index 0000000000..594ec46afb --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning.yaml @@ -0,0 +1,22 @@ +esphome: + name: scheduler-blocking-warning + on_boot: + then: + - script.execute: blocking_script + +host: +api: +logger: + level: DEBUG + +# The busy-block runs in the second delay's continuation; the warning must name the script. Two +# delays verify the source survives chained delays (the scheduler republishes it each continuation). +script: + - id: blocking_script + then: + - delay: 10ms + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml new file mode 100644 index 0000000000..2d8a62f25b --- /dev/null +++ b/tests/integration/fixtures/scheduler_blocking_warning_generic_source.yaml @@ -0,0 +1,30 @@ +esphome: + name: scheduler-blocking-generic + +host: +api: +logger: + level: DEBUG + +globals: + - id: done + type: bool + restore_value: false + initial_value: "false" + +# A delay in a plain (non-script) automation has no owning script, so the block must log the +# generic "a scheduled task" label, not a script name. +interval: + - interval: 100ms + id: gen_interval + then: + - if: + condition: + lambda: "return !id(done);" + then: + - lambda: "id(done) = true;" + - delay: 10ms + - lambda: |- + const uint32_t start = millis(); + while (millis() - start < 80) { + } diff --git a/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml new file mode 100644 index 0000000000..860fa00c37 --- /dev/null +++ b/tests/integration/fixtures/scheduler_delay_runs_on_failed_component.yaml @@ -0,0 +1,29 @@ +esphome: + name: scheduler-delay-failed + +host: +api: +logger: + level: DEBUG + +globals: + - id: started + type: bool + restore_value: false + initial_value: "false" + +# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed +# component must not drop it, since the SELF_POINTER scheduler item has no owning component. +interval: + - interval: 100ms + id: host_interval + then: + - if: + condition: + lambda: "return !id(started);" + then: + - lambda: |- + id(started) = true; + id(host_interval)->mark_failed(); + - delay: 200ms + - logger.log: "DELAY_FIRED_AFTER_FAIL" diff --git a/tests/integration/fixtures/socket_wake_gate_tcp.yaml b/tests/integration/fixtures/socket_wake_gate_tcp.yaml new file mode 100644 index 0000000000..4dbf89cbf0 --- /dev/null +++ b/tests/integration/fixtures/socket_wake_gate_tcp.yaml @@ -0,0 +1,27 @@ +esphome: + name: socket-wake-gate-tcp + on_boot: + priority: -100 + then: + - lambda: |- + // Raise loop_interval_ to 2000ms. Without wake_request_set() being + // called when select() returns due to socket data, the component + // phase would be gated for up to 2000ms after a TCP request arrives. + App.set_loop_interval(2000); + # Let boot transients and API handshake settle. + - delay: 500ms + - lambda: |- + ESP_LOGI("test", "BOOT_DONE"); + +host: + +api: + actions: + - action: ping + then: + - logger.log: + format: "PONG" + level: INFO + +logger: + level: INFO diff --git a/tests/integration/fixtures/uart_mock_modbus.yaml b/tests/integration/fixtures/uart_mock_modbus.yaml index da36da4de1..7e2bcff3ef 100644 --- a/tests/integration/fixtures/uart_mock_modbus.yaml +++ b/tests/integration/fixtures/uart_mock_modbus.yaml @@ -49,15 +49,16 @@ modbus_controller: - address: 1 id: modbus_controller_ok max_cmd_retries: 2 - update_interval: 1s + # Update interval is set to never to prevent automatic polling: the test will trigger requests by pressing the "Start Scenario" button + update_interval: never - address: 2 id: modbus_controller_slow max_cmd_retries: 0 - update_interval: 1s + update_interval: never - address: 3 id: modbus_controller_offline max_cmd_retries: 0 - update_interval: 1s + update_interval: never sensor: - platform: modbus_controller @@ -91,4 +92,11 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(modbus_controller_ok).set_update_interval(1000); + id(modbus_controller_ok).start_poller(); + id(modbus_controller_slow).set_update_interval(1000); + id(modbus_controller_slow).start_poller(); + id(modbus_controller_offline).set_update_interval(1000); + id(modbus_controller_offline).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml index 9bc4dc50e9..5a7c9b74dc 100644 --- a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml @@ -54,7 +54,11 @@ modbus: sensor: - platform: sdm_meter address: 2 - update_interval: 1s + id: sdm_meter_1 + # update_interval is set to never to avoid automatic polling before the test starts the scenario. + # The test will manually start the poller after subscribing to states, to ensure no state changes are missed. + # This also allows us to assert there are no modbus errors/warnings during the initial request/response. + update_interval: never phase_a: voltage: name: sdm_voltage @@ -64,4 +68,7 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(sdm_meter_1).set_update_interval(1000); + id(sdm_meter_1).start_poller(); diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml index 1e5f5a3389..20306bd73a 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -53,8 +53,8 @@ modbus: modbus_controller: - address: 1 modbus_id: virtual_modbus_controller - update_interval: 1s id: modbus_controller_1 + update_interval: 1s modbus_server: - address: 1 @@ -176,6 +176,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml index e68edd2271..18423be6d5 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml @@ -113,7 +113,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_server_2).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml index 94890e90de..b3b5e76e31 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -326,6 +326,4 @@ button: - platform: template name: "Start Scenario" id: start_scenario_btn - on_press: - - lambda: "id(virtual_uart_server).start_scenario();" - - lambda: "id(virtual_uart_controller).start_scenario();" + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_timing.yaml b/tests/integration/fixtures/uart_mock_modbus_timing.yaml index c670864085..c62e0188bb 100644 --- a/tests/integration/fixtures/uart_mock_modbus_timing.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_timing.yaml @@ -53,7 +53,11 @@ modbus: sensor: - platform: sdm_meter address: 2 - update_interval: 1s + id: sdm_meter_1 + # update_interval is set to never to avoid automatic polling before the test starts the scenario. + # The test will manually start the poller after subscribing to states, to ensure no state changes are missed. + # This also allows us to assert there are no modbus errors/warnings during the initial request/response. + update_interval: never phase_a: voltage: name: sdm_voltage @@ -63,4 +67,7 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: "id(virtual_uart_dev).start_scenario();" + - lambda: |- + id(virtual_uart_dev).start_scenario(); + id(sdm_meter_1).set_update_interval(1000); + id(sdm_meter_1).start_poller(); diff --git a/tests/integration/test_logger_buffered_recursion_guard.py b/tests/integration/test_logger_buffered_recursion_guard.py new file mode 100644 index 0000000000..5bef915b28 --- /dev/null +++ b/tests/integration/test_logger_buffered_recursion_guard.py @@ -0,0 +1,119 @@ +"""Integration test for the recursion guard on the buffered logger drain path. + +Regression test for a crash where a log message drained from the task log buffer +(i.e. logged from a non-main thread) re-entered the logger on the main task while it +was still being delivered to listeners. The buffered drain in +``Logger::process_messages_`` did not hold the main-task recursion guard that the +synchronous logging path holds, so a listener callback that logged again on the main +task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused +the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed +up as a ``StoreProhibited`` panic inside the API send path. + +The fixture logs a small batch of verifiable messages from a non-main thread (kept +under the host task-log-buffer slot count so they all take the buffered drain path +rather than the emergency console fallback) while an ``on_message`` automation re-logs +``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message. + +Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while +the buffered thread message is still being delivered, so the message the API receives is +contaminated (it contains the marker and an embedded newline glued onto the thread +payload). With the guard the re-entrant log is dropped during the drain, the marker +never appears, and every thread message is delivered clean. +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import LogLevel +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index +THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})") + +NUM_MESSAGES = 30 + + +@pytest.mark.asyncio +async def test_logger_buffered_recursion_guard( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Buffered (non-main-thread) log messages survive a re-entrant main-task log.""" + api_messages: list[str] = [] + all_drained = asyncio.Event() + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "logger-recursion-test" + + # Subscribe over the API: this is the exact path that crashed in the field + # (the API log callback runs during the buffered drain). The API message field + # preserves embedded newlines, so it reliably exposes a clobbered buffer. + # + # Every buffered thread message is delivered here whether it survives intact or + # gets clobbered (a clobbered message still carries its THREADMSG payload), so + # counting THREADMSG occurrences is a deterministic "drain complete" signal: no + # arbitrary sleep, no dependence on the fix being present. + def on_log(msg) -> None: + text = msg.message.decode("utf-8", errors="replace") + api_messages.append(text) + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + if received >= NUM_MESSAGES: + all_drained.set() + + client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE) + + entities, _ = await client.list_entities_services() + buttons = [e for e in entities if e.name == "Start Race Test"] + assert buttons, "Could not find Start Race Test button" + client.button_command(buttons[0].key) + + # Wait until every buffered thread message has been delivered over the API. + try: + await asyncio.wait_for(all_drained.wait(), timeout=30.0) + except TimeoutError: + received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages) + pytest.fail( + f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; " + "device likely crashed or hung." + ) + + intact: set[int] = set() + contaminated: list[str] = [] + for raw in api_messages: + text = _ANSI.sub("", raw) + if "THREADMSG" not in text: + continue + # A clean thread message is a single line carrying only its own payload. A + # clobbered buffer glues the re-entrant marker (and an embedded newline) onto it. + if "REENTRANT" in text or "\n" in text: + contaminated.append(repr(raw)) + continue + match = THREAD_MSG_PATTERN.search(text) + assert match, f"Unexpected thread message format: {raw!r}" + msg_num = int(match.group(1)) + expected = f"{msg_num * 12345:08X}" + if match.group(2) != expected: + contaminated.append(repr(raw)) + continue + intact.add(msg_num) + + assert not contaminated, ( + "Buffered thread messages were clobbered by a re-entrant main-task log " + "(missing recursion guard on the buffered drain path):\n" + + "\n".join(contaminated[:10]) + ) + assert len(intact) == NUM_MESSAGES, ( + f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got " + f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}" + ) diff --git a/tests/integration/test_scheduler_blocking_warning.py b/tests/integration/test_scheduler_blocking_warning.py new file mode 100644 index 0000000000..699a5bc746 --- /dev/null +++ b/tests/integration/test_scheduler_blocking_warning.py @@ -0,0 +1,120 @@ +"""Integration tests for blocking-warning source attribution. + +A blocking operation that runs inside a deferred scheduler continuation (e.g. after a ``delay`` +in a script) used to be reported as `` took a long time for an operation (NN ms), +max is 30 ms`` because the continuation carries no component. The warning should instead name +the owning script and report the real threshold (50 ms). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Matches: " took a long time for an operation (NN ms), max is NN ms" +WARN_PATTERN = re.compile( + r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms" +) + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Deferred blocking work inside a script is attributed to the script, not "".""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + + # on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the + # continuation, tripping the blocking warning. + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + # Must name the owning script, not "" and not the generic fallback. + assert "" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + assert "a scheduled task" not in warning_line, ( + f"Warning should name the script, got: {warning_line}" + ) + match = WARN_PATTERN.search(warning_line) + assert match is not None + assert match.group(1) == "blocking_script", ( + f"Warning should name 'blocking_script', got: {warning_line}" + ) + # The reported threshold must be the real default (50 ms), not the stale "30 ms". + assert match.group(3) == "50", f"Expected 'max is 50 ms', got: {warning_line}" + + +@pytest.mark.asyncio +async def test_scheduler_blocking_warning_generic_source( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay in a plain (non-script) automation logs the generic label, not a script name.""" + loop = asyncio.get_running_loop() + warning_future: asyncio.Future[str] = loop.create_future() + + def check_output(line: str) -> None: + if WARN_PATTERN.search(line) and not warning_future.done(): + warning_future.set_result(line) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + warning_line = await asyncio.wait_for(warning_future, timeout=10.0) + + assert "a scheduled task took a long time" in warning_line, ( + f"Non-script deferred work should log the generic label, got: {warning_line}" + ) + assert "" not in warning_line + match = WARN_PATTERN.search(warning_line) + assert match is not None and match.group(3) == "50", ( + f"Expected 'max is 50 ms', got: {warning_line}" + ) + + +@pytest.mark.asyncio +async def test_scheduler_delay_runs_on_failed_component( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A delay must still fire even when its context component is marked failed. + + Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's + failed-component skip must not drop them. + """ + loop = asyncio.get_running_loop() + fired: asyncio.Future[bool] = loop.create_future() + + def check_output(line: str) -> None: + if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done(): + fired.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + assert await client.device_info() is not None + # If the failed host component wrongly dropped the delay, this times out. + await asyncio.wait_for(fired, timeout=10.0) diff --git a/tests/integration/test_socket_wake_gate_tcp.py b/tests/integration/test_socket_wake_gate_tcp.py new file mode 100644 index 0000000000..2955d2803a --- /dev/null +++ b/tests/integration/test_socket_wake_gate_tcp.py @@ -0,0 +1,75 @@ +"""Test that a TCP socket receive opens the component-phase gate immediately. + +Regression test for the wake-request flag not being set when select() returns +due to socket data on the host platform (wake_host.cpp wakeable_delay fix). + +The API server's accepted connection sockets use accept_loop_monitored(), so +they are registered with the host select() loop. A service call from the Python +client arrives on that socket. Without the fix, select() returning early did not +set g_wake_requested, so Application::loop()'s Phase B gate stayed closed until +loop_interval_ expired. With the fix, the gate opens immediately. +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_socket_wake_gate_tcp( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """TCP socket receive must open the component-phase gate immediately, + even with loop_interval_ raised to 2000ms.""" + loop = asyncio.get_running_loop() + boot_done: asyncio.Future[None] = loop.create_future() + pong: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "BOOT_DONE" in line and not boot_done.done(): + boot_done.set_result(None) + if "PONG" in line and not pong.done(): + pong.set_result(None) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "socket-wake-gate-tcp" + + try: + await asyncio.wait_for(boot_done, timeout=15.0) + except TimeoutError: + pytest.fail("BOOT_DONE never appeared — device did not complete boot") + + _, services = await client.list_entities_services() + ping_service = next((s for s in services if s.name == "ping"), None) + assert ping_service is not None, "ping service not found" + + # Execute the service and time how long until PONG appears in logs. + # The request bytes arrive on an accept_loop_monitored() TCP socket, + # which is registered with the host select() loop. + t_send = time.monotonic() + await client.execute_service(ping_service, {}) + + try: + await asyncio.wait_for(pong, timeout=5.0) + except TimeoutError: + pytest.fail("PONG never appeared — service did not execute") + + elapsed_ms = (time.monotonic() - t_send) * 1000 + # Without the fix the gate stays closed for up to loop_interval_=2000ms. + # With the fix the gate opens on the next tick; 500ms gives ample CI headroom. + assert elapsed_ms < 500, ( + f"Service response took {elapsed_ms:.0f}ms with loop_interval_=2000ms — " + f"expected < 500ms; without the wake-request fix this would take up to 2000ms" + ) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index e8dfa1b822..2c437341c6 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -127,15 +127,18 @@ async def test_uart_mock_modbus_timing( ) -> None: """Test modbus timing with multi-register SDM meter response.""" + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + tracker = SensorTracker(["sdm_voltage"]) voltage_changed = tracker.expect_any("sdm_voltage") async with ( - run_compiled(yaml_config), + run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) await tracker.await_change(voltage_changed, "sdm_voltage") + _assert_no_modbus_errors(error_log_lines, warning_log_lines) @pytest.mark.asyncio @@ -148,26 +151,25 @@ async def test_uart_mock_modbus_no_threshold( Without the 50ms fallback timeout, the chunked response with a 40ms gap between USB packets would cause a false timeout and CRC failure cascade. - Bus-level warnings (CRC failures, buffer clears) are expected during - chunked reassembly — the test only verifies the final value arrives. + Bus-level warnings (CRC/parse failures, buffer clears) are NOT expected during + chunked reassembly, if timeouts are set properly — these warnings indicate undersized timeouts. """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + tracker = SensorTracker(["sdm_voltage"]) voltage_changed = tracker.expect_any("sdm_voltage") async with ( - run_compiled(yaml_config), + run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): await tracker.setup_and_start_scenario(client) await tracker.await_change(voltage_changed, "sdm_voltage") + _assert_no_modbus_errors(error_log_lines, warning_log_lines) @pytest.mark.asyncio -@pytest.mark.xfail( - reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", - strict=True, -) async def test_uart_mock_modbus_server( yaml_config: str, run_compiled: RunCompiledFunction, @@ -308,10 +310,6 @@ async def test_uart_mock_modbus_server_controller_write( @pytest.mark.asyncio -@pytest.mark.xfail( - reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", - strict=True, -) async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, run_compiled: RunCompiledFunction, diff --git a/tests/script/test_build_helpers.py b/tests/script/test_build_helpers.py new file mode 100644 index 0000000000..efa6a75483 --- /dev/null +++ b/tests/script/test_build_helpers.py @@ -0,0 +1,76 @@ +"""Unit tests for script/build_helpers.py.""" + +from pathlib import Path +import sys + +import pytest + +# Add the script directory to the path so we can import build_helpers. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) + +import build_helpers # noqa: E402 + +from esphome.core import CORE # noqa: E402 + + +class _FakeComponent: + def __init__(self, config_schema, *, is_target_platform=False): + self.multi_conf = False + self.is_platform_component = False + self.is_target_platform = is_target_platform + self.config_schema = config_schema + + +@pytest.fixture(autouse=True) +def _restore_core_toolchain(): + """Keep CORE.toolchain changes from leaking between tests.""" + saved = CORE.toolchain + try: + yield + finally: + CORE.toolchain = saved + + +def test_populate_dependency_config_skips_target_platforms() -> None: + """Target-platform deps must be skipped, not config-populated, in a host build. + + Regression test for #17035: esp32 (a target platform) appears only as a + transitive dependency of a host C++ unit test. Running its schema with {} + set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation, + which crashed the host compile with KeyError('esp32'). The fix skips + target-platform components entirely so their schema never runs. + """ + CORE.toolchain = None # the state a host build starts from + schema_calls = [] + + def leaky_schema(value): + # If this ever runs for a target platform, the bug is back. + schema_calls.append(value) + CORE.toolchain = "esp-idf-leak" + raise ValueError("no board or variant") + + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["esp32"], + get_component_fn=lambda name: _FakeComponent( + leaky_schema, is_target_platform=True + ), + register_platform_fn=lambda domain: None, + ) + + assert "esp32" not in config # skipped: no synthesized entry + assert schema_calls == [] # schema never run + assert CORE.toolchain is None # no global side effect leaked + + +def test_populate_dependency_config_populates_defaults() -> None: + """A non-target-platform dep still has its schema defaults harvested.""" + config: dict = {} + build_helpers.populate_dependency_config( + config, + ["ok"], + get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}), + register_platform_fn=lambda domain: None, + ) + assert config["ok"] == {"default": 1} diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 8b81a57fef..8bbaa2773a 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -4,7 +4,12 @@ from __future__ import annotations import ast import importlib.util +import json from pathlib import Path +import subprocess +import sys + +import pytest from esphome import config_validation as cv @@ -134,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None: assert "foo" in config_var["schema"]["config_vars"] +def test_convert_emits_variant_enum() -> None: + """A per-variant enum is dumped with each value tagged by its variants.""" + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32S3, + variant_filtered_enum, + ) + + validator = variant_filtered_enum( + {VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")}, + lower=True, + ) + config_var: dict = {} + _bls.convert(validator, config_var, "/test") + + assert config_var["type"] == "enum" + assert config_var["values"] == { + "quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]}, + "octal": {"variants": [VARIANT_ESP32S3]}, + } + + def test_convert_keys_emits_heuristic_sensitive_marker() -> None: converted: dict = {} _bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root") @@ -176,3 +203,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None: entry = converted["schema"]["config_vars"]["hostname"] assert "sensitive" not in entry assert "sensitive_source" not in entry + + +# --------------------------------------------------------------------------- +# Regression tests for the lvgl schema dump. +# +# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are +# built lazily at validation time, so the static dumper used to emit an empty +# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA +# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise +# the full `build_schema()` and assert the generated lvgl.json carries the data +# the schema_extractor hooks added. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict: + """Run the full language-schema build once and return parsed lvgl.json. + + The build must run in a fresh interpreter: ``build_language_schema.py`` + enables schema extraction *before* importing any esphome component, and the + extraction hooks are no-ops if the components were already imported (as they + are inside the pytest session). Running it as a subprocess mirrors how CI + generates the schema and keeps this test isolated from import order. + """ + out_dir = tmp_path_factory.mktemp("language_schema") + subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)], + check=True, + capture_output=True, + text=True, + ) + return json.loads((out_dir / "lvgl.json").read_text()) + + +def _lvgl_config_vars(lvgl_schema: dict) -> dict: + config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"] + # Previously empty (`{}`); the schema_extractor on lvgl_config_schema now + # hands the dumper the composed top-level schema. + assert config_schema["type"] == "schema" + return config_schema["schema"]["config_vars"] + + +def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed. + assert len(config_vars) > 100 + # A representative spread of top-level options the runtime validates. + for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"): + assert key in config_vars, f"missing top-level lvgl option: {key}" + + +def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None: + config_vars = _lvgl_config_vars(lvgl_schema) + # The widgets: list is assembled per-value at runtime; the extractor + # enumerates every registered widget type into a named WIDGET_TYPES schema + # which the widgets: list references (recursive, so widgets can nest). + assert "widgets" in config_vars + widgets = config_vars["widgets"] + assert widgets["is_list"] is True + assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][ + "config_vars" + ] + # Every registered widget type should appear as an optional key. + for name in ("obj", "label", "button", "slider", "switch", "arc"): + assert name in widget_types, f"widget type not enumerated: {name}" + # Each enumerated widget carries its own property schema, not an empty stub. + assert widget_types["label"]["type"] == "schema" + assert len(widget_types["label"]["schema"]["config_vars"]) > 0 + # Each widget can contain child widgets, via the same named ref — so the + # tree is recursive and the dump stays finite. + nested = widget_types["obj"]["schema"]["config_vars"]["widgets"] + assert nested["is_list"] is True + assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"] + + +def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None: + schemas = lvgl_schema["lvgl"]["schemas"] + # Importing these into the lvgl __init__ namespace lets the dumper register + # them as named schemas and emit `extends` refs instead of inlining them. + for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"): + assert name in schemas, f"style schema not registered as named: {name}" + + # STYLE_SCHEMA must be referenced via `extends`, not inlined at every use + # site. Count the references to prove the dedup actually happened. + refs = 0 + + def _count(node: object) -> None: + nonlocal refs + if isinstance(node, dict): + extends = node.get("extends") + if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends: + refs += 1 + for value in node.values(): + _count(value) + elif isinstance(node, list): + for value in node: + _count(value) + + _count(lvgl_schema) + assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}" diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index 194926a5df..b5a9d8ebe9 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -1,9 +1,7 @@ """Unit tests for script/clang_tidy_hash.py module.""" -import hashlib from pathlib import Path import sys -from unittest.mock import Mock, patch import pytest @@ -11,76 +9,45 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script")) import clang_tidy_hash # noqa: E402 +from clang_tidy_hash import CLANG_TIDY_GLOBAL_FILES # noqa: E402 -@pytest.mark.parametrize( - ("file_content", "expected"), - [ - ( - "clang-tidy==18.1.5 # via -r requirements_dev.in\n", - "clang-tidy==18.1.5 # via -r requirements_dev.in", - ), - ( - "other-package==1.0\nclang-tidy==17.0.0\nmore-packages==2.0\n", - "clang-tidy==17.0.0", - ), - ( - "# comment\nclang-tidy==16.0.0 # some comment\n", - "clang-tidy==16.0.0 # some comment", - ), - ("no-clang-tidy-here==1.0\n", "clang-tidy version not found"), - ], -) -def test_get_clang_tidy_version_from_requirements( - file_content: str, expected: str +def _populate(repo_root: Path) -> None: + """Create every clang-tidy global file plus a base sdkconfig.defaults.""" + for name in CLANG_TIDY_GLOBAL_FILES: + path = repo_root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"contents of {name}\n") + (repo_root / "sdkconfig.defaults").write_text("CONFIG_BASE=y\n") + + +def test_calculate_clang_tidy_hash_is_deterministic(tmp_path: Path) -> None: + """Same inputs must produce the same hash.""" + _populate(tmp_path) + assert clang_tidy_hash.calculate_clang_tidy_hash( + repo_root=tmp_path + ) == clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) + + +@pytest.mark.parametrize("filename", CLANG_TIDY_GLOBAL_FILES) +def test_calculate_clang_tidy_hash_changes_with_each_global_file( + tmp_path: Path, filename: str ) -> None: - """Test extracting clang-tidy version from various file formats.""" - # Mock read_file_lines to return our test content - with patch("clang_tidy_hash.read_file_lines") as mock_read: - mock_read.return_value = file_content.splitlines(keepends=True) + """Editing any global file must change the hash.""" + _populate(tmp_path) + before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - result = clang_tidy_hash.get_clang_tidy_version_from_requirements() + (tmp_path / filename).write_text("changed\n") + after = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - assert result == expected - - -def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: - """Test calculating hash from all configuration sources including sdkconfig.defaults.""" - clang_tidy_content = b"Checks: '-*,readability-*'\n" - requirements_version = "clang-tidy==18.1.5" - platformio_content = b"[env:esp32]\nplatform = espressif32\n" - sdkconfig_content = b"" - requirements_content = "clang-tidy==18.1.5\n" - - # Create temporary files - (tmp_path / ".clang-tidy").write_bytes(clang_tidy_content) - (tmp_path / "platformio.ini").write_bytes(platformio_content) - (tmp_path / "sdkconfig.defaults").write_bytes(sdkconfig_content) - (tmp_path / "requirements_dev.txt").write_text(requirements_content) - - # Expected hash calculation - expected_hasher = hashlib.sha256() - expected_hasher.update(clang_tidy_content) - expected_hasher.update(requirements_version.encode()) - expected_hasher.update(platformio_content) - expected_hasher.update(b"sdkconfig.defaults") - expected_hasher.update(sdkconfig_content) - expected_hash = expected_hasher.hexdigest() - - result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - - assert result == expected_hash + assert after != before def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( tmp_path: Path, ) -> None: """Per-target sdkconfig.defaults. files must be part of the hash.""" - (tmp_path / ".clang-tidy").write_bytes(b"Checks: '-*'\n") - (tmp_path / "platformio.ini").write_bytes(b"[env:esp32]\n") - (tmp_path / "requirements_dev.txt").write_text("clang-tidy==18.1.5\n") - (tmp_path / "sdkconfig.defaults").write_bytes(b"CONFIG_BASE=y\n") - + _populate(tmp_path) before = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) # Adding a per-target file must change the hash. @@ -95,230 +62,14 @@ def test_calculate_clang_tidy_hash_includes_per_target_sdkconfig( assert after_edit != after_add -def test_calculate_clang_tidy_hash_without_sdkconfig(tmp_path: Path) -> None: - """Test calculating hash without sdkconfig.defaults file.""" - clang_tidy_content = b"Checks: '-*,readability-*'\n" - requirements_version = "clang-tidy==18.1.5" - platformio_content = b"[env:esp32]\nplatform = espressif32\n" - requirements_content = "clang-tidy==18.1.5\n" - - # Create temporary files (without sdkconfig.defaults) - (tmp_path / ".clang-tidy").write_bytes(clang_tidy_content) - (tmp_path / "platformio.ini").write_bytes(platformio_content) - (tmp_path / "requirements_dev.txt").write_text(requirements_content) - - # Expected hash calculation (no sdkconfig) - expected_hasher = hashlib.sha256() - expected_hasher.update(clang_tidy_content) - expected_hasher.update(requirements_version.encode()) - expected_hasher.update(platformio_content) - expected_hash = expected_hasher.hexdigest() - +def test_calculate_clang_tidy_hash_handles_missing_optional_files( + tmp_path: Path, +) -> None: + """Hash calculation must not fail when files are absent.""" + # Only .clang-tidy present; everything else missing. + (tmp_path / ".clang-tidy").write_text("Checks: '-*'\n") result = clang_tidy_hash.calculate_clang_tidy_hash(repo_root=tmp_path) - - assert result == expected_hash - - -def test_read_stored_hash_exists(tmp_path: Path) -> None: - """Test reading hash when file exists.""" - stored_hash = "abc123def456" - hash_file = tmp_path / ".clang-tidy.hash" - hash_file.write_text(f"{stored_hash}\n") - - result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path) - - assert result == stored_hash - - -def test_read_stored_hash_not_exists(tmp_path: Path) -> None: - """Test reading hash when file doesn't exist.""" - result = clang_tidy_hash.read_stored_hash(repo_root=tmp_path) - - assert result is None - - -def test_write_hash(tmp_path: Path) -> None: - """Test writing hash to file.""" - hash_value = "abc123def456" - hash_file = tmp_path / ".clang-tidy.hash" - - clang_tidy_hash.write_hash(hash_value, repo_root=tmp_path) - - assert hash_file.exists() - assert hash_file.read_text() == hash_value.strip() + "\n" - - -@pytest.mark.parametrize( - ("args", "current_hash", "stored_hash", "hash_file_in_changed", "expected_exit"), - [ - (["--check"], "abc123", "abc123", False, 1), # Hashes match, no scan needed - (["--check"], "abc123", "def456", False, 0), # Hashes differ, scan needed - (["--check"], "abc123", None, False, 0), # No stored hash, scan needed - ( - ["--check"], - "abc123", - "abc123", - True, - 0, - ), # Hash file updated in PR, scan needed - ], -) -def test_main_check_mode( - args: list[str], - current_hash: str, - stored_hash: str | None, - hash_file_in_changed: bool, - expected_exit: int, -) -> None: - """Test main function in check mode.""" - changed = [".clang-tidy.hash"] if hash_file_in_changed else [] - - # Create a mock module that can be imported - mock_helpers = Mock() - mock_helpers.changed_files = Mock(return_value=changed) - - with ( - patch("sys.argv", ["clang_tidy_hash.py"] + args), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch.dict("sys.modules", {"helpers": mock_helpers}), - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == expected_exit - - -def test_main_update_mode(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in update mode.""" - current_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - ): - clang_tidy_hash.main() - - mock_write.assert_called_once_with(current_hash) - captured = capsys.readouterr() - assert f"Hash updated: {current_hash}" in captured.out - - -@pytest.mark.parametrize( - ("current_hash", "stored_hash"), - [ - ("abc123", "def456"), # Hash changed, should update - ("abc123", None), # No stored hash, should update - ], -) -def test_main_update_if_changed_mode_update( - current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - """Test main function in update-if-changed mode when update is needed.""" - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 0 - mock_write.assert_called_once_with(current_hash) - captured = capsys.readouterr() - assert "Clang-tidy hash updated" in captured.out - - -def test_main_update_if_changed_mode_no_update( - capsys: pytest.CaptureFixture[str], -) -> None: - """Test main function in update-if-changed mode when no update is needed.""" - current_hash = "abc123" - stored_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - patch("clang_tidy_hash.write_hash") as mock_write, - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 0 - mock_write.assert_not_called() - captured = capsys.readouterr() - assert "Clang-tidy hash unchanged" in captured.out - - -def test_main_verify_mode_success(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in verify mode when verification passes.""" - current_hash = "abc123" - stored_hash = "abc123" - - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - ): - clang_tidy_hash.main() - captured = capsys.readouterr() - assert "Hash verification passed" in captured.out - - -@pytest.mark.parametrize( - ("current_hash", "stored_hash"), - [ - ("abc123", "def456"), # Hashes differ, verification fails - ("abc123", None), # No stored hash, verification fails - ], -) -def test_main_verify_mode_failure( - current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str] -) -> None: - """Test main function in verify mode when verification fails.""" - with ( - patch("sys.argv", ["clang_tidy_hash.py", "--verify"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - pytest.raises(SystemExit) as exc_info, - ): - clang_tidy_hash.main() - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "ERROR: Clang-tidy configuration has changed" in captured.out - - -def test_main_default_mode(capsys: pytest.CaptureFixture[str]) -> None: - """Test main function in default mode (no arguments).""" - current_hash = "abc123" - stored_hash = "def456" - - with ( - patch("sys.argv", ["clang_tidy_hash.py"]), - patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash), - patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash), - ): - clang_tidy_hash.main() - - captured = capsys.readouterr() - assert f"Current hash: {current_hash}" in captured.out - assert f"Stored hash: {stored_hash}" in captured.out - assert "Match: False" in captured.out - - -def test_read_file_lines(tmp_path: Path) -> None: - """Test read_file_lines helper function.""" - test_file = tmp_path / "test.txt" - test_content = "line1\nline2\nline3\n" - test_file.write_text(test_content) - - result = clang_tidy_hash.read_file_lines(test_file) - - assert result == ["line1\n", "line2\n", "line3\n"] + assert len(result) == 64 # sha256 hexdigest length def test_read_file_bytes(tmp_path: Path) -> None: @@ -330,35 +81,3 @@ def test_read_file_bytes(tmp_path: Path) -> None: result = clang_tidy_hash.read_file_bytes(test_file) assert result == test_content - - -def test_write_file_content(tmp_path: Path) -> None: - """Test write_file_content helper function.""" - test_file = tmp_path / "test.txt" - test_content = "test content" - - clang_tidy_hash.write_file_content(test_file, test_content) - - assert test_file.read_text() == test_content - - -@pytest.mark.parametrize( - ("line", "expected"), - [ - ("clang-tidy==18.1.5", ("clang-tidy", "clang-tidy==18.1.5")), - ( - "clang-tidy==18.1.5 # comment", - ("clang-tidy", "clang-tidy==18.1.5 # comment"), - ), - ("some-package>=1.0,<2.0", ("some-package", "some-package>=1.0,<2.0")), - ("pkg_with-dashes==1.0", ("pkg_with-dashes", "pkg_with-dashes==1.0")), - ("# just a comment", None), - ("", None), - (" ", None), - ("invalid line without version", None), - ], -) -def test_parse_requirement_line(line: str, expected: tuple[str, str] | None) -> None: - """Test parsing individual requirement lines.""" - result = clang_tidy_hash.parse_requirement_line(line) - assert result == expected diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index acc268fa68..a9876632bd 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -5,7 +5,7 @@ import importlib.util import json from pathlib import Path import sys -from unittest.mock import Mock, call, patch +from unittest.mock import Mock, patch import pytest @@ -68,13 +68,13 @@ def mock_should_run_device_builder() -> Generator[Mock, None, None]: @pytest.fixture -def mock_native_idf_components_to_test() -> Generator[Mock, None, None]: - """Mock native_idf_components_to_test from determine_jobs. +def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]: + """Mock esp32_platformio_components_to_test from determine_jobs. - main() drives both the ``native_idf`` boolean output and the - ``native_idf_components`` CSV from this one function. + main() drives both the ``esp32_platformio`` boolean output and the + ``esp32_platformio_components`` CSV from this one function. """ - with patch.object(determine_jobs, "native_idf_components_to_test") as mock: + with patch.object(determine_jobs, "esp32_platformio_components_to_test") as mock: yield mock @@ -115,7 +115,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -131,7 +131,7 @@ def test_main_all_tests_should_run( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True - mock_native_idf_components_to_test.return_value = ["api", "esp32"] + mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -213,8 +213,8 @@ def test_main_all_tests_should_run( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - assert output["native_idf_components"] == "api,esp32" + assert output["esp32_platformio"] is True + assert output["esp32_platformio_components"] == "api,esp32" assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -248,7 +248,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -264,7 +264,7 @@ def test_main_no_tests_should_run( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -305,8 +305,8 @@ def test_main_no_tests_should_run( assert output["python_linters"] is False assert output["import_time"] is False assert output["device_builder"] is False - assert output["native_idf"] is False - assert output["native_idf_components"] == "" + assert output["esp32_platformio"] is False + assert output["esp32_platformio_components"] == "" assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -322,6 +322,65 @@ def test_main_no_tests_should_run( assert output["component_test_batches"] == [] +def test_main_esp_idf_infra_change_folds_esp32( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_esp32_platformio_components_to_test: Mock, + mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An ESP-IDF infra-only change folds the `esp32` component into the matrix, + so the default native-IDF build path is still compiled.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_esp32_platformio_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + + # IDF build generator changed; no component changed. + mock_changed_files.return_value = ["esphome/build_gen/espidf.py"] + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "get_changed_components", return_value=[]), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), + patch.object( + determine_jobs, "get_components_with_dependencies", return_value=[] + ), + # esp32 has tests on disk, but pin it so the fold-in isn't coupled to layout. + patch.object(determine_jobs, "_component_has_tests", return_value=True), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, "create_intelligent_batches", return_value=([], {}) + ), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + # Only `esp32` is folded in (not the whole representative set), and it's + # grouped, not isolated (infra changed, not the component). + assert output["changed_components_with_tests"] == ["esp32"] + assert output["directly_changed_components_with_tests"] == [] + assert output["component_test_count"] == 1 + + def test_main_with_branch_argument( mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, @@ -329,7 +388,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -345,7 +404,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.return_value = True mock_should_run_import_time.return_value = True mock_should_run_device_builder.return_value = True - mock_native_idf_components_to_test.return_value = ["esp32"] + mock_esp32_platformio_components_to_test.return_value = ["esp32"] mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -384,7 +443,7 @@ def test_main_with_branch_argument( mock_should_run_python_linters.assert_called_once_with("main") mock_should_run_import_time.assert_called_once_with("main") mock_should_run_device_builder.assert_called_once_with("main") - mock_native_idf_components_to_test.assert_called_once_with("main") + mock_esp32_platformio_components_to_test.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -398,8 +457,8 @@ def test_main_with_branch_argument( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - assert output["native_idf_components"] == "esp32" + assert output["esp32_platformio"] is True + assert output["esp32_platformio_components"] == "esp32" assert output["changed_components"] == ["mqtt"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -653,52 +712,38 @@ def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None: @pytest.mark.parametrize( - ("check_returncode", "changed_files", "expected_result"), + ("changed_files", "expected_result"), [ - (0, [], True), # Hash changed - need full scan - (1, ["esphome/core.cpp"], True), # C++ file changed - (1, ["README.md"], False), # No C++ files changed - (1, [".clang-tidy.hash"], True), # Hash file itself changed - (1, ["platformio.ini", ".clang-tidy.hash"], True), # Config + hash changed + ([], False), # Nothing changed + (["esphome/core.cpp"], True), # C++ file changed + (["README.md"], False), # No C++ files changed + ([".clang-tidy"], True), # clang-tidy config changed - full scan + (["platformio.ini"], True), # build config changed - full scan + (["requirements_dev.txt"], True), # clang-tidy version source changed + (["sdkconfig.defaults"], True), # sdkconfig changed - full scan + (["sdkconfig.defaults.esp32c6"], True), # per-target sdkconfig changed + (["esphome/idf_component.yml"], True), # idf managed deps changed + (["platformio.ini", "README.md"], True), # config + non-C++ ], ) def test_should_run_clang_tidy( - check_returncode: int, changed_files: list[str], expected_result: bool, ) -> None: """Test should_run_clang_tidy function.""" - with ( - patch.object(determine_jobs, "changed_files", return_value=changed_files), - patch("subprocess.run") as mock_run, - ): - # Test with hash check returning specific code - mock_run.return_value = Mock(returncode=check_returncode) + with patch.object(determine_jobs, "changed_files", return_value=changed_files): result = determine_jobs.should_run_clang_tidy() assert result == expected_result -def test_should_run_clang_tidy_hash_check_exception() -> None: - """Test should_run_clang_tidy when hash check fails with exception.""" - # When hash check fails, clang-tidy should run as a safety measure - with ( - patch.object(determine_jobs, "changed_files", return_value=["README.md"]), - patch("subprocess.run", side_effect=Exception("Hash check failed")), - ): - result = determine_jobs.should_run_clang_tidy() - assert result is True # Fail safe - run clang-tidy - - def test_should_run_clang_tidy_with_branch() -> None: """Test should_run_clang_tidy with branch argument.""" with patch.object(determine_jobs, "changed_files") as mock_changed: mock_changed.return_value = [] - with patch("subprocess.run") as mock_run: - mock_run.return_value = Mock(returncode=1) # Hash unchanged - determine_jobs.should_run_clang_tidy("release") - # Changed files is called twice now - once for hash check, once for .clang-tidy.hash check - assert mock_changed.call_count == 2 - mock_changed.assert_has_calls([call("release"), call("release")]) + determine_jobs.should_run_clang_tidy("release") + # changed_files is queried against the given branch by both the + # config-file full-scan check and the C++ extension check. + mock_changed.assert_called_with("release") @pytest.mark.parametrize( @@ -930,23 +975,22 @@ def test_should_run_device_builder_skips_beta_release(target_branch: str) -> Non mock_changed.assert_not_called() -_NATIVE_IDF_FULL_LIST_FILES = [ +_ESP32_PLATFORMIO_FULL_LIST_FILES = [ # Core C++/Python changes -- caught by core_changed() ["esphome/core/component.cpp"], ["esphome/core/config.py"], - # Native IDF infrastructure paths - ["esphome/espidf/framework.py"], - ["esphome/espidf/component.py"], - ["esphome/espidf/api.py"], - ["esphome/build_gen/espidf.py"], + # PlatformIO subsystem (path-prefix trigger) + build generator + ["esphome/platformio/runner.py"], + ["esphome/platformio/toolchain.py"], + ["esphome/build_gen/platformio.py"], # Workflow / harness files ["script/test_build_components.py"], [".github/workflows/ci.yml"], ] -@pytest.mark.parametrize("changed_files", _NATIVE_IDF_FULL_LIST_FILES) -def test_native_idf_components_to_test_returns_full_list_on_infrastructure( +@pytest.mark.parametrize("changed_files", _ESP32_PLATFORMIO_FULL_LIST_FILES) +def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure( changed_files: list[str], ) -> None: """Infrastructure / core / harness changes fall back to the full component list.""" @@ -958,8 +1002,8 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( determine_jobs, "get_components_with_dependencies", return_value=["wifi"] ), ): - result = determine_jobs.native_idf_components_to_test() - assert result == sorted(determine_jobs.NATIVE_IDF_TEST_COMPONENTS) + result = determine_jobs.esp32_platformio_components_to_test() + assert result == sorted(determine_jobs.ESP32_PLATFORMIO_TEST_COMPONENTS) @pytest.mark.parametrize( @@ -979,7 +1023,7 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( ["ble_scanner", "esp32_ble", "esp32_ble_tracker"], ), # api in the test set -- narrow to [api] even though the closure - # has other (unrelated to native-IDF coverage) entries. + # has other (unrelated to PlatformIO coverage) entries. ( ["esphome/components/api/api_connection.cpp"], ["api", "logger"], @@ -993,15 +1037,15 @@ def test_native_idf_components_to_test_returns_full_list_on_infrastructure( ), # Pure Python-only change outside trigger paths -> empty. (["esphome/yaml_util.py"], [], []), - # Non-IDF files in esphome/build_gen/ do NOT trigger the full - # list -- only esphome/build_gen/espidf.py is a trigger. - (["esphome/build_gen/platformio.py"], [], []), + # Non-PlatformIO files in esphome/build_gen/ do NOT trigger the + # full list -- only esphome/build_gen/platformio.py is a trigger. + (["esphome/build_gen/espidf.py"], [], []), # Docs / unrelated files -> empty. (["README.md"], [], []), ([], [], []), ], ) -def test_native_idf_components_to_test_narrowing( +def test_esp32_platformio_components_to_test_narrowing( changed_files: list[str], dependency_closure: list[str], expected: list[str], @@ -1015,12 +1059,12 @@ def test_native_idf_components_to_test_narrowing( return_value=dependency_closure, ), ): - result = determine_jobs.native_idf_components_to_test() + result = determine_jobs.esp32_platformio_components_to_test() assert result == expected -def test_native_idf_components_to_test_with_branch() -> None: - """native_idf_components_to_test passes branch argument through. +def test_esp32_platformio_components_to_test_with_branch() -> None: + """esp32_platformio_components_to_test passes branch argument through. Regression test: an earlier version called ``get_changed_components()``, which silently ignored the branch argument because that helper re-runs @@ -1035,7 +1079,7 @@ def test_native_idf_components_to_test_with_branch() -> None: ), ): mock_changed.return_value = [] - determine_jobs.native_idf_components_to_test("release") + determine_jobs.esp32_platformio_components_to_test("release") mock_changed.assert_called_once_with("release") @@ -1047,25 +1091,46 @@ def test_native_idf_components_to_test_with_branch() -> None: (["esp32", "api"], True), ], ) -def test_should_run_native_idf(components_to_test: list[str], expected: bool) -> None: - """should_run_native_idf is a thin wrapper around the component list.""" +def test_should_run_esp32_platformio( + components_to_test: list[str], expected: bool +) -> None: + """should_run_esp32_platformio is a thin wrapper around the component list.""" with patch.object( determine_jobs, - "native_idf_components_to_test", + "esp32_platformio_components_to_test", return_value=components_to_test, ): - assert determine_jobs.should_run_native_idf() is expected + assert determine_jobs.should_run_esp32_platformio() is expected -def test_should_run_native_idf_with_branch() -> None: - """Test should_run_native_idf passes branch argument through.""" +def test_should_run_esp32_platformio_with_branch() -> None: + """Test should_run_esp32_platformio passes branch argument through.""" with patch.object( - determine_jobs, "native_idf_components_to_test", return_value=[] + determine_jobs, "esp32_platformio_components_to_test", return_value=[] ) as mock_inner: - determine_jobs.should_run_native_idf("release") + determine_jobs.should_run_esp32_platformio("release") mock_inner.assert_called_once_with("release") +@pytest.mark.parametrize( + ("changed_files", "expected"), + [ + # ESP-IDF runner / framework / build generator -> trigger + (["esphome/espidf/runner.py"], True), + (["esphome/espidf/framework.py"], True), + (["esphome/build_gen/espidf.py"], True), + # PlatformIO build gen and esp32 component are NOT IDF-infra triggers + (["esphome/build_gen/platformio.py"], False), + (["esphome/components/esp32/__init__.py"], False), + (["README.md"], False), + ([], False), + ], +) +def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None: + """ESP-IDF build/runner infra paths are detected; other paths are not.""" + assert determine_jobs._esp_idf_infra_changed(changed_files) is expected + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ @@ -1470,6 +1535,7 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: assert result["use_merged_config"] == "true" +@pytest.mark.usefixtures("mock_target_branch_dev") def test_detect_memory_impact_config_variant_only_platform_excluded( tmp_path: Path, ) -> None: @@ -2764,7 +2830,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], @@ -2785,7 +2851,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) mock_changed_files.return_value = [] @@ -2826,9 +2892,9 @@ def test_main_force_all_overrides_detection( assert output["python_linters"] is True assert output["import_time"] is True assert output["device_builder"] is True - assert output["native_idf"] is True - # native_idf_components is a CSV of NATIVE_IDF_TEST_COMPONENTS - assert "esp32" in output["native_idf_components"].split(",") + assert output["esp32_platformio"] is True + # esp32_platformio_components is a CSV of ESP32_PLATFORMIO_TEST_COMPONENTS + assert "esp32" in output["esp32_platformio_components"].split(",") assert output["cpp_unit_tests_run_all"] is True assert output["cpp_unit_tests_components"] == [] assert output["benchmarks"] is True @@ -2839,7 +2905,7 @@ def test_main_force_all_overrides_detection( mock_should_run_python_linters.assert_not_called() mock_should_run_import_time.assert_not_called() mock_should_run_device_builder.assert_not_called() - mock_native_idf_components_to_test.assert_not_called() + mock_esp32_platformio_components_to_test.assert_not_called() mock_determine_cpp_unit_tests.assert_not_called() # Component matrix is populated from disk (tests/components/ in the repo) assert output["component_test_count"] > 0 @@ -2853,7 +2919,7 @@ def test_main_force_all_off_uses_detection( mock_should_run_python_linters: Mock, mock_should_run_import_time: Mock, mock_should_run_device_builder: Mock, - mock_native_idf_components_to_test: Mock, + mock_esp32_platformio_components_to_test: Mock, mock_determine_cpp_unit_tests: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], @@ -2868,7 +2934,7 @@ def test_main_force_all_off_uses_detection( mock_should_run_python_linters.return_value = False mock_should_run_import_time.return_value = False mock_should_run_device_builder.return_value = False - mock_native_idf_components_to_test.return_value = [] + mock_esp32_platformio_components_to_test.return_value = [] mock_determine_cpp_unit_tests.return_value = (False, []) mock_changed_files.return_value = [] @@ -2899,7 +2965,7 @@ def test_main_force_all_off_uses_detection( assert output["clang_tidy"] is False assert output["clang_format"] is False assert output["python_linters"] is False - assert output["native_idf"] is False + assert output["esp32_platformio"] is False assert output["component_test_count"] == 0 mock_determine_integration_tests.assert_called_once() mock_should_run_clang_tidy.assert_called_once() diff --git a/tests/script/test_docker_build.py b/tests/script/test_docker_build.py new file mode 100644 index 0000000000..34bcc4e714 --- /dev/null +++ b/tests/script/test_docker_build.py @@ -0,0 +1,169 @@ +"""Unit tests for docker/build.py command generation.""" + +import importlib.util +from pathlib import Path +import sys + +import pytest + +_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py" +_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY) +docker_build = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(docker_build) + + +def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]: + """Run build.py main() in dry-run mode and return the emitted commands.""" + full_argv = ["build.py", "--dry-run", *argv] + with pytest.MonkeyPatch.context() as mp: + mp.setattr(sys, "argv", full_argv) + docker_build.main() + out = capsys.readouterr().out + return [line[2:] for line in out.splitlines() if line.startswith("$ ")] + + +def test_branch_build_pushes_single_ghcr_tag_without_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + "--push", + "--no-cache-to", + ) + + assert len(commands) == 1 + cmd = commands[0] + # Custom tag -> only the tag itself, no companion "dev"/"latest" tags + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert ":dev" not in cmd + # ghcr only -> no Docker Hub image name + assert "--tag esphome/esphome-amd64:my-branch" not in cmd + # custom tag falls back to the dev cache for reads + assert ( + "--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd + ) + assert "--push" in cmd + # --no-cache-to must suppress the cache write + assert "--cache-to" not in cmd + + +def test_branch_manifest_targets_ghcr_only( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "ha-addon", + "--registry", + "ghcr", + "manifest", + ) + + assert commands == [ + "docker buildx imagetools create " + "--tag ghcr.io/esphome/esphome-hassio:my-branch " + "ghcr.io/esphome/esphome-hassio-amd64:my-branch " + "ghcr.io/esphome/esphome-hassio-aarch64:my-branch" + ] + + +def test_release_build_keeps_both_registries_and_cache_to( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "2025.6.0", + "--arch", + "amd64", + "--build-type", + "docker", + "build", + "--push", + ) + + cmd = commands[0] + # Default (no --registry) keeps both Docker Hub and ghcr image names + assert "--tag esphome/esphome-amd64:2025.6.0" in cmd + assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd + # Release channel still gets its companion tags + assert "--tag esphome/esphome-amd64:latest" in cmd + # Without --no-cache-to the cache write is preserved + assert ( + "--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max" + in cmd + ) + + +def test_build_no_push_omits_push_and_cache( + capsys: pytest.CaptureFixture[str], +) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "ghcr", + "build", + ) + + cmd = commands[0] + assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd + assert "--push" not in cmd + assert "--cache-to" not in cmd + + +def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--arch", + "amd64", + "--build-type", + "docker", + "--registry", + "dockerhub", + "build", + "--push", + ) + + cmd = commands[0] + assert "--tag esphome/esphome-amd64:my-branch" in cmd + assert "ghcr.io" not in cmd + # Cache reference falls back to Docker Hub when GHCR isn't selected + assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd + + +def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None: + commands = _run( + capsys, + "--tag", + "my-branch", + "--build-type", + "docker", + "--registry", + "dockerhub", + "manifest", + ) + + create = commands[0] + assert create.startswith( + "docker buildx imagetools create --tag esphome/esphome:my-branch " + ) + assert "ghcr.io" not in create diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index a8100252da..4b05cab376 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -266,11 +266,13 @@ def _make_component_stub( *, multi_conf: bool = False, is_platform_component: bool = False, + is_target_platform: bool = False, config_schema=None, ) -> MagicMock: stub = MagicMock() stub.multi_conf = multi_conf stub.is_platform_component = is_platform_component + stub.is_target_platform = is_target_platform stub.config_schema = config_schema return stub diff --git a/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml b/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml new file mode 100644 index 0000000000..92a65c463e --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/esp32-idf.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP32 IDF tests - 2400 baud, EVEN parity + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml b/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml new file mode 100644 index 0000000000..00333867db --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/esp8266-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for ESP8266 Arduino tests - 2400 baud even parity + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml b/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml new file mode 100644 index 0000000000..c915e7846d --- /dev/null +++ b/tests/test_build_components/common/uart_2400_even/rp2040-ard.yaml @@ -0,0 +1,12 @@ +# Common UART configuration for RP2040 Arduino tests - 2400 baud even parity + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 2400 + parity: EVEN diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 540dd06731..0f4444f719 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -136,6 +136,54 @@ def test_get_project_cmakelists_full_emits_builtin_components_property( assert "JPEGDEC APPEND" not in content +def test_get_component_cmakelists_no_link_flags() -> None: + """With no -Wl, flags the target_link_options block is emitted with an empty body.""" + CORE.build_flags = set() + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert "target_link_options(${COMPONENT_LIB} PUBLIC\n \n)" in content + + +def test_get_component_cmakelists_single_link_flag() -> None: + """A single -Wl, flag appears indented inside target_link_options.""" + CORE.build_flags = {"-Wl,--gc-sections"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert ( + "target_link_options(${COMPONENT_LIB} PUBLIC\n -Wl,--gc-sections\n)" + in content + ) + + +def test_get_component_cmakelists_multiple_link_flags_sorted() -> None: + """Multiple -Wl, flags are sorted and joined with the four-space indent.""" + CORE.build_flags = {"-Wl,-z,noexecstack", "-Wl,--gc-sections", "-Wl,-Map=out.map"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + expected = ( + "target_link_options(${COMPONENT_LIB} PUBLIC\n" + " -Wl,--gc-sections\n" + " -Wl,-Map=out.map\n" + " -Wl,-z,noexecstack\n" + ")" + ) + assert expected in content + + +def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> None: + """-D and -W (non-linker) flags must not appear in target_link_options.""" + CORE.build_flags = {"-DFOO", "-Wall", "-Wl,--gc-sections"} + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + assert "-DFOO" not in content.split("target_link_options")[1] + assert "-Wall" not in content.split("target_link_options")[1] + assert "-Wl,--gc-sections" in content + + def test_get_project_cmakelists_emits_managed_components_property( tmp_path: Path, ) -> None: @@ -162,3 +210,53 @@ def test_get_project_cmakelists_emits_managed_components_property( "idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS" " espressif__esp-dsp APPEND)" ) in content + + +def test_get_project_cmakelists_replaces_cpp_standard(tmp_path: Path) -> None: + """cg.set_cpp_standard() replaces the IDF default -std in + CXX_COMPILE_OPTIONS between include(project.cmake) and project().""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", "gnu++20"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert ( + "idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS)" + in content + ) + assert 'list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")' in content + assert 'list(APPEND esphome_cxx_compile_options "-std=gnu++20")' in content + # The replacement must come after project.cmake (which appends the IDF + # default) and before project() (which consumes the options). + include_pos = content.index("tools/cmake/project.cmake") + replace_pos = content.index("CXX_COMPILE_OPTIONS") + project_pos = content.index("project(test)") + assert include_pos < replace_pos < project_pos + + +def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", None), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert "CXX_COMPILE_OPTIONS" not in content + + +def test_get_component_cmakelists_no_compile_features() -> None: + """The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the + top-level CMakeLists; the src component must not set its own.""" + with patch.object(CORE, "build_flags", set()): + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + + assert "target_compile_features" not in content diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index da0010afa3..2ae3836a25 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -160,3 +160,43 @@ def test_write_ini_no_change_when_content_same( call_args = mock_write_file_if_changed.call_args[0] assert call_args[0] == ini_file assert content in call_args[1] + + +@pytest.fixture +def clean_core(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(CORE, "name", "test") + monkeypatch.setattr(CORE, "platformio_options", {}) + monkeypatch.setattr(CORE, "platformio_libraries", {}) + monkeypatch.setattr(CORE, "build_flags", set()) + monkeypatch.setattr(CORE, "build_unflags", set()) + + +def test_get_ini_content_pins_cpp_standard( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """cg.set_cpp_standard() pins -std via build_flags and unflags every other + known standard so the platform/framework default is stripped.""" + monkeypatch.setattr(CORE, "cpp_standard", "gnu++20") + + content = platformio.get_ini_content() + + flags_section = content.split("build_flags =")[1].split("build_unflags =")[0] + unflags_section = content.split("build_unflags =")[1].split("extra_scripts")[0] + assert "-std=gnu++20\n" in flags_section + # Both the GNU and strict dialects of every other standard are stripped. + for year in ("11", "14", "17", "23", "26", "2a", "2b", "2c"): + assert f"-std=gnu++{year}\n" in unflags_section + assert f"-std=c++{year}\n" in unflags_section + assert "-std=c++20\n" in unflags_section + # The selected standard must not unflag itself. + assert "-std=gnu++20\n" not in unflags_section + + +def test_get_ini_content_no_cpp_standard( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(CORE, "cpp_standard", None) + + content = platformio.get_ini_content() + + assert "-std=" not in content diff --git a/tests/unit_tests/components/test_esp_stacktrace.py b/tests/unit_tests/components/test_esp_stacktrace.py index 5235f313d6..f231ac5fb7 100644 --- a/tests/unit_tests/components/test_esp_stacktrace.py +++ b/tests/unit_tests/components/test_esp_stacktrace.py @@ -45,6 +45,36 @@ def test_process_stacktrace_esp8266_backtrace( assert state is False +def test_process_stacktrace_esp8266_crash_handler( + setup_core: Path, mock_esp8266_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP8266 crash handler backtrace lines.""" + from esphome.components.esp8266 import process_stacktrace + + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp8266:191]: PC: 0x40220060" + state = process_stacktrace(config, line_pc, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40220060") + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + # Near-null data address (wild pointer) is not a code address, must be ignored + line_excvaddr = "[E][esp8266:193]: EXCVADDR: 0x0000008A" + state = process_stacktrace(config, line_excvaddr, False) + mock_esp8266_decode_pc.assert_not_called() + assert state is False + + mock_esp8266_decode_pc.reset_mock() + + line_bt0 = "[E][esp8266:196]: BT0: 0x40212345" + state = process_stacktrace(config, line_bt0, False) + mock_esp8266_decode_pc.assert_called_once_with(config, "40212345") + assert state is False + + def test_process_stacktrace_esp32_backtrace( setup_core: Path, mock_esp32_decode_pc: Mock ) -> None: diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ff150f2540..e2b34d92d8 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -20,6 +20,9 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + Toolchain, ) from esphome.core import CORE, config from esphome.core.config import ( @@ -1161,3 +1164,123 @@ def test_make_app_name_cpp_special_chars_escaped() -> None: cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False) # cpp_string_escape uses octal escapes for quotes assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes + + +@pytest.mark.parametrize( + ("lib", "name", "version", "repository"), + [ + ("ArduinoJson", "ArduinoJson", None, None), + ("bblanchon/ArduinoJson@7.4.2", "bblanchon/ArduinoJson", "7.4.2", None), + ( + "noise-c=https://github.com/esphome/noise-c.git", + "noise-c", + None, + "https://github.com/esphome/noise-c.git", + ), + ], +) +def test_add_library_str( + lib: str, name: str, version: str | None, repository: str | None +) -> None: + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + config._add_library_str(lib) + + libraries = list(CORE.platformio_libraries.values()) + assert len(libraries) == 1 + assert libraries[0].name == name + assert libraries[0].version == version + assert libraries[0].repository == repository + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_idf( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the native IDF toolchain, build_flags/lib_deps/lib_ignore are + honored, upload_speed is silent and everything else warns.""" + CORE.toolchain = Toolchain.ESP_IDF + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", # string and list forms both valid + "lib_deps": ["bblanchon/ArduinoJson@7.4.2"], + "lib_ignore": "libsodium", + "upload_speed": "115200", + "board_build.f_flash": "80000000L", + } + ) + + assert "-DSINGLE_FLAG" in CORE.build_flags + assert "ArduinoJson" in CORE.platformio_libraries + # lib_ignore is stored (listified) for generate_idf_components to read; + # nothing else lands in platformio_options on the native toolchain. + assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} + assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text + assert "upload_speed" not in caplog.text + # build_flags has a first-class esphome equivalent, so it is deprecated. + # lib_deps/lib_ignore are kept as valid platformio_options (no warning). + assert ( + "esphome->platformio_options->build_flags is deprecated; use " + "esphome->build_flags instead" in caplog.text + ) + assert "lib_deps is deprecated" not in caplog.text + assert "lib_ignore is deprecated" not in caplog.text + + +@pytest.mark.asyncio +async def test_add_platformio_options_platformio( + caplog: pytest.LogCaptureFixture, +) -> None: + """On the PlatformIO toolchain all options pass through to the ini, + with build_flags/lib_ignore listified.""" + CORE.toolchain = Toolchain.PLATFORMIO + + await config._add_platformio_options( + { + "build_flags": "-DSINGLE_FLAG", + "lib_ignore": "libsodium", + "upload_speed": "115200", + } + ) + + assert CORE.platformio_options == { + "build_flags": ["-DSINGLE_FLAG"], + "lib_ignore": ["libsodium"], + "upload_speed": "115200", + } + # platformio_options is the correct mechanism on the PlatformIO toolchain, + # so the native-equivalent deprecation must not fire here. + assert "deprecated" not in caplog.text + + +def test_add_library_str_bare_url_requires_name() -> None: + """A bare repository URL has no library name; CORE.add_library rejects it.""" + with pytest.raises(ValueError, match="must have a name"): + config._add_library_str("https://github.com/esphome/noise-c.git") + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: + """esphome->libraries entries are parsed and registered via cg.add_library.""" + result = load_config_from_fixture(yaml_file, "libraries.yaml", FIXTURES_DIR) + assert result is not None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + mock_cg.add_library.assert_any_call("SomeLib", None) + mock_cg.add_library.assert_any_call("bblanchon/ArduinoJson", "7.4.2") + mock_cg.add_library.assert_any_call( + "noise-c", None, "https://github.com/esphome/noise-c.git" + ) diff --git a/tests/unit_tests/fixtures/core/config/libraries.yaml b/tests/unit_tests/fixtures/core/config/libraries.yaml new file mode 100644 index 0000000000..c93e828f31 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/libraries.yaml @@ -0,0 +1,8 @@ +esphome: + name: test-libraries + libraries: + - SomeLib + - bblanchon/ArduinoJson@7.4.2 + - noise-c=https://github.com/esphome/noise-c.git + +host: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index cc371ee1f9..a61b6ae7ae 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -915,3 +915,21 @@ class TestEsphomeCore: mock_enable.assert_called_once_with("Wire") assert "Wire" in target.platformio_libraries + + def test_add_build_unflag__warns_on_native_idf_toolchain( + self, target, caplog: pytest.LogCaptureFixture + ) -> None: + """Build unflags are not consumed by the native IDF build generator, + so adding one on that toolchain warns; PlatformIO stays silent.""" + target.toolchain = const.Toolchain.PLATFORMIO + target.add_build_unflag("-fno-rtti") + assert "ignored" not in caplog.text + + target.toolchain = const.Toolchain.ESP_IDF + target.add_build_unflag("-fno-exceptions") + assert ( + "Build unflag -fno-exceptions is ignored when building with the " + "native ESP-IDF toolchain" in caplog.text + ) + # The unflag is still recorded either way. + assert target.build_unflags == {"-fno-rtti", "-fno-exceptions"} diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index 9791dfc543..cb25535d8d 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -56,11 +56,11 @@ def test_setup_core_sets_arduino_env( target_framework: str, expected: str, ) -> None: - """_setup_core sets ESPHOME_ARDUINO, which gates arduino-only manifest deps.""" + """_setup_core sets ESPHOME_ARDUINO_COMPONENT, which gates arduino-only manifest deps.""" # monkeypatch snapshots os.environ, so the env var _setup_core writes is # restored after the test instead of leaking into later tests. - monkeypatch.delenv("ESPHOME_ARDUINO", raising=False) + monkeypatch.delenv("ESPHOME_ARDUINO_COMPONENT", raising=False) _setup_core(tmp_path / "proj", _settings(target_framework=target_framework)) - assert os.environ["ESPHOME_ARDUINO"] == expected + assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 602ff03942..87e168dc94 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -1,3 +1,4 @@ +import hashlib import json import os from pathlib import Path @@ -515,7 +516,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -557,6 +558,62 @@ def test_generate_idf_components_dedupes_shared_dependency( assert "idf_component_register" in generated +def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + esp32_idf_core: None, +) -> None: + # lib_ignore must drop B at the top level and C when it is discovered as a + # dependency of A during the graph walk -- neither may be resolved, + # downloaded, or wired into a manifest. Matching is by lowercase short name. + manifests = { + "esphome/A": { + "name": "A", + "dependencies": [ + {"owner": "esphome", "name": "C", "version": "==1.10021.0"} + ], + }, + "esphome/B": {"name": "B"}, + } + + download_salts: list[str] = [] + + def fake_download(self, force=False, salt=""): + download_salts.append(salt) + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + (self.path / "src").mkdir(parents=True, exist_ok=True) + (self.path / "src" / "x.c").write_text("int x;") + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(IDFComponent, "download", fake_download) + + resolve_calls: list[str] = [] + + def fake_resolve(owner, pkgname, requirements): + resolve_calls.append(pkgname) + return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" + + monkeypatch.setattr( + esphome.espidf.component, "_resolve_registry_version", fake_resolve + ) + # lib_ignore is read from CORE.platformio_options (stored there by + # _add_platformio_options); matched by lowercase short name. + monkeypatch.setattr(CORE, "platformio_options", {"lib_ignore": ["B", "esphome/C"]}) + + top = generate_idf_components( + [Library("esphome/A", "1.0.0", None), Library("esphome/B", "1.0.0", None)] + ) + + assert [c.name for c in top] == ["esphome/A"] + # Ignored libraries were never resolved (and therefore never downloaded). + assert resolve_calls == ["A"] + # The ignored dependency is not wired into A's manifest. + assert top[0].dependencies == [] + # lib_ignore changes the generated wiring, so the cache path is salted to + # keep this conversion separate from ones with a different lib_ignore. + assert download_salts == [hashlib.sha256(b"b,c").hexdigest()[:8]] + + def test_generate_idf_components_handles_dependency_cycle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -575,7 +632,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -632,7 +689,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -669,7 +726,7 @@ def test_generate_idf_components_missing_manifest_raises( ) -> None: # A library with neither library.json nor library.properties is invalid; # fail loudly rather than silently generating build files for it. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written @@ -711,7 +768,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "src" / "x.c").write_text("int x;") @@ -744,7 +801,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ) -> None: # A top-level library that isn't ESP-IDF/esp32 compatible must fail fast, # not be silently dropped. - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -782,7 +839,7 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False): + def fake_download(self, force=False, salt=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text(json.dumps(manifests[self.name])) @@ -804,3 +861,54 @@ def test_generate_idf_components_incompatible_dependency_skipped( assert [c.name for c in top] == ["esphome/A"] # The incompatible dependency was dropped, not wired in. assert top[0].dependencies == [] + + +def test_url_source_salt_changes_cache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The salt is mixed into the URL hash so salted conversions get their own + cache tree. Pre-created extraction markers keep this network-free.""" + monkeypatch.setattr(CORE, "config_path", tmp_path / "test.yaml") + url = "http://example.com/lib.tar.gz" + base = tmp_path / ".esphome" / "pio_components" + expected = {} + for salt in ("", "abcd1234"): + digest = hashlib.sha256((url + salt).encode()).hexdigest()[:8] + expected[salt] = base / digest / "lib" + expected[salt].mkdir(parents=True) + (expected[salt] / ".esphome_extracted").touch() + + source = URLSource(url) + assert source.download("lib") == expected[""] + assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + + +def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: + """The salt becomes a subdirectory of the git clone domain.""" + domains: list[str] = [] + + def fake_clone_or_update(**kwargs): + domains.append(kwargs["domain"]) + return Path("/cloned"), None + + monkeypatch.setattr( + esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + ) + + source = GitSource("https://github.com/esphome/noise-c.git", "v1.0") + source.download("noise-c") + source.download("noise-c", salt="abcd1234") + assert domains == ["pio_components", "pio_components/abcd1234"] + + +def test_idf_component_download_passes_salt() -> None: + """IDFComponent.download forwards the sanitized name and salt to the + source and records the returned path.""" + source = MagicMock() + source.download.return_value = Path("/converted/owner/name") + + c = IDFComponent("owner/name", "1.0", source=source) + c.download(force=True, salt="abcd1234") + + source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + assert c.path == Path("/converted/owner/name") diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 8849ea8bc8..017d8c49b4 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "g++"} - assert json.loads(cache.read_text()) == {"cxx_path": "g++"} + prog_path = str(toolchain.get_elf_path()) + assert result == {"cxx_path": "g++", "prog_path": prog_path} + assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path} def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None: @@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) - result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "fresh"} + assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())} def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: @@ -147,7 +148,40 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None: result = toolchain.get_idedata() mock_transform.assert_called_once() - assert result == {"cxx_path": "regen"} + assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())} + + +def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None: + """The idedata exposes prog_path (the ELF) so consumers like build-action + can locate firmware.factory.bin / firmware.ota.bin as its siblings.""" + compile_commands, _ = _setup_build(setup_core) + compile_commands.parent.mkdir(parents=True, exist_ok=True) + compile_commands.write_text("[]") + + with patch( + "esphome.espidf.idedata.idedata_from_build", + return_value={"cxx_path": "g++"}, + ): + result = toolchain.get_idedata() + + # Use Path semantics so the contract holds on Windows too (backslashes). + prog_path = Path(result["prog_path"]) + assert prog_path.name == "firmware.elf" + assert prog_path.parent.name == "build" + + +def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None: + """The IDF env caps git's upward search at the config directory. + + This stops ESP-IDF's `git describe` from walking into an uninitialized or + corrupt git repo in a parent directory and failing the build. + """ + toolchain._cache().env.clear() + # Set IDF_PATH so the framework-install branch is skipped. + with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}): + env = toolchain._get_idf_env(version="5.5.4") + assert CORE.config_dir == setup_core + assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) def test_get_core_framework_version_from_core_data(): diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index a8533608c0..f6e783b5e8 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -25,6 +25,8 @@ from esphome.framework_helpers import ( archive_extract_all, create_venv, download_from_mirrors, + get_project_compile_flags, + get_project_link_flags, get_python_env_executable_path, get_system_python_path, rmdir, @@ -952,3 +954,84 @@ class TestSevenZipExtractAll: out.mkdir() archive_extract_all(archive, out) assert (out / "hello.txt").exists() + + +# --------------------------------------------------------------------------- +# get_project_compile_flags / get_project_link_flags +# --------------------------------------------------------------------------- + + +def _make_core(flags: set[str]): + core = MagicMock() + core.build_flags = flags + return core + + +class TestGetProjectCompileFlags: + def test_returns_define_flags(self) -> None: + with patch("esphome.core.CORE", _make_core({"-DFOO", "-DBAR=1"})): + assert get_project_compile_flags() == ["-DBAR=1", "-DFOO"] + + def test_returns_warning_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wno-error", "-Wall"}), + ): + assert get_project_compile_flags() == ["-Wall", "-Wno-error"] + + def test_excludes_linker_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DFOO", "-Wl,--gc-sections", "-Wl,-Map=output.map"}), + ): + assert get_project_compile_flags() == ["-DFOO"] + + def test_excludes_other_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-O2", "-std=gnu++20", "-DFOO"}), + ): + assert get_project_compile_flags() == ["-DFOO"] + + def test_empty_build_flags(self) -> None: + with patch("esphome.core.CORE", _make_core(set())): + assert get_project_compile_flags() == [] + + def test_result_is_sorted(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DZFLAG", "-DAFLAG", "-Wno-unused"}), + ): + result = get_project_compile_flags() + assert result == sorted(result) + + +class TestGetProjectLinkFlags: + def test_returns_linker_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wl,--gc-sections", "-Wl,-Map=output.map"}), + ): + assert get_project_link_flags() == [ + "-Wl,--gc-sections", + "-Wl,-Map=output.map", + ] + + def test_excludes_compile_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-DFOO", "-Wall", "-Wl,--gc-sections"}), + ): + assert get_project_link_flags() == ["-Wl,--gc-sections"] + + def test_empty_build_flags(self) -> None: + with patch("esphome.core.CORE", _make_core(set())): + assert get_project_link_flags() == [] + + def test_result_is_sorted(self) -> None: + with patch( + "esphome.core.CORE", + _make_core({"-Wl,-z", "-Wl,-a", "-Wl,-m"}), + ): + result = get_project_link_flags() + assert result == sorted(result) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index efc2d8e42a..70c4b90082 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -196,6 +196,33 @@ def test_is_ha_addon(monkeypatch, value, expected): assert actual == expected +def test_add_git_ceiling_directory_sets_when_unset(): + """An empty env gets GIT_CEILING_DIRECTORIES set to the directory.""" + env: dict[str, str] = {} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + +def test_add_git_ceiling_directory_appends_to_existing(): + """An existing value is preserved and the new directory is appended.""" + env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))} + directory = Path("/home/user/config") + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [ + str(Path("/some/ceiling")), + str(directory), + ] + + +def test_add_git_ceiling_directory_skips_duplicate(): + """A directory already in the list is not appended again.""" + directory = Path("/home/user/config") + env = {"GIT_CEILING_DIRECTORIES": str(directory)} + helpers.add_git_ceiling_directory(env, directory) + assert env["GIT_CEILING_DIRECTORIES"] == str(directory) + + def test_walk_files(fixture_path): path = fixture_path / "helpers" diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 3fb0eca4a0..42e5203a73 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -1,8 +1,28 @@ """Unit tests for esphome.loader module.""" -from unittest.mock import MagicMock, patch +import ast +import logging +from pathlib import Path +import sys +import textwrap +from types import ModuleType +from unittest.mock import MagicMock, Mock, patch -from esphome.loader import ComponentManifest, _replace_component_manifest, get_component +import pytest +import voluptuous as vol + +from esphome import config as esphome_config, config_validation as cv +from esphome.core import CORE +import esphome.loader as loader_mod +from esphome.loader import ( + AliasMeta, + ComponentManifest, + _AliasFinder, + _build_alias_map, + _read_aliases, + _replace_component_manifest, + get_component, +) from tests.testing_helpers import ComponentManifestOverride # --------------------------------------------------------------------------- @@ -322,3 +342,642 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub names = [r.resource for r in manifest.resources] assert names == ["wake/wake_freertos.cpp"] + + +# --------------------------------------------------------------------------- +# Component aliases (renamed-platform back-compat) +# --------------------------------------------------------------------------- +# +# These tests pin down the substrate behind `ALIASES = [...]` on component +# `__init__.py` files: the AST scanner, the resulting global alias map, the +# Python-import `sys.meta_path` finder, the `get_component` integration, and +# the YAML pre-pass that rewrites legacy top-level keys. +# +# The framework is component-agnostic, so the integration tests inject a +# synthetic alias map (pointing a fake legacy name at the real `esp32` +# component) rather than depending on any specific renamed component. + +# A legacy name that is NOT a real component, used as a synthetic alias. +_FAKE_ALIAS = "esp32_legacy_alias" + + +def _write_component(root: Path, name: str, body: str) -> None: + """Write a fake component package at ``root//__init__.py``.""" + pkg = root / name + pkg.mkdir() + (pkg / "__init__.py").write_text(body) + + +def test_read_aliases_extracts_list_literal(tmp_path: Path) -> None: + """AST scan should pick up ``ALIASES = ["legacy"]`` without executing.""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['legacy_name']\n") + aliases, removal = _read_aliases(init, ast) + assert aliases == ["legacy_name"] + assert removal is None + + +def test_read_aliases_extracts_removal_version(tmp_path: Path) -> None: + """``ALIAS_REMOVAL_VERSION`` should be paired with the alias list.""" + init = tmp_path / "__init__.py" + init.write_text( + textwrap.dedent("""\ + ALIASES = ['old'] + ALIAS_REMOVAL_VERSION = "2027.6.0" + """) + ) + aliases, removal = _read_aliases(init, ast) + assert aliases == ["old"] + assert removal == "2027.6.0" + + +def test_read_aliases_skips_dynamic_forms(tmp_path: Path) -> None: + """A call-expression / non-literal ALIASES shouldn't surface — the + scanner deliberately ignores anything non-static to keep behavior + predictable (and avoid executing component code).""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = list_helper()\nALIASES = ['caught'] if False else []\n") + aliases, _ = _read_aliases(init, ast) + assert aliases == [] + + +def test_read_aliases_returns_empty_for_missing_declaration(tmp_path: Path) -> None: + init = tmp_path / "__init__.py" + init.write_text("CODEOWNERS = ['@me']\n") + aliases, removal = _read_aliases(init, ast) + assert aliases == [] + assert removal is None + + +def test_read_aliases_handles_syntax_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A broken __init__.py shouldn't crash the alias scanner — it'll + surface as an ImportError elsewhere, but the scanner logs a warning and + yields nothing so other components keep working. The substring pre-filter + only skips files with no ``ALIASES`` token, so this file (which has one) + still reaches the parse.""" + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['x']\ndef broken( :\n") + assert _read_aliases(init, ast) == ([], None) + assert "Could not parse" in caplog.text + + +def test_read_aliases_handles_read_error( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unreadable __init__.py logs a warning and yields nothing rather + than aborting the whole component scan.""" + missing = tmp_path / "nope" / "__init__.py" + assert _read_aliases(missing, ast) == ([], None) + assert "Could not read" in caplog.text + + +def test_build_alias_map_aggregates_components(tmp_path: Path) -> None: + """End-to-end map build over a fake components dir.""" + _write_component(tmp_path, "newcomp", "ALIASES = ['oldcomp']\n") + _write_component(tmp_path, "other", "") + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, meta_map = _build_alias_map() + + assert alias_map == {"oldcomp": "newcomp"} + assert meta_map == {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)} + + +def test_build_alias_map_carries_removal_version(tmp_path: Path) -> None: + _write_component( + tmp_path, + "newcomp", + "ALIASES = ['oldcomp']\nALIAS_REMOVAL_VERSION = '2028.1.0'\n", + ) + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + _, meta_map = _build_alias_map() + + assert meta_map["oldcomp"].removal_version == "2028.1.0" + + +def test_build_alias_map_rejects_duplicate_alias(tmp_path: Path) -> None: + """If two canonical components both claim the same legacy alias, + routing becomes ambiguous — the build must refuse to start so the + conflict surfaces immediately at import time, not later as a + 'mysterious wrong component' bug.""" + _write_component(tmp_path, "comp_a", "ALIASES = ['shared']\n") + _write_component(tmp_path, "comp_b", "ALIASES = ['shared']\n") + + from esphome.core import EsphomeError + + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shared"), + ): + _build_alias_map() + + +def test_build_alias_map_handles_missing_dir(tmp_path: Path) -> None: + """If the components directory doesn't exist (unlikely in production, + but possible in some test contexts), we want an empty map rather than + a crash — the rest of the loader can still function.""" + fake = tmp_path / "does-not-exist" + with patch("esphome.loader.CORE_COMPONENTS_PATH", fake): + alias_map, meta_map = _build_alias_map() + assert alias_map == {} + assert meta_map == {} + + +def test_build_alias_map_rejects_alias_shadowing_component(tmp_path: Path) -> None: + """An alias that names an existing component package is refused: it would + hijack a live domain, and a self-alias (alias == canonical) would send + ``_lookup_module`` into infinite recursion.""" + # `newcomp` declares itself as an alias — its own package already exists. + _write_component(tmp_path, "newcomp", "ALIASES = ['newcomp']\n") + + from esphome.core import EsphomeError + + with ( + patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path), + pytest.raises(EsphomeError, match="shadows an existing component"), + ): + _build_alias_map() + + +# ---- Integration against a synthetic alias map (fake legacy -> esp32) ---- + + +def _patch_alias_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None: + """Force the loader's alias map (used by the finder and get_component). + + Patches the lazily-built caches so both ``_get_alias_map`` and the + installed meta-path finder resolve against ``mapping`` regardless of + what the real on-disk scan would produce. + """ + monkeypatch.setattr("esphome.loader._get_alias_map", lambda: mapping) + + +def test_get_component_resolves_alias(monkeypatch: pytest.MonkeyPatch) -> None: + """``get_component()`` should return the canonical manifest — every + caller of the loader (dep checker, schema validator, codegen) hits + the canonical component without knowing about the alias.""" + import esphome.loader as loader_mod + + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + loader_mod._COMPONENT_CACHE.pop(_FAKE_ALIAS, None) + + canonical = get_component("esp32") + aliased = get_component(_FAKE_ALIAS) + assert canonical is not None + assert aliased is canonical + + +def test_alias_finder_resolves_top_level_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``import esphome.components.`` resolves to the canonical + module via the meta-path finder. ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) + + finder = _AliasFinder() + spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}", None) + assert spec is not None + + import esphome.components.esp32 + import esphome.components.esp32_legacy_alias + + assert esphome.components.esp32_legacy_alias is esphome.components.esp32 + + +def test_alias_finder_resolves_submodule_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``from esphome.components. import boards`` routes through to + ``esphome.components.esp32.boards`` — same submodule object on both paths. + + The canonical submodule is imported first so its parent module carries + the ``boards`` attribute; ``from import boards`` then resolves + the aliased parent (via the finder) and reads that same attribute, + rather than triggering a fresh file load under the alias name. + ``_FAKE_ALIAS`` == ``esp32_legacy_alias``.""" + _patch_alias_map(monkeypatch, {_FAKE_ALIAS: "esp32"}) + sys.modules.pop(f"esphome.components.{_FAKE_ALIAS}", None) + + finder = _AliasFinder() + spec = finder.find_spec(f"esphome.components.{_FAKE_ALIAS}.boards", None) + assert spec is not None + + from esphome.components.esp32 import boards as canonical_boards + from esphome.components.esp32_legacy_alias import boards as aliased_boards + + assert aliased_boards is canonical_boards + + +def test_alias_finder_ignores_non_components_path() -> None: + """The finder must scope itself to ``esphome.components.`` — + everything else (other esphome submodules, third-party packages) is + left for the normal import machinery.""" + finder = _AliasFinder() + assert finder.find_spec("esphome.core", None) is None + assert finder.find_spec("os.path", None) is None + # `esphome.components` itself (no domain segment) is not a candidate. + assert finder.find_spec("esphome.components", None) is None + # A real, non-aliased component domain defers to normal import machinery + # (no component declares an alias in this repo, so the live map is empty). + assert finder.find_spec("esphome.components.logger", None) is None + + +# --------------------------------------------------------------------------- +# YAML pre-pass: top-level key rename + centralized deprecation warning +# --------------------------------------------------------------------------- +# +# The companion to the loader-side alias map: ``esphome.config`` runs a +# pre-pass over the user's parsed YAML that rewrites legacy top-level keys +# to their canonical names, surfacing a one-shot deprecation warning. These +# tests inject a synthetic alias-metadata map so the rewrite behavior, the +# warning text, and the both-keys-present conflict can be tested in isolation. + + +def _patch_alias_metadata( + monkeypatch: pytest.MonkeyPatch, mapping: dict[str, AliasMeta] +) -> None: + monkeypatch.setattr("esphome.loader.get_alias_metadata", lambda: mapping) + + +def test_resolve_component_aliases_renames_legacy_key( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A legacy alias key should be renamed to the canonical key and a + deprecation warning citing the removal version logged.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version="2027.6.0")}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires + config = {"esphome": {"name": "test"}, "oldcomp": {"board": "x"}} + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert "oldcomp" not in config + assert config["newcomp"] == {"board": "x"} + assert any( + "'oldcomp:' top-level key is deprecated" in record.message + and "rename it to 'newcomp:'" in record.message + and "2027.6.0" in record.message + for record in caplog.records + ) + + +def test_resolve_component_aliases_dedupes_warning_within_a_run( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Schema validators can run twice (auto-load discovery + final pass) + so the rename pass must emit the warning only once per alias per run. + Deduped via ``CORE.data``; cleared between runs.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases({"oldcomp": {"board": "a"}}) + _resolve_component_aliases({"oldcomp": {"board": "b"}}) + + matches = [ + r + for r in caplog.records + if "'oldcomp:' top-level key is deprecated" in r.message + ] + assert len(matches) == 1 + + +def test_resolve_component_aliases_rejects_both_keys_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the user has BOTH legacy and canonical keys, silently dropping + one would hide a real misconfiguration. Raise instead.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"newcomp": {"board": "x"}, "oldcomp": {"board": "x"}} + with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_rejects_canonical_key_after_legacy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The both-keys conflict must be detected even when the canonical key + appears *after* the legacy key in the config (the up-front conflict + scan, not a position-dependent check).""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"oldcomp": {"board": "x"}, "newcomp": {"board": "x"}} + with pytest.raises(vol.Invalid, match="Both 'oldcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_rejects_multiple_aliases_of_one_component( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two different deprecated aliases of the same canonical component is + ambiguous — silently keeping one would hide a misconfiguration.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + { + "oldcomp": AliasMeta(canonical="newcomp", removal_version=None), + "legacycomp": AliasMeta(canonical="newcomp", removal_version=None), + }, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"oldcomp": {"board": "x"}, "legacycomp": {"board": "y"}} + with pytest.raises(vol.Invalid, match=r"Multiple deprecated aliases of 'newcomp:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_preserves_key_position( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The renamed canonical key keeps the legacy key's original position + rather than being moved to the end of the config.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "t"}, "oldcomp": {"board": "x"}, "logger": {}} + + _resolve_component_aliases(config) + + assert list(config) == ["esphome", "newcomp", "logger"] + + +def test_resolve_component_aliases_no_op_when_no_legacy_keys( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The pre-pass must be a no-op (no warning, no mutation) for configs + that already use canonical keys.""" + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + _patch_alias_metadata( + monkeypatch, + {"oldcomp": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "test"}, "newcomp": {"board": "x"}} + original = dict(config) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert config == original + assert not any("deprecated" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# ComponentManifest alias properties +# --------------------------------------------------------------------------- + + +def test_component_manifest_alias_properties_default_empty() -> None: + """``aliases`` / ``alias_removal_version`` fall back to ``[]`` / ``None`` + when the component module declares neither. + + Uses a real ``ModuleType`` rather than a ``MagicMock`` so that the + ``getattr(..., default)`` fallback is actually exercised — a bare mock + auto-creates any attribute on access and would never hit the default.""" + mod = ModuleType("fake_component") + manifest = ComponentManifest(mod) + assert manifest.aliases == [] + assert manifest.alias_removal_version is None + + +def test_component_manifest_alias_properties_read_module_values() -> None: + """The properties surface the module's declared values verbatim.""" + mod = MagicMock() + mod.ALIASES = ["legacy"] + mod.ALIAS_REMOVAL_VERSION = "2027.6.0" + manifest = ComponentManifest(mod) + assert manifest.aliases == ["legacy"] + assert manifest.alias_removal_version == "2027.6.0" + + +# --------------------------------------------------------------------------- +# Real (unpatched) lazy build + cache and remaining scanner branches +# --------------------------------------------------------------------------- + + +def test_get_alias_map_real_build_and_caches(monkeypatch: pytest.MonkeyPatch) -> None: + """Exercise the real lazy build over the actual components dir (no patch): + the first call scans and caches, the second returns the cached object.""" + monkeypatch.setattr(loader_mod, "_ALIAS_MAP_CACHE", None) + first = loader_mod._get_alias_map() + second = loader_mod._get_alias_map() + assert isinstance(first, dict) + assert first is second # cached, not rebuilt on the second call + + +def test_get_alias_metadata_real_build_and_caches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(loader_mod, "_ALIAS_META_CACHE", None) + first = loader_mod.get_alias_metadata() + second = loader_mod.get_alias_metadata() + assert isinstance(first, dict) + assert first is second + + +def test_build_alias_map_skips_files_and_initless_dirs(tmp_path: Path) -> None: + """Loose files and directories without an ``__init__.py`` are ignored; + only real component packages contribute to the map.""" + (tmp_path / "loose_file.py").write_text("ALIASES = ['ignored']\n") + (tmp_path / "initless").mkdir() # a dir, but no __init__.py + _write_component(tmp_path, "realcomp", "ALIASES = ['legacy']\n") + + with patch("esphome.loader.CORE_COMPONENTS_PATH", tmp_path): + alias_map, _ = _build_alias_map() + + assert alias_map == {"legacy": "realcomp"} + + +def test_read_aliases_ignores_non_assignment_and_complex_targets( + tmp_path: Path, +) -> None: + """Non-assignment statements and assignments to non-Name targets are + skipped; only simple ``NAME = ...`` assignments are read.""" + init = tmp_path / "__init__.py" + init.write_text( + "import os\n" # non-Assign (Import) node -> skipped + "obj.attr = 'v'\n" # Assign with an Attribute target -> skipped + "ALIASES = ['legacy']\n" + ) + aliases, _ = _read_aliases(init, ast) + assert aliases == ["legacy"] + + +# --------------------------------------------------------------------------- +# Finder / loader edge branches +# --------------------------------------------------------------------------- + + +def test_alias_finder_returns_none_when_canonical_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an alias points at a canonical *target* that doesn't exist, the + finder declines (returns None) and lets normal import machinery report + the missing module.""" + _patch_alias_map(monkeypatch, {"broken_alias": "definitely_not_a_real_component"}) + finder = _AliasFinder() + assert finder.find_spec("esphome.components.broken_alias", None) is None + + +def test_alias_finder_reraises_when_canonical_dependency_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the canonical module exists but fails to import one of its own + dependencies, the finder surfaces that real error instead of masking it + as an unresolved alias (which would silently fall through to a confusing + 'no module named ').""" + _patch_alias_map(monkeypatch, {"some_alias": "real_canonical"}) + + def boom(name: str) -> None: + raise ModuleNotFoundError("No module named 'missing_dep'", name="missing_dep") + + monkeypatch.setattr("esphome.loader.importlib.import_module", boom) + finder = _AliasFinder() + with pytest.raises(ModuleNotFoundError, match="missing_dep"): + finder.find_spec("esphome.components.some_alias", None) + + +def test_install_alias_finder_is_idempotent() -> None: + """The finder is installed once at import; calling the installer again is + a no-op (no duplicate ``_AliasFinder`` on ``sys.meta_path``).""" + before = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] + assert len(before) == 1 # installed at module import time + loader_mod._install_alias_finder() + after = [e for e in sys.meta_path if isinstance(e, _AliasFinder)] + assert len(after) == 1 + + +def test_get_component_alias_to_missing_canonical_returns_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If an alias resolves to a canonical component that can't be loaded, + ``get_component`` returns None and caches no bogus manifest.""" + _patch_alias_map(monkeypatch, {"ghost_alias": "definitely_not_a_real_component"}) + loader_mod._COMPONENT_CACHE.pop("ghost_alias", None) + + assert get_component("ghost_alias") is None + assert "ghost_alias" not in loader_mod._COMPONENT_CACHE + + +# --------------------------------------------------------------------------- +# YAML pre-pass: empty-map fast path + validate_config integration +# --------------------------------------------------------------------------- + + +def test_resolve_component_aliases_noop_when_no_aliases_declared( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When no component declares an alias, the pre-pass returns immediately + without inspecting or mutating the config.""" + from esphome.config import _resolve_component_aliases + + monkeypatch.setattr("esphome.loader.get_alias_metadata", dict) # empty map + config = {"esphome": {"name": "t"}, "rp2040": {"board": "x"}} + original = dict(config) + _resolve_component_aliases(config) + assert config == original + + +def _default_component_mock() -> Mock: + """A permissive component mock that validates any config (ALLOW_EXTRA).""" + return Mock( + auto_load=[], + is_platform_component=False, + is_platform=False, + multi_conf=False, + multi_conf_no_default=False, + dependencies=[], + conflicts_with=[], + config_schema=cv.Schema({}, extra=cv.ALLOW_EXTRA), + ) + + +@pytest.mark.usefixtures("setup_core") +def test_validate_config_renames_alias_key( + mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """End-to-end: a legacy top-level key is renamed to its canonical name + before the rest of ``validate_config`` runs, and validation succeeds. + + A real ``esp32`` target platform is included so ``preload_core_config`` + is satisfied and validation runs to completion (the renamed canonical + key is loaded via the mocked, permissive component).""" + mock_get_component.side_effect = lambda name: _default_component_mock() + monkeypatch.setattr( + "esphome.loader.get_alias_metadata", + lambda: { + "legacyfoo": AliasMeta(canonical="newcomp", removal_version="2027.6.0") + }, + ) + CORE.data.pop("_component_aliases_warned", None) + + raw_config = { + "esphome": {"name": "test"}, + "esp32": {"board": "esp32dev"}, + "legacyfoo": {"opt": 1}, + } + result = esphome_config.validate_config(raw_config, {}) + + assert not result.errors, f"unexpected errors: {result.errors}" + assert "newcomp" in result + assert "legacyfoo" not in result + + +@pytest.mark.usefixtures("setup_core") +def test_validate_config_reports_alias_conflict_as_error( + mock_get_component: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """If both the legacy and canonical keys are present, ``validate_config`` + surfaces the conflict as a config error (the ``vol.Invalid`` path).""" + mock_get_component.return_value = _default_component_mock() + monkeypatch.setattr( + "esphome.loader.get_alias_metadata", + lambda: {"legacyfoo": AliasMeta(canonical="newcomp", removal_version=None)}, + ) + CORE.data.pop("_component_aliases_warned", None) + + raw_config = { + "esphome": {"name": "test"}, + "newcomp": {"opt": 1}, + "legacyfoo": {"opt": 2}, + } + result = esphome_config.validate_config(raw_config, {}) + + assert result.errors + assert "Both 'legacyfoo:'" in str(result.errors) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e99a630e83..acd39cedc6 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -32,6 +32,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_idedata, command_rename, command_run, command_update_all, @@ -689,6 +690,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: assert result == ["192.168.1.100"] +def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None: + """A .local device with mDNS disabled resolves via the dashboard-supplied cache.""" + setup_core( + config={ + CONF_API: {}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], + CONF_MDNS: {CONF_DISABLED: True}, + }, + address="esp32-a1s.local", + ) + CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]}) + + for purpose in (Purpose.LOGGING, Purpose.UPLOADING): + result = choose_upload_log_host( + default="OTA", check_default=None, purpose=purpose + ) + assert result == ["192.168.1.50"] + + def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: """Test OTA device when API is configured (no upload without OTA in config).""" setup_core(config={CONF_API: {}}, address="192.168.1.100") @@ -3135,6 +3155,22 @@ def test_has_resolvable_address() -> None: setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None) assert has_resolvable_address() is False + # mDNS disabled + .local, but the dashboard cached the address -> resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache( + mdns_cache={"esphome-device.local": ["192.168.1.100"]} + ) + assert has_resolvable_address() is True + + # mDNS disabled + .local, cache present but missing this host -> not resolvable + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]}) + assert has_resolvable_address() is False + def test_has_name_add_mac_suffix() -> None: """Test has_name_add_mac_suffix function.""" @@ -6118,6 +6154,15 @@ def test_should_subscribe_states_env_suppresses() -> None: assert _should_subscribe_states(args) is False +def test_should_subscribe_states_env_enables() -> None: + """Test that ESPHOME_LOG_STATES=true enables states by default.""" + from esphome.__main__ import _should_subscribe_states + + args = parse_args(["esphome", "logs", "device.yaml"]) + with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}): + assert _should_subscribe_states(args) is True + + def test_should_subscribe_states_flag_overrides_env() -> None: """Test that --states overrides ESPHOME_LOG_STATES=false.""" from esphome.__main__ import _should_subscribe_states @@ -6202,10 +6247,39 @@ def test_command_run_defaults_subscribe_states_true( ), patch("esphome.__main__.upload_program", return_value=(0, "192.168.1.100")), patch("esphome.__main__.get_serial_ports", return_value=[]), + patch.dict(os.environ, {}, clear=False), ): + # Ensure the default behavior is not affected by an ambient + # ESPHOME_LOG_STATES set in the test runner's environment. + os.environ.pop("ESPHOME_LOG_STATES", None) result = command_run(args, CORE.config) assert result == 0 mock_run_logs.assert_called_once_with( CORE.config, ["192.168.1.100"], subscribe_states=True ) + + +def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: + """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} + + with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get: + result = command_idedata(MagicMock(), CORE.config) + + assert result == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def test_command_idedata_esp_idf_no_build_errors() -> None: + """Under ESP-IDF, a missing build (no idedata) returns an error, not a crash.""" + setup_core() + CORE.toolchain = Toolchain.ESP_IDF + + with patch("esphome.espidf.toolchain.get_idedata", return_value=None): + result = command_idedata(MagicMock(), CORE.config) + + assert result == 1 diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 9652ad08eb..04c712f0b7 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -58,6 +58,9 @@ def nrf52_dirs(setup_core: Path) -> SimpleNamespace: toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION for d in (python_env, framework, toolchain_dir): d.mkdir(parents=True, exist_ok=True) + zephyr_scripts = framework / "zephyr" / "scripts" + zephyr_scripts.mkdir(parents=True, exist_ok=True) + (zephyr_scripts / "requirements.txt").touch() return SimpleNamespace( python_env=python_env, framework=framework, @@ -102,6 +105,7 @@ class TestCheckAndInstall: ) -> None: """All three sentinels present → nothing downloaded or compiled.""" (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.toolchain / ".ready").touch() @@ -121,11 +125,13 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_called_once() - # pip install west, west init, west update - assert mock_nrf52_ops.run_command_ok.call_count == 3 - mock_nrf52_ops.download_from_mirrors.assert_called_once() - mock_nrf52_ops.archive_extract_all.assert_called_once() + # pip install requirements, west init, west update, pip install zephyr reqs + assert mock_nrf52_ops.run_command_ok.call_count == 4 + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 + assert mock_nrf52_ops.archive_extract_all.call_count == 2 assert (nrf52_dirs.python_env / ".ready").exists() + assert (nrf52_dirs.python_env / ".zephyr_reqs_ready").exists() assert (nrf52_dirs.framework / ".ready").exists() assert (nrf52_dirs.toolchain / ".ready").exists() @@ -140,9 +146,10 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_not_called() - # west init + west update only (no pip install) - assert mock_nrf52_ops.run_command_ok.call_count == 2 - mock_nrf52_ops.download_from_mirrors.assert_called_once() + # west init, west update, pip install zephyr reqs + assert mock_nrf52_ops.run_command_ok.call_count == 3 + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 def test_toolchain_only_missing( self, @@ -151,24 +158,26 @@ class TestCheckAndInstall: ) -> None: """Venv and framework ready → only toolchain downloaded and extracted.""" (nrf52_dirs.python_env / ".ready").touch() + (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() check_and_install() mock_nrf52_ops.create_venv.assert_not_called() mock_nrf52_ops.run_command_ok.assert_not_called() - mock_nrf52_ops.download_from_mirrors.assert_called_once() - mock_nrf52_ops.archive_extract_all.assert_called_once() + # minimal SDK + per-arch toolchain + assert mock_nrf52_ops.download_from_mirrors.call_count == 2 + assert mock_nrf52_ops.archive_extract_all.call_count == 2 - def test_west_install_failure_raises( + def test_requirements_install_failure_raises( self, nrf52_dirs: SimpleNamespace, mock_nrf52_ops: SimpleNamespace, ) -> None: - """Failing pip install west raises EsphomeError.""" + """Failing pip install -r requirements.txt raises EsphomeError.""" mock_nrf52_ops.run_command_ok.return_value = False - with pytest.raises(EsphomeError, match="Install west"): + with pytest.raises(EsphomeError, match="Install requirements"): check_and_install() def test_framework_init_failure_raises( diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index a37b19f584..568b43a259 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -304,6 +304,11 @@ def test_run_platformio_cli_sets_environment_variables( ) assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ + # Caps git's upward search at the config dir so an uninitialized or + # corrupt parent git repo can't break the framework's `git describe`. + assert str(CORE.config_dir) in os.environ["GIT_CEILING_DIRECTORIES"].split( + os.pathsep + ) # Check command was called correctly — runs PlatformIO as a subprocess # via the esphome.platformio.runner entry point.